E5d-5: 줄 메뉴 · 지우기 · 옮기기 · rsync · 파일 열기 흐름을 모듈로 (explorer_page 3,531 → 2,032)
- explorer/write_guard.dart: ExplorerGuard (압축 안 · 시스템 폴더 · 루트는 읽기 전용, 압축에서 꺼내기) - explorer/explorer_jobs.dart: ExplorerJobs (도는 복사 · 이동 · rsync 목록, 끝나면 잠깐 뒤 내림, 이어받기) - explorer/transfer_flow.dart: ExplorerTransfers (복사 · 이동 · 담기 · 붙여넣기 · 확인 창 · rsync 실행 파일) - explorer/rsync_flow.dart: ExplorerRsyncFlow (Rsync 화면 → ← ⇄) - explorer/delete_flow.dart: ExplorerDeleteFlow (휴지통 · 앱 휴지통 · 영구 삭제 · 되돌리기 · 휴지통 열기) - explorer/open_flow.dart: ExplorerOpener (file_opener 의 길대로 실제로 열기 · 보기 · 재생 · 압축 · PDF · 공유) - explorer/item_menu.dart · item_actions.dart: ExplorerItemMenu · 정보 · 경로 복사 · 숨기기 확인 · .nomedia · 아이콘 줄 - explorer/name_dialog.dart: askName - ExplorerSelect.targets 더함, ExplorerCommands 24 → 16 - 약속 시험: test/explorer_flows_contract_test.dart (버튼 약속 시험은 실제 흐름으로) - 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_pages
parent
77d962bcdf
commit
4b3ccb0c4f
|
|
@ -0,0 +1,276 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../app/delete_ops.dart';
|
||||
import '../../core/app_trash.dart';
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../../platform/windows/recycle_bin.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../notice.dart';
|
||||
import '../theme.dart';
|
||||
import '../trash_page.dart';
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_pane.dart';
|
||||
import 'explorer_select.dart';
|
||||
import 'write_guard.dart';
|
||||
|
||||
/// 약속 (E5d-5): 지우기 흐름. Windows 의 로컬 파일은 기본으로 휴지통 (창에서 "영구 삭제" 를 고르거나 [shiftDelete]),
|
||||
/// Android 는 앱 휴지통 ([appTrash], 보관 일이 0 이면 끔), WebDAV 등 휴지통이 없는 곳은 처음부터 영구 삭제로 묻는다.
|
||||
/// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다.
|
||||
/// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다
|
||||
class ExplorerDeleteFlow {
|
||||
ExplorerDeleteFlow(this.ctl, this.sel, this.guard, {required this.androidTrash});
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerSelect sel;
|
||||
final ExplorerGuard guard;
|
||||
|
||||
/// 앱 휴지통을 쓰는 기기인지 (Android)
|
||||
final bool Function() androidTrash;
|
||||
|
||||
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
|
||||
bool shiftDelete = false;
|
||||
|
||||
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
|
||||
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
|
||||
Future<void> run(BuildContext context, ExplorerPane pane, List<String> paths) async {
|
||||
if (guard.blockArchive(paths)) return;
|
||||
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
|
||||
final shift = shiftDelete;
|
||||
shiftDelete = false;
|
||||
if (paths.isEmpty) {
|
||||
ctl.host.snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
|
||||
return;
|
||||
}
|
||||
// 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다)
|
||||
final local = Platform.isWindows && paths.every((x) => !isDav(x));
|
||||
// X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔)
|
||||
final trash = appTrash;
|
||||
final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x));
|
||||
final canRecycle = (local && ctl.c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
|
||||
var permanent = !canRecycle || shift;
|
||||
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
|
||||
final hiddenInside = [
|
||||
for (final h in ctl.c.settings.explorerHiddenItems)
|
||||
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
|
||||
].length;
|
||||
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
|
||||
// U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소]
|
||||
final ok = await confirmDialog(
|
||||
context,
|
||||
title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'),
|
||||
danger: () => permanent,
|
||||
confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'),
|
||||
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
|
||||
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(paths.length == 1
|
||||
? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}'
|
||||
'\n${vDirname(paths.first)}'
|
||||
: [
|
||||
permanent
|
||||
? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length])
|
||||
: trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]),
|
||||
for (final x in paths.take(8)) '· ${vBasename(x)}',
|
||||
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
||||
{for (final x in paths) vDirname(x)}.join('\n'),
|
||||
].join('\n')),
|
||||
// 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다)
|
||||
if (paths.any(vIsDirSync))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
),
|
||||
if (hiddenInside > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
),
|
||||
if (local && !canRecycle)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
),
|
||||
if (canTrash && !permanent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [ctl.c.settings.androidTrashDays]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
if (canRecycle)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: permanent,
|
||||
onChanged: (v) => set(() => permanent = v ?? false),
|
||||
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
|
||||
),
|
||||
// U4: 다른 창의 표시는 이번 대상이 아님
|
||||
if (otherMarks > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
if (!ok) return;
|
||||
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b)
|
||||
final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null);
|
||||
final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed;
|
||||
final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since;
|
||||
final recycled = report.recycled, nuked = report.nuked, kept = report.kept;
|
||||
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
|
||||
if (notTrashed.isNotEmpty && context.mounted) {
|
||||
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
|
||||
final nuke = await confirmDialog(
|
||||
context,
|
||||
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
|
||||
title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]),
|
||||
danger: () => true,
|
||||
cancelLabel: tr('그대로 두기'),
|
||||
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]),
|
||||
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
|
||||
const SizedBox(height: 8),
|
||||
Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
|
||||
]),
|
||||
);
|
||||
if (nuke) {
|
||||
for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) {
|
||||
failed.add((x, '$e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((failed.isNotEmpty || tooLong.isNotEmpty) && context.mounted) {
|
||||
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
|
||||
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
|
||||
Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
|
||||
if (tooLong.isNotEmpty) ...[
|
||||
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
|
||||
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
for (final (x, why) in failed.take(30)) ...[
|
||||
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
|
||||
const SizedBox(height: 6),
|
||||
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
|
||||
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
|
||||
]);
|
||||
// U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만
|
||||
final nukeLong = tooLong.isNotEmpty
|
||||
? await confirmDialog(
|
||||
context,
|
||||
icon: failIcon,
|
||||
title: () => failTitle,
|
||||
danger: () => true,
|
||||
cancelLabel: tr('그대로 두기'),
|
||||
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]),
|
||||
body: (ctx, set) => failBody(),
|
||||
)
|
||||
: await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
icon: failIcon,
|
||||
title: Text(failTitle),
|
||||
content: failBody(),
|
||||
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
||||
),
|
||||
) ==
|
||||
true;
|
||||
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
|
||||
if (nukeLong == true) {
|
||||
final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}'];
|
||||
ctl.host.snack(joinSentences([
|
||||
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
||||
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
||||
]));
|
||||
}
|
||||
} else if (!permanent && context.mounted) {
|
||||
final text = joinSentences([
|
||||
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
|
||||
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
|
||||
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
|
||||
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
|
||||
]);
|
||||
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
|
||||
showNotice(ScaffoldMessenger.of(context),
|
||||
text: text,
|
||||
action: trashed.isNotEmpty
|
||||
? (tr('되돌리기'), () => _undoTrash(trashed))
|
||||
: sent.isEmpty
|
||||
? null
|
||||
: (tr('되돌리기'), () => _undoRecycle(sent, since)));
|
||||
}
|
||||
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
|
||||
await ctl.followHidden(gone: [
|
||||
for (final x in paths)
|
||||
if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x,
|
||||
]);
|
||||
for (final x in ctl.panes) {
|
||||
paths.forEach(x.sel.forget);
|
||||
if (sel.selecting && x.sel.isEmpty) sel.selecting = false;
|
||||
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
||||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
|
||||
}
|
||||
await ctl.refreshAll(paths.map(p.dirname));
|
||||
}
|
||||
|
||||
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
|
||||
AppTrash? get appTrash => androidTrash() && ctl.c.settings.androidTrashDays > 0
|
||||
? AppTrash([for (final (v, _) in ctl.local) v])
|
||||
: null;
|
||||
|
||||
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
|
||||
Future<void> _undoTrash(List<TrashItem> items) async {
|
||||
final trash = appTrash;
|
||||
if (trash == null) return;
|
||||
final back = await undoTrash(trash, items);
|
||||
await ctl.refreshAll(back.map(p.dirname));
|
||||
ctl.host.snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
|
||||
}
|
||||
|
||||
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
|
||||
Future<void> openTrash(BuildContext context) async {
|
||||
final trash = appTrash;
|
||||
if (trash == null) {
|
||||
if (Platform.isWindows) {
|
||||
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
|
||||
} else {
|
||||
ctl.host.snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final back = await Navigator.of(context).push<List<String>>(
|
||||
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: ctl.c.settings.androidTrashDays)));
|
||||
if (back != null && back.isNotEmpty && context.mounted) await ctl.refreshAll(back.map(p.dirname));
|
||||
}
|
||||
|
||||
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
|
||||
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
|
||||
final (:back, failed: why) = undoRecycle(paths, since);
|
||||
final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
|
||||
await ctl.refreshAll(paths.map(p.dirname));
|
||||
for (final x in ctl.panes) {
|
||||
x.changed();
|
||||
}
|
||||
ctl.host.snack(joinSentences([
|
||||
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
|
||||
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,13 @@ import '../monitor_page.dart';
|
|||
import '../vault_page.dart' show openVault;
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_pane.dart';
|
||||
import 'delete_flow.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 {
|
||||
|
|
@ -92,28 +98,21 @@ abstract interface class ExplorerCommands {
|
|||
/// 창 · 메뉴를 띄울 곳
|
||||
BuildContext get context;
|
||||
|
||||
/// 다음 지우기는 휴지통을 거치지 않음 (Shift+Delete · [삭제] 길게 누르기 - 창에서 한 번 더 확인)
|
||||
abstract bool shiftDelete;
|
||||
/// 복사 · 이동 갈 곳 (두 창 · 숨은 다른 창의 지금 폴더, 없으면 null - 담아 두기)
|
||||
String? get otherDir;
|
||||
|
||||
/// 버튼이 다룰 것 (표시한 것, 없으면 지금 줄)
|
||||
List<String> targetsOf(ExplorerPane pane);
|
||||
Future<void> runRsync({required bool right, required bool left});
|
||||
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false});
|
||||
Future<void> playFolder(String dir);
|
||||
/// 두 창으로 보이는지
|
||||
bool get dual;
|
||||
|
||||
/// 81: 빠른 보기 - [path] (없으면 지금 줄). 같은 폴더에 보이는 항목을 ← → 로
|
||||
Future<void> quickLook(ExplorerPane pane, [String? path]);
|
||||
Future<void> addMkv(List<String> paths);
|
||||
|
||||
/// 압축 안이면 막고 알림 (막았으면 true)
|
||||
bool blockArchive(List<String> paths, {bool self = false});
|
||||
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false});
|
||||
Future<void> renameItem(ExplorerPane pane, String path);
|
||||
Future<void> batchRename(ExplorerPane pane, List<String> paths);
|
||||
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move});
|
||||
Future<void> paste(ExplorerPane pane, String dest);
|
||||
Future<void> deleteItems(ExplorerPane pane, List<String> paths);
|
||||
Future<void> openSearch(ExplorerPane pane);
|
||||
Future<void> openStorage(ExplorerPane pane, String dir);
|
||||
Future<void> openWifiShare(String dir);
|
||||
Future<void> openTrash();
|
||||
Future<void> sortDialog();
|
||||
Future<void> historyDialog(ExplorerPane pane);
|
||||
Future<void> openBookmarks(ExplorerPane pane);
|
||||
|
|
@ -123,13 +122,19 @@ abstract interface class ExplorerCommands {
|
|||
}
|
||||
|
||||
/// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다.
|
||||
/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host)
|
||||
/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 복사 · 이동 · 지우기 · rsync 는 흐름 모듈, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host)
|
||||
class ExplorerButtons {
|
||||
ExplorerButtons(this.ctl, this.sel, this.cmd);
|
||||
ExplorerButtons(this.ctl, this.sel, this.cmd,
|
||||
{required this.guard, required this.transfers, required this.deletes, required this.rsync, required this.opener});
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerSelect sel;
|
||||
final ExplorerCommands cmd;
|
||||
final ExplorerGuard guard;
|
||||
final ExplorerTransfers transfers;
|
||||
final ExplorerDeleteFlow deletes;
|
||||
final ExplorerRsyncFlow rsync;
|
||||
final ExplorerOpener opener;
|
||||
|
||||
/// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null)
|
||||
static String? longPressLabel(ExplorerButton b) => switch (b) {
|
||||
|
|
@ -147,7 +152,7 @@ class ExplorerButtons {
|
|||
case ExplorerButton.move:
|
||||
return press(ExplorerButton.copy);
|
||||
case ExplorerButton.delete:
|
||||
cmd.shiftDelete = true;
|
||||
deletes.shiftDelete = true;
|
||||
return press(ExplorerButton.delete);
|
||||
default:
|
||||
return;
|
||||
|
|
@ -167,30 +172,30 @@ class ExplorerButtons {
|
|||
case ExplorerButton.monitor:
|
||||
await MonitorPage.open(cmd.context, ctl.c);
|
||||
case ExplorerButton.toRight:
|
||||
await cmd.runRsync(right: true, left: false);
|
||||
await rsync.run(cmd.context, right: true, left: false);
|
||||
case ExplorerButton.toLeft:
|
||||
await cmd.runRsync(right: false, left: true);
|
||||
await rsync.run(cmd.context, right: false, left: true);
|
||||
case ExplorerButton.both:
|
||||
await cmd.runRsync(right: true, left: true);
|
||||
await rsync.run(cmd.context, right: true, left: true);
|
||||
case ExplorerButton.refresh:
|
||||
// 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게)
|
||||
await ctl.reloadOpen();
|
||||
case ExplorerButton.play:
|
||||
final t = cmd.targetsOf(pane);
|
||||
final t = sel.targets(pane);
|
||||
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
|
||||
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
|
||||
if (videos.isNotEmpty) {
|
||||
await cmd.play(videos, keepOrder: !pane.sel.isEmpty);
|
||||
await opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty);
|
||||
} else {
|
||||
await cmd.playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
|
||||
await opener.playFolder(cmd.context, t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
|
||||
}
|
||||
case ExplorerButton.addMkv:
|
||||
final t = cmd.targetsOf(pane);
|
||||
final t = sel.targets(pane);
|
||||
await cmd.addMkv(t.isEmpty ? [pane.current] : t);
|
||||
case ExplorerButton.newFolder:
|
||||
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
|
||||
if (cmd.blockArchive([pane.current], self: true)) return;
|
||||
final name = await cmd.askName(tr('새 폴더'), '');
|
||||
if (guard.blockArchive([pane.current], self: true)) return;
|
||||
final name = await askName(cmd.context, tr('새 폴더'), '');
|
||||
if (name == null) return;
|
||||
try {
|
||||
final parent = pane.current;
|
||||
|
|
@ -211,7 +216,7 @@ class ExplorerButtons {
|
|||
ctl.host.snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
|
||||
}
|
||||
case ExplorerButton.rename:
|
||||
final t = cmd.targetsOf(pane);
|
||||
final t = sel.targets(pane);
|
||||
if (t.isEmpty) {
|
||||
ctl.host.snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
|
||||
return;
|
||||
|
|
@ -220,13 +225,13 @@ class ExplorerButtons {
|
|||
if (t.length > 1) return cmd.batchRename(pane, t);
|
||||
await cmd.renameItem(pane, t.first);
|
||||
case ExplorerButton.copy:
|
||||
await cmd.transfer(pane, cmd.targetsOf(pane), move: false);
|
||||
await transfers.transfer(cmd.context, pane, sel.targets(pane), move: false, otherDir: cmd.otherDir);
|
||||
case ExplorerButton.move:
|
||||
await cmd.transfer(pane, cmd.targetsOf(pane), move: true);
|
||||
await transfers.transfer(cmd.context, pane, sel.targets(pane), move: true, otherDir: cmd.otherDir);
|
||||
case ExplorerButton.paste:
|
||||
await cmd.paste(pane, pane.current);
|
||||
await transfers.paste(cmd.context, pane, pane.current);
|
||||
case ExplorerButton.delete:
|
||||
await cmd.deleteItems(pane, cmd.targetsOf(pane));
|
||||
await deletes.run(cmd.context, pane, sel.targets(pane));
|
||||
case ExplorerButton.search:
|
||||
await cmd.openSearch(pane);
|
||||
case ExplorerButton.storage:
|
||||
|
|
@ -242,7 +247,7 @@ class ExplorerButtons {
|
|||
await openVault(cmd.context, ctl.c);
|
||||
}
|
||||
case ExplorerButton.trash:
|
||||
await cmd.openTrash();
|
||||
await deletes.openTrash(cmd.context);
|
||||
case ExplorerButton.sort:
|
||||
await cmd.sortDialog();
|
||||
case ExplorerButton.hidden:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../app/transfer_job.dart';
|
||||
import '../../core/file_ops.dart' show samePath;
|
||||
import '../../core/vfs.dart' show vBasename, vDirname, vExists, vJoin;
|
||||
import 'explorer_controller.dart';
|
||||
|
||||
/// 약속 (E5d-5): 지금 도는 복사 · 이동 · rsync 작업 (아래에서 올라오는 진행 패널의 목록). 23: 여럿이 함께 돈다.
|
||||
/// 끝난 것은 진행 막대가 잠깐 100% 를 보인 뒤 ([dropDelay]) 목록에서 내려간다. 바뀌면 알림
|
||||
class ExplorerJobs extends ChangeNotifier {
|
||||
ExplorerJobs(this.ctl);
|
||||
|
||||
final ExplorerController ctl;
|
||||
|
||||
static const dropDelay = Duration(milliseconds: 1200);
|
||||
|
||||
List<TransferJob> list = const [];
|
||||
|
||||
bool get isEmpty => list.isEmpty;
|
||||
|
||||
/// 새 작업을 목록에 (끝난 것은 이때 뺀다)
|
||||
void start(TransferJob job) {
|
||||
list = [...list.where((j) => !j.finished), job];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 같은 것을 같은 곳으로 복사 · 이동하는 중인지 (그것만 막는다)
|
||||
bool busyWith(List<String> sources, String dest) =>
|
||||
list.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources));
|
||||
|
||||
/// [jobs] 를 잠깐 뒤 목록에서 뺀다
|
||||
Future<void> dropLater(Iterable<TransferJob> jobs) async {
|
||||
await Future<void>.delayed(dropDelay);
|
||||
if (!ctl.host.alive || !list.any(jobs.contains)) return;
|
||||
list = [for (final j in list) if (!jobs.contains(j)) j];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로)
|
||||
Future<void> followMoved(TransferJob job) async {
|
||||
if (!job.move || job.contents || ctl.c.settings.explorerHiddenItems.isEmpty) return;
|
||||
final moved = <(String, String)>[];
|
||||
for (final s in job.sources) {
|
||||
final to = vJoin(job.dest, vBasename(s));
|
||||
if (!await vExists(s) && await vExists(to)) moved.add((s, to));
|
||||
}
|
||||
await ctl.followHidden(moved: moved);
|
||||
}
|
||||
|
||||
/// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
|
||||
void adopt(TransferJob job) {
|
||||
if (list.contains(job)) return;
|
||||
list = [...list, job];
|
||||
notifyListeners();
|
||||
unawaited(job.done.then((_) async {
|
||||
if (!ctl.host.alive) return;
|
||||
await followMoved(job);
|
||||
if (!ctl.host.alive) return;
|
||||
await ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]);
|
||||
for (final x in ctl.panes) {
|
||||
x.changed();
|
||||
}
|
||||
await dropLater([job]);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +120,14 @@ class ExplorerSelect extends ChangeNotifier {
|
|||
|
||||
bool isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path);
|
||||
|
||||
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
|
||||
List<String> targets(ExplorerPane pane) {
|
||||
if (!pane.sel.isEmpty) return pane.sel.marked;
|
||||
final f = pane.focused;
|
||||
if (f == null || samePath(f, pane.root)) return const [];
|
||||
return [f];
|
||||
}
|
||||
|
||||
Future<List<String>> _childPaths(ExplorerPane pane, String dir) async {
|
||||
await ctl.load(pane, dir);
|
||||
return [for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../core/file_attributes.dart' show readFileAttributes;
|
||||
import '../../core/file_ops.dart' show FileEntry, FileOps, formatSize;
|
||||
import '../../core/ftp.dart' show FtpRegistry;
|
||||
import '../../core/name_clash.dart' show fileDateText;
|
||||
import '../../core/playlist.dart' show isVideoFile;
|
||||
import '../../core/sftp.dart' show SftpRegistry;
|
||||
import '../../core/smb.dart' show SmbRegistry;
|
||||
import '../../core/vfs.dart';
|
||||
import '../../core/webdav.dart' show DavRegistry;
|
||||
import '../../l10n/tr.dart';
|
||||
import '../theme.dart';
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_pane.dart';
|
||||
import 'file_thumbs.dart' show VideoMeta;
|
||||
|
||||
// 약속 (E5d-5): 줄 메뉴의 항목 하나씩 하는 일 (정보 · 경로 복사 · 목록에서 숨기기 확인 · .nomedia) 과 메뉴 위 아이콘 줄.
|
||||
// 묻는 창은 [취소] 면 아무것도 바꾸지 않는다. 못 하면 까닭을 알린다 (Host)
|
||||
|
||||
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
|
||||
String copyablePath(ExplorerController ctl, String path) {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
// 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/)
|
||||
if (d.isSftp) {
|
||||
final s = SftpRegistry.server(d.server);
|
||||
if (s == null) return ctl.displayPath(path);
|
||||
final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}');
|
||||
return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}';
|
||||
}
|
||||
// l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음)
|
||||
if (d.isFtp) {
|
||||
final s = FtpRegistry.server(d.server);
|
||||
if (s == null) return ctl.displayPath(path);
|
||||
final implicit = s.security == 'implicit';
|
||||
final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}'
|
||||
'${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||||
}
|
||||
// 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음)
|
||||
if (d.isSmb) {
|
||||
final s = SmbRegistry.server(d.server);
|
||||
if (s == null) return ctl.displayPath(path);
|
||||
final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||||
}
|
||||
try {
|
||||
return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString();
|
||||
} catch (_) {
|
||||
return ctl.displayPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 정보 창 - 경로 · 크기 · 만든 · 바뀐 날 · 속성 · 동영상 정보 (보기만)
|
||||
Future<void> showFileInfo(BuildContext context, ExplorerController ctl, FileEntry e) async {
|
||||
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size;
|
||||
final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(ctl.c, e.path) : null;
|
||||
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만)
|
||||
final attrs = isDav(e.path) ? null : readFileAttributes(e.path);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(e.name),
|
||||
content: SelectableText([
|
||||
e.path,
|
||||
'${tr('크기')}: ${formatSize(size)}',
|
||||
if (attrs?.created != null) '${tr('만든 날')}: ${fileDateText(attrs!.created!)}',
|
||||
if (e.modified.year > 1) '${tr('바뀐 날')}: ${fileDateText(e.modified)}',
|
||||
if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}',
|
||||
if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
|
||||
].join('\n')),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// X-plore 숨기기: 목록에서 숨기기 전에 무엇인지 알리고 묻기 ([다음부터 묻지 않기] 는 설정에)
|
||||
Future<bool> confirmAppHide(BuildContext context, ExplorerController ctl, List<String> targets) async {
|
||||
var dontAsk = false;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
|
||||
const SizedBox(height: 6),
|
||||
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: dontAsk,
|
||||
onChanged: (v) => set(() => dontAsk = v ?? false),
|
||||
title: Text(tr('다음부터 묻지 않기')),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok != true) return false;
|
||||
if (dontAsk) await ctl.c.updateSettings((s) => s.explorerHideConfirm = false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
|
||||
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
|
||||
Future<void> setNomedia(BuildContext context, ExplorerController ctl, ExplorerPane pane, String dir, {required bool on}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 6),
|
||||
Text(on
|
||||
? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.')
|
||||
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
|
||||
const SizedBox(height: 6),
|
||||
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(on ? tr('숨기기') : tr('다시 보이기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
final f = File(p.join(dir, '.nomedia'));
|
||||
try {
|
||||
if (on) {
|
||||
if (!await f.exists()) await f.writeAsBytes(const []);
|
||||
} else if (await f.exists()) {
|
||||
await f.delete();
|
||||
}
|
||||
} catch (err) {
|
||||
ctl.host.snack(trf('바꾸지 못했습니다: {0}', [err]));
|
||||
return;
|
||||
}
|
||||
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
|
||||
await ctl.c.services.shell.mediaScan([f.path, dir]);
|
||||
await ctl.refreshAll([dir]);
|
||||
pane.changed();
|
||||
ctl.host.snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
|
||||
}
|
||||
|
||||
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||||
class MenuIconRow extends PopupMenuEntry<String> {
|
||||
final List<(String, IconData, String, String, bool)> items;
|
||||
const MenuIconRow(this.items, {super.key});
|
||||
|
||||
@override
|
||||
double get height => 60;
|
||||
|
||||
@override
|
||||
bool represents(String? value) => false;
|
||||
|
||||
@override
|
||||
State<MenuIconRow> createState() => MenuIconRowState();
|
||||
}
|
||||
|
||||
class MenuIconRowState extends State<MenuIconRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final (id, icon, label, tip, on) in widget.items)
|
||||
Tooltip(
|
||||
message: tip,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: on ? () => Navigator.pop(context, id) : null,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: on,
|
||||
label: tip,
|
||||
excludeSemantics: true,
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Opacity(
|
||||
opacity: on ? 1 : 0.38,
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(height: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../app/settings.dart' show FolderBookmark;
|
||||
import '../../core/app_hidden.dart';
|
||||
import '../../core/archive_ops.dart' show archiveKindOf;
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
|
||||
import '../../core/reader_sources.dart' show extOf, zipExtensions;
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../archive_ui.dart' show createZipFlow;
|
||||
import '../file_tools.dart' show showFileHash, showFolderProperties;
|
||||
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_pane.dart';
|
||||
import 'explorer_select.dart';
|
||||
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});
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerSelect sel;
|
||||
final ExplorerCommands cmd;
|
||||
final ExplorerGuard guard;
|
||||
final ExplorerTransfers transfers;
|
||||
final ExplorerDeleteFlow deletes;
|
||||
final ExplorerOpener opener;
|
||||
|
||||
/// .nomedia 항목을 보일 기기인지 (Android)
|
||||
final bool Function() nomedia;
|
||||
|
||||
Future<void> show(BuildContext context, ExplorerPane pane, FileEntry e, Offset at) async {
|
||||
ctl
|
||||
..active = ctl.panes.indexOf(pane)
|
||||
..update();
|
||||
pane.focused = e.path;
|
||||
pane.changed();
|
||||
final video = !e.isDir && isVideoFile(e.path);
|
||||
final audio = !e.isDir && isAudioFile(e.path);
|
||||
final dav = isDav(e.path);
|
||||
// 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만)
|
||||
final viewer = ctl.c.settings.components.contains('viewer');
|
||||
final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path];
|
||||
final images = [for (final x in picked) if (ctl.c.settings.imageExts.contains(extOf(x))) x];
|
||||
final ext = extOf(e.path);
|
||||
final viewable = viewer && !e.isDir &&
|
||||
(ctl.c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
|
||||
final dual = cmd.dual;
|
||||
// 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사)
|
||||
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);
|
||||
// 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
|
||||
final items = ctl.rsync
|
||||
? <(String, IconData, String)>[
|
||||
('pickRsync', Icons.check_circle_outline,
|
||||
sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')),
|
||||
('open', Icons.folder_open, tr('열기')),
|
||||
('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||||
('delete', Icons.delete_outline, tr('삭제')),
|
||||
if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||||
('info', Icons.info_outline, tr('정보')),
|
||||
]
|
||||
: <(String, IconData, String)>[
|
||||
// 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목
|
||||
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
|
||||
if (sel.selecting && sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
|
||||
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
|
||||
// 81: 빠른 보기 (PC 는 Space · TC 는 F3)
|
||||
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')),
|
||||
// 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두)
|
||||
if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')),
|
||||
if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
|
||||
// 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일)
|
||||
if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')),
|
||||
// 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만)
|
||||
if (e.isDir && !dav && !ctl.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')),
|
||||
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
|
||||
if (e.isDir) ('folderSize', Icons.data_usage, ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
|
||||
// X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌)
|
||||
if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')),
|
||||
if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')),
|
||||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||||
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||||
// X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로
|
||||
if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')),
|
||||
// X-plore 비교 g: 그림 · 동영상을 썸네일 격자로
|
||||
if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')),
|
||||
if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
|
||||
if (viewable) ('view', Icons.visibility_outlined, tr('보기')),
|
||||
// X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용)
|
||||
if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')),
|
||||
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
|
||||
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
|
||||
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음)
|
||||
if (ctl.c.settings.vaultEnabled && !inArchive)
|
||||
('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')),
|
||||
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
|
||||
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
|
||||
if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||||
if (viewer && images.isNotEmpty && !inArchive)
|
||||
('toPdf', Icons.picture_as_pdf_outlined,
|
||||
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
|
||||
('select', Icons.check_circle_outline, tr('선택')),
|
||||
// X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기
|
||||
targets.every(ctl.appHidden)
|
||||
? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기'))
|
||||
: ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
|
||||
// X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android)
|
||||
if (e.isDir && !dav && !inArchive && nomedia())
|
||||
File(p.join(e.path, '.nomedia')).existsSync()
|
||||
? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))
|
||||
: ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')),
|
||||
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
|
||||
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])),
|
||||
('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('즐겨찾기에 추가')),
|
||||
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||||
('info', Icons.info_outline, tr('정보')),
|
||||
];
|
||||
// 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에)
|
||||
final icons = ctl.rsync
|
||||
? const <(String, IconData, String, String, bool)>[]
|
||||
: <(String, IconData, String, String, bool)>[
|
||||
(dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true),
|
||||
(dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true),
|
||||
// 78: 여럿이면 일괄 이름 바꾸기
|
||||
('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true),
|
||||
('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true),
|
||||
if (ctl.c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))),
|
||||
];
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final pick = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
|
||||
items: [
|
||||
// 77: 여러 개를 골랐으면 무엇에 하는지 먼저
|
||||
if (many)
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
height: 32,
|
||||
child: Text(trf('{0}개 항목', [targets.length]),
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||||
),
|
||||
if (icons.isNotEmpty) ...[MenuIconRow(icons), const PopupMenuDivider(height: 1)],
|
||||
for (final (id, icon, label) in items)
|
||||
PopupMenuItem(
|
||||
value: id,
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (pick == null || !context.mounted) return;
|
||||
switch (pick) {
|
||||
case 'pickRsync':
|
||||
await sel.pick(pane, e);
|
||||
case 'open':
|
||||
await ctl.goTo(pane, e.path);
|
||||
case 'openOther':
|
||||
final other = ctl.panes[1 - ctl.panes.indexOf(pane)];
|
||||
await ctl.goTo(other, e.path);
|
||||
ctl
|
||||
..active = ctl.panes.indexOf(other)
|
||||
..update();
|
||||
case 'playFolder':
|
||||
await opener.playFolder(context, e.path);
|
||||
case 'slideshow':
|
||||
await opener.slideshow(context, pane, e.path);
|
||||
case 'playInternal':
|
||||
await opener.play(context, [e.path], internal: true);
|
||||
case 'grid':
|
||||
await opener.grid(context, pane, e.path);
|
||||
case 'text':
|
||||
await 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);
|
||||
});
|
||||
await ctl.refreshAll([vDirname(e.path)]);
|
||||
case 'openWith':
|
||||
final local = await 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);
|
||||
if (local != null && !await ctl.c.services.shell.openWith(local)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path));
|
||||
case 'addMkv':
|
||||
await cmd.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);
|
||||
case 'extract':
|
||||
await opener.extractArchive(context, e.path, pane);
|
||||
case 'storage':
|
||||
await cmd.openStorage(pane, e.path);
|
||||
case 'wifiShare':
|
||||
await cmd.openWifiShare(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;
|
||||
final made = await createZipFlow(context, ctl.c, items, dir: vDirname(items.first), otherDir: other);
|
||||
if (made != null && context.mounted) {
|
||||
await ctl.refreshAll([vDirname(made)]);
|
||||
pane.focused = made;
|
||||
pane.changed();
|
||||
}
|
||||
case 'appHide':
|
||||
if (ctl.c.settings.explorerHideConfirm && !await confirmAppHide(context, ctl, targets)) return;
|
||||
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
|
||||
...s.explorerHiddenItems,
|
||||
for (final x in targets)
|
||||
if (!ctl.appHidden(x)) x,
|
||||
]);
|
||||
for (final q in ctl.panes) {
|
||||
targets.forEach(q.sel.forget);
|
||||
q.changed();
|
||||
}
|
||||
ctl.update();
|
||||
ctl.host.snack(ctl.showAppHidden
|
||||
? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length])
|
||||
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
|
||||
case 'appUnhide':
|
||||
final keys = {for (final x in targets) HiddenList.keyOf(x)};
|
||||
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
|
||||
for (final x in s.explorerHiddenItems)
|
||||
if (!keys.contains(HiddenList.keyOf(x))) x,
|
||||
]);
|
||||
for (final q in ctl.panes) {
|
||||
q.changed();
|
||||
}
|
||||
ctl.update();
|
||||
case 'nomediaOn' || 'nomediaOff':
|
||||
await setNomedia(context, ctl, pane, e.path, on: pick == 'nomediaOn');
|
||||
case 'props':
|
||||
await showFolderProperties(context, e.path);
|
||||
case 'hash':
|
||||
await showFileHash(context, e.path);
|
||||
case 'folderSize':
|
||||
if (ctl.sizes.busy(e.path)) {
|
||||
ctl.sizes.cancel(e.path);
|
||||
break;
|
||||
}
|
||||
// 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴)
|
||||
ctl.sizes
|
||||
..forget(e.path)
|
||||
..request(e.path);
|
||||
if (context.mounted) {
|
||||
showNotice(ScaffoldMessenger.of(context),
|
||||
text: isDav(e.path)
|
||||
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
|
||||
: trf('"{0}" 크기를 계산합니다.', [e.name]),
|
||||
action: (tr('취소'), () => ctl.sizes.cancel(e.path)));
|
||||
}
|
||||
case 'rename':
|
||||
if (sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) {
|
||||
await cmd.batchRename(pane, pane.sel.marked);
|
||||
} else {
|
||||
await cmd.renameItem(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);
|
||||
case 'move':
|
||||
await 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);
|
||||
case 'clipMove':
|
||||
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));
|
||||
case 'delete':
|
||||
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
|
||||
await deletes.run(context, pane, targets);
|
||||
case 'share':
|
||||
await opener.share(context, targets);
|
||||
case 'copyPath':
|
||||
final paths = targets.map((x) => copyablePath(ctl, x)).toList();
|
||||
await Clipboard.setData(ClipboardData(text: paths.join('\n')));
|
||||
ctl.host.snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single]));
|
||||
case 'bookmark':
|
||||
final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path);
|
||||
await ctl.c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]);
|
||||
ctl.host.snack(trf('즐겨찾기에 넣었습니다: {0}', [name]));
|
||||
ctl.update();
|
||||
case 'view':
|
||||
await opener.open(context, e.path, pane: pane);
|
||||
case 'toPdf':
|
||||
await opener.makePdf(context, images);
|
||||
case 'reveal':
|
||||
await ctl.c.services.shell.revealFile(e.path);
|
||||
case 'info':
|
||||
await showFileInfo(context, ctl, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/tr.dart';
|
||||
|
||||
/// 약속 (E5d-5): 이름 하나를 묻는 창 (새 폴더 · 이름 변경 · PDF 이름 등). 처음 값은 [initial] (확장자 앞까지 고른 채 -
|
||||
/// [selectAll] 이면 모두). 빈 칸 · [취소] 는 null, 아니면 앞뒤 빈칸을 뺀 이름
|
||||
Future<String?> askName(BuildContext context, String title, String initial, {String? hint, bool selectAll = false}) {
|
||||
final ctl = TextEditingController(text: initial);
|
||||
final dot = initial.lastIndexOf('.');
|
||||
ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: ctl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: hint ?? tr('이름')),
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()),
|
||||
child: Text(tr('확인'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../app/component_store.dart';
|
||||
import '../../app/file_opener.dart';
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/hwp_source.dart';
|
||||
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
|
||||
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 '../reader_page.dart';
|
||||
import '../text_viewer.dart';
|
||||
import '../zip_page.dart';
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_pane.dart';
|
||||
import 'explorer_select.dart';
|
||||
import 'file_thumbs.dart';
|
||||
import 'name_dialog.dart';
|
||||
import 'write_guard.dart';
|
||||
|
||||
/// 약속 (E5d-5): 파일을 여는 일. 여는 길은 file_opener ([openRouteFor]) 가 고르고, 여기는 그 길대로 실제로 연다 -
|
||||
/// 내장 플레이어 · 그림 보기 (같은 폴더를 지금 순서로) · PDF · HWP (Android, 못 열면 다른 앱) · 문서 미리 보기 (PDF 로 바꿔) ·
|
||||
/// ZIP (만화 보기 · 목록) · 압축 풀기 · 다른 앱. 압축 안의 파일은 꺼내서 ([ExplorerGuard.extractOne]), WebDAV 등 원격은 받아서 ([fetch]).
|
||||
/// 열 앱이 없으면 글 파일은 글 뷰어로, 아니면 까닭을 알린다 (Host). [otherDirOf]: 압축을 풀 다른 창의 폴더 (없으면 null)
|
||||
class ExplorerOpener {
|
||||
ExplorerOpener(this.ctl, this.sel, this.guard, {required this.otherDirOf});
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerSelect sel;
|
||||
final ExplorerGuard guard;
|
||||
final String? Function(ExplorerPane pane) otherDirOf;
|
||||
|
||||
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
|
||||
static String noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
|
||||
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
|
||||
: tr('이 파일을 열 수 있는 앱이 없습니다.');
|
||||
|
||||
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
|
||||
Future<void> open(BuildContext context, String path, {ExplorerPane? pane}) async {
|
||||
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
|
||||
if (guard.insideArchive(path)) {
|
||||
final local = await guard.extractOne(path);
|
||||
if (local == null || !context.mounted) return;
|
||||
path = local;
|
||||
}
|
||||
// 여는 길은 file_opener (E5c) 가 고른다
|
||||
switch (openRouteFor(path,
|
||||
components: ctl.c.settings.components,
|
||||
imageExts: ctl.c.settings.imageExts,
|
||||
android: Platform.isAndroid,
|
||||
windows: Platform.isWindows,
|
||||
hwpEngine: RhwpEngine.available)) {
|
||||
case OpenRoute.player:
|
||||
return play(context, [path]);
|
||||
case OpenRoute.extractArchive:
|
||||
return extractArchive(context, path, pane ?? ctl.pane);
|
||||
case OpenRoute.images:
|
||||
return viewImages(context, path, pane);
|
||||
case OpenRoute.pdf:
|
||||
return viewPdf(context, path);
|
||||
case OpenRoute.zip:
|
||||
return openZipFile(context, path, pane);
|
||||
// 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로
|
||||
case OpenRoute.hwp:
|
||||
if (await viewHwp(context, path)) return;
|
||||
case OpenRoute.docPreview:
|
||||
return viewDoc(context, path);
|
||||
case OpenRoute.external:
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final local = await fetch(context, path);
|
||||
if (local == null) return;
|
||||
final ok = await ctl.c.services.shell.openWith(local);
|
||||
if (ok || !context.mounted) return;
|
||||
// X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로
|
||||
if (textExtensions.contains(extOf(path))) return viewText(context, local);
|
||||
ctl.host.snack(noAppMessage(path));
|
||||
}
|
||||
|
||||
/// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤)
|
||||
Future<void> viewText(BuildContext context, String path) async {
|
||||
final local = await fetch(context, path);
|
||||
if (local == null || !context.mounted) return;
|
||||
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: local)));
|
||||
}
|
||||
|
||||
/// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로
|
||||
Future<void> grid(BuildContext context, ExplorerPane pane, String dir) async {
|
||||
await ctl.load(pane, dir);
|
||||
if (!context.mounted) return;
|
||||
final list = sel.shown(pane, dir);
|
||||
final at = await Navigator.of(context).push<String>(MaterialPageRoute(
|
||||
builder: (_) => ExplorerGridPage(
|
||||
title: vBasename(dir).isEmpty ? dir : vBasename(dir),
|
||||
entries: list,
|
||||
imageExts: ctl.c.settings.imageExts,
|
||||
videoThumb: (e) => BigVideoThumbs.of(ctl.c, e),
|
||||
onOpen: (e) => open(context, e.path, pane: pane),
|
||||
),
|
||||
));
|
||||
if (at == null || !context.mounted) return;
|
||||
await ctl.goTo(pane, dir);
|
||||
pane.focused = at;
|
||||
pane.changed();
|
||||
}
|
||||
|
||||
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart)
|
||||
Future<String?> fetch(BuildContext context, String path) => fetchDav(context, ctl.c, path);
|
||||
|
||||
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
|
||||
Future<void> play(BuildContext context, List<String> paths, {bool internal = false, bool keepOrder = false}) async {
|
||||
if (!context.mounted || paths.isEmpty) return;
|
||||
await playFiles(context, ctl.c, paths, internal: internal, keepOrder: keepOrder);
|
||||
}
|
||||
|
||||
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
|
||||
Future<void> viewImages(BuildContext context, String path, ExplorerPane? pane) async {
|
||||
final dir = vDirname(path);
|
||||
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
|
||||
List<FileEntry> list;
|
||||
try {
|
||||
list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
|
||||
} catch (_) {
|
||||
list = const [];
|
||||
}
|
||||
final images = [
|
||||
for (final e in list)
|
||||
if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path))) e.path,
|
||||
];
|
||||
if (!images.any((x) => samePath(x, path))) images.insert(0, path);
|
||||
final start = images.indexWhere((x) => samePath(x, path));
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
if (!context.mounted) return;
|
||||
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
|
||||
}
|
||||
|
||||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
|
||||
Future<void> slideshow(BuildContext context, ExplorerPane pane, String dir) async {
|
||||
List<FileEntry> list;
|
||||
try {
|
||||
list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
|
||||
return;
|
||||
}
|
||||
final images = [
|
||||
for (final e in list)
|
||||
if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path)) && (ctl.showAppHidden || !ctl.appHidden(e.path))) e.path,
|
||||
];
|
||||
if (images.isEmpty) {
|
||||
ctl.host.snack(tr('이 폴더에는 그림이 없습니다'));
|
||||
return;
|
||||
}
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
if (!context.mounted) return;
|
||||
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
|
||||
}
|
||||
|
||||
/// 문서 → PDF (진행 창) → 보기
|
||||
Future<void> viewDoc(BuildContext context, String path) async {
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
if (!context.mounted) return;
|
||||
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
|
||||
String? pdf;
|
||||
Object? error;
|
||||
try {
|
||||
final local = await vLocalCopy(path, temp);
|
||||
pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs'));
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
|
||||
if (dialog.cancelled()) return;
|
||||
dialog.close();
|
||||
await dialog.done;
|
||||
if (pdf == null) {
|
||||
ctl.host.snack(trf('미리 볼 수 없습니다: {0}', [error]));
|
||||
return;
|
||||
}
|
||||
PdfSource src;
|
||||
try {
|
||||
src = await PdfSource.open(pdf, tempDir: temp);
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await openReader(context, ctl.c, src);
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
|
||||
Future<bool> viewHwp(BuildContext context, String path) async {
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
if (!context.mounted) return true;
|
||||
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
|
||||
HwpSource? src;
|
||||
Object? error;
|
||||
try {
|
||||
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: ctl.c.note);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
|
||||
if (dialog.cancelled()) {
|
||||
await src?.dispose();
|
||||
return true;
|
||||
}
|
||||
dialog.close();
|
||||
await dialog.done;
|
||||
if (src == null) {
|
||||
ctl.host.snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
await src.dispose();
|
||||
return true;
|
||||
}
|
||||
await openReader(context, ctl.c, src);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> viewPdf(BuildContext context, String path) async {
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
PdfSource src;
|
||||
try {
|
||||
src = await PdfSource.open(path, tempDir: temp);
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await openReader(context, ctl.c, src);
|
||||
await ctl.refreshAll([vDirname(path)]);
|
||||
}
|
||||
|
||||
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
|
||||
Future<void> extractArchive(BuildContext context, String path, ExplorerPane pane) async {
|
||||
final other = otherDirOf(pane);
|
||||
final out = await extractArchiveFlow(context, ctl.c, path, otherDir: other);
|
||||
if (out != null && context.mounted) await ctl.refreshAll([out, vDirname(out)]);
|
||||
}
|
||||
|
||||
Future<void> openZipFile(BuildContext context, String path, ExplorerPane? pane) async {
|
||||
try {
|
||||
if (ctl.c.settings.zipComic && await openZipComic(context, ctl.c, path)) return;
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('열 수 없습니다: {0}', [e]));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final other = pane == null ? null : otherDirOf(pane);
|
||||
final out = await openZip(context, ctl.c, path, otherDir: other);
|
||||
if (out != null) await ctl.refreshAll([out, vDirname(out)]);
|
||||
}
|
||||
|
||||
/// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다)
|
||||
Future<void> makePdf(BuildContext context, List<String> images) async {
|
||||
if (images.isEmpty) return;
|
||||
final dir = vDirname(images.first);
|
||||
final first = vBasename(images.first);
|
||||
final name = await askName(context, tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf');
|
||||
if (name == null || name.trim().isEmpty) return;
|
||||
final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf');
|
||||
final temp = await ctl.c.services.storage.tempDirectory();
|
||||
try {
|
||||
await imagesToPdf(images, out, tempDir: temp);
|
||||
ctl.host.snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)]));
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('PDF 를 만들지 못했습니다: {0}', [e]));
|
||||
}
|
||||
await ctl.refreshAll([dir]);
|
||||
}
|
||||
|
||||
Future<void> playFolder(BuildContext context, String dir) async {
|
||||
final List<String> list;
|
||||
try {
|
||||
list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList();
|
||||
} catch (e) {
|
||||
ctl.host.snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
|
||||
return;
|
||||
}
|
||||
if (list.isEmpty) {
|
||||
ctl.host.snack(tr('이 폴더에 동영상이 없습니다.'));
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await play(context, list);
|
||||
}
|
||||
|
||||
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
|
||||
Future<void> share(BuildContext context, List<String> paths) async {
|
||||
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
|
||||
if (files.isEmpty) {
|
||||
ctl.host.snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
|
||||
return;
|
||||
}
|
||||
final local = <String>[];
|
||||
for (final f in files) {
|
||||
final l = await fetch(context, f);
|
||||
if (l == null || !context.mounted) return;
|
||||
local.add(l);
|
||||
}
|
||||
if (!await ctl.c.services.shell.shareFiles(local)) {
|
||||
ctl.host.snack(tr('공유 창을 열 수 없습니다.'));
|
||||
} else if (files.length < paths.length) {
|
||||
ctl.host.snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/rsync_runner.dart';
|
||||
import '../../core/file_ops.dart' show FileOpCancelled;
|
||||
import '../../core/vfs.dart' show vDirname, vMissingSync;
|
||||
import '../../l10n/tr.dart';
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_jobs.dart';
|
||||
import 'rsync_run_dialog.dart';
|
||||
import 'transfer_flow.dart';
|
||||
|
||||
/// 약속 (E5d-5): Rsync 화면의 [→ ← ⇄] - 두 창에서 고른 폴더로 rsync. 막는 조건 · 옵션 · 미리 보기 · 실행은 [RsyncRun],
|
||||
/// 확인 창은 rsync_run_dialog, 진행은 [ExplorerJobs], rsync 실행 파일은 [ExplorerTransfers.ensureRsync].
|
||||
/// 끝나면 두 폴더를 다시 읽고 결과를 알린다 (Host). 확인 창이 떠 있는 동안 또 누르면 무시
|
||||
class ExplorerRsyncFlow {
|
||||
ExplorerRsyncFlow(this.ctl, this.jobs, this.transfers);
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerJobs jobs;
|
||||
final ExplorerTransfers transfers;
|
||||
|
||||
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
|
||||
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
|
||||
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
|
||||
/// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110)
|
||||
bool _asking = false;
|
||||
|
||||
Future<void> run(BuildContext context, {required bool right, required bool left}) async {
|
||||
if (_asking) return;
|
||||
_asking = true;
|
||||
try {
|
||||
await _run(context, right: right, left: left);
|
||||
} finally {
|
||||
_asking = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(BuildContext context, {required bool right, required bool left}) async {
|
||||
final l = ctl.panes[0].sel.marked.firstOrNull, r = ctl.panes[1].sel.marked.firstOrNull;
|
||||
// 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog
|
||||
final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(ctl.c),
|
||||
l: l,
|
||||
r: r,
|
||||
right: right,
|
||||
left: left,
|
||||
allowInnerToOuter: ctl.c.settings.allowInnerToOuter,
|
||||
jobs: this.jobs.list,
|
||||
viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]),
|
||||
copySkipNewer: ctl.c.settings.copySkipNewer);
|
||||
if (run == null) {
|
||||
ctl.host.snack(problem!);
|
||||
if (missing) {
|
||||
for (final x in ctl.panes) {
|
||||
x.sel.removeWhere(vMissingSync);
|
||||
}
|
||||
await ctl.refreshAll([vDirname(l!), vDirname(r!)]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final go = await showRsyncRunDialog(context, run);
|
||||
// 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘)
|
||||
ctl.c.note(trf('Rsync 확인 창 ({0}): {1}', [
|
||||
run.both ? '⇄' : right ? '→' : '←',
|
||||
go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'),
|
||||
]));
|
||||
if (go != true || !context.mounted) return;
|
||||
if (run.nestedDelete()) {
|
||||
ctl.host.snack(RsyncRun.nestedDeleteMessage);
|
||||
return;
|
||||
}
|
||||
final exe = run.viaDav ? '' : await transfers.ensureRsync(context);
|
||||
if (exe == null || !context.mounted) return;
|
||||
final res = await run.execute(
|
||||
rsyncExe: exe,
|
||||
alive: () => context.mounted,
|
||||
note: ctl.c.note,
|
||||
onStarted: this.jobs.start);
|
||||
// 205: 시작하지 못하면 이유를 알린다
|
||||
if (res.notStarted != null && context.mounted) ctl.host.snack(trf('시작하지 못했습니다: {0}', [res.notStarted]));
|
||||
final jobs = res.jobs;
|
||||
if (jobs.isEmpty || !context.mounted) return;
|
||||
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
|
||||
for (final x in ctl.panes) {
|
||||
x.sel.removeWhere(vMissingSync);
|
||||
}
|
||||
await ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]);
|
||||
for (final x in ctl.panes) {
|
||||
x.changed();
|
||||
}
|
||||
final error = jobs.map((j) => j.error).whereType<Object>().firstOrNull;
|
||||
ctl.host.snack(error is FileOpCancelled
|
||||
? tr('취소했습니다.')
|
||||
: error != null
|
||||
? trf('끝나지 못했습니다: {0}', [error])
|
||||
// 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것
|
||||
: jobs.map((j) => j.doneSummary()).join('\n'));
|
||||
await this.jobs.dropLater(jobs);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/live_sync.dart' show rsyncExecutable;
|
||||
import '../../app/transfer_job.dart';
|
||||
import '../../app/transfer_plan.dart';
|
||||
import '../../core/file_clipboard.dart';
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/name_clash.dart';
|
||||
import '../../core/sync_preview.dart' show optionsRemoveSource, withoutSourceRemovingOptions;
|
||||
import '../../core/sync_tools.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../notice.dart';
|
||||
import '../rsync_setup.dart';
|
||||
import '../sync_preview_view.dart';
|
||||
import '../theme.dart';
|
||||
import 'explorer_controller.dart';
|
||||
import 'explorer_jobs.dart';
|
||||
import 'explorer_pane.dart';
|
||||
import 'write_guard.dart';
|
||||
|
||||
/// 약속 (E5d-5): 복사 · 이동 흐름. 두 창이면 다른 창의 폴더 ([otherDir]) 로, 아니면 담아 두기 ([clip] → [paste]).
|
||||
/// [run]: 확인 창 (같은 이름 · 방법 · 옵션) → 작업 (CopyCenter) → 진행 패널 ([ExplorerJobs]) → 다시 읽기 → 끝 알림 ([폴더 열기]).
|
||||
/// 계획 (같은 폴더 · 없는 원본 · 자기 안으로) 은 transfer_plan, 읽기 전용인 곳은 [ExplorerGuard] 가 막는다. 끝까지 했으면 true.
|
||||
/// 담은 것이 바뀌면 알림
|
||||
class ExplorerTransfers extends ChangeNotifier {
|
||||
ExplorerTransfers(this.ctl, this.guard, this.jobs);
|
||||
|
||||
final ExplorerController ctl;
|
||||
final ExplorerGuard guard;
|
||||
final ExplorerJobs jobs;
|
||||
|
||||
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
|
||||
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
|
||||
Future<void> transfer(BuildContext context, ExplorerPane pane, List<String> sources, {required bool move, String? otherDir}) async {
|
||||
if (guard.blockShizukuMove(sources, null, move: move)) return;
|
||||
// X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음
|
||||
if (sources.any(guard.insideArchive)) {
|
||||
if (move) {
|
||||
guard.blockArchive(sources);
|
||||
return;
|
||||
}
|
||||
await guard.extractFrom(context, sources, otherDir);
|
||||
return;
|
||||
}
|
||||
sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만
|
||||
if (otherDir == null) {
|
||||
toClipboard(sources, move: move);
|
||||
return;
|
||||
}
|
||||
await run(context, pane, sources, otherDir, move: move);
|
||||
}
|
||||
|
||||
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b)
|
||||
final clip = FileClipboard();
|
||||
|
||||
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
|
||||
static String pickFirst(bool move) => move
|
||||
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
|
||||
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
|
||||
|
||||
void toClipboard(List<String> sources, {required bool move}) {
|
||||
if (guard.blockShizukuMove(sources, null, move: move)) return;
|
||||
if (sources.isEmpty) {
|
||||
ctl.host.snack(pickFirst(move));
|
||||
return;
|
||||
}
|
||||
clip.put(sources, move: move);
|
||||
notifyListeners();
|
||||
ctl.host.snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
|
||||
[namesOf(sources), move ? tr('이동') : tr('복사')]));
|
||||
}
|
||||
|
||||
Future<void> paste(BuildContext context, ExplorerPane pane, String dest) async {
|
||||
if (clip.isEmpty) {
|
||||
ctl.host.snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
|
||||
return;
|
||||
}
|
||||
final done = await run(context, pane, clip.items, dest, move: clip.move);
|
||||
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
|
||||
if (context.mounted) {
|
||||
clip.pasted(done: done);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
|
||||
static bool viaDav(Iterable<String> paths) => paths.any(isDav);
|
||||
|
||||
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
|
||||
Future<String?> ensureRsync(BuildContext context) async {
|
||||
final s = ctl.c.settings;
|
||||
final have = await rsyncExecutable(s);
|
||||
if (have != null) return have;
|
||||
if (!context.mounted) return null;
|
||||
if (s.rsyncSource == 'custom' || !Platform.isWindows) {
|
||||
ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
|
||||
return null;
|
||||
}
|
||||
return installRsyncWithDialog(context);
|
||||
}
|
||||
|
||||
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
|
||||
Future<bool> run(BuildContext context, ExplorerPane pane, List<String> sources, String dest,
|
||||
{required bool move, void Function(TransferJob job)? onFinished}) async {
|
||||
if (sources.isEmpty) {
|
||||
ctl.host.snack(pickFirst(move));
|
||||
return false;
|
||||
}
|
||||
if (guard.blockShizukuMove(sources, dest, move: move)) return false;
|
||||
// X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로
|
||||
if (guard.blockArchive([dest], self: true)) return false;
|
||||
if (sources.any(guard.insideArchive)) {
|
||||
if (move) return !guard.blockArchive(sources);
|
||||
await guard.extractFrom(context, sources, dest);
|
||||
return true;
|
||||
}
|
||||
// 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다
|
||||
if (jobs.busyWith(sources, dest)) {
|
||||
ctl.host.snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
|
||||
return false;
|
||||
}
|
||||
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan)
|
||||
final plan = planTransfer(sources, dest, move: move);
|
||||
sources = plan.sources;
|
||||
final skippedHere = plan.skippedHere, here = plan.here;
|
||||
var sameFolderCopy = false;
|
||||
if (plan.nothingToMove) {
|
||||
// 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('이미 이 폴더에 있습니다')),
|
||||
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])),
|
||||
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (plan.askCopyHere) {
|
||||
final ok = await confirmDialog(context,
|
||||
title: () => tr('같은 폴더입니다'),
|
||||
confirmLabel: () => tr('사본 만들기'),
|
||||
body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [namesOf(here)])));
|
||||
if (!ok || !context.mounted) return false;
|
||||
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
|
||||
}
|
||||
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
|
||||
final problem = plan.problem;
|
||||
if (problem != null) {
|
||||
ctl.host.snack(problem);
|
||||
pane.sel.removeWhere(vMissingSync);
|
||||
await ctl.refreshAll([dest, ...sources.map(p.dirname)]);
|
||||
for (final x in ctl.panes) {
|
||||
x.changed();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다)
|
||||
final center = CopyCenter.of(ctl.c);
|
||||
final task = center.fresh(sources, dest, move: move);
|
||||
final method = CopyMethod.of(task.method);
|
||||
// 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다
|
||||
// (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름)
|
||||
final builtin = method == CopyMethod.builtin || viaDav([...sources, dest]);
|
||||
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
|
||||
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
|
||||
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
|
||||
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
|
||||
final always = switch (ctl.c.settings.copyConflict) {
|
||||
'rename' => NameConflict.rename,
|
||||
'overwrite' => NameConflict.overwrite,
|
||||
'skip' => NameConflict.skip,
|
||||
_ => null,
|
||||
};
|
||||
var conflict = always ?? NameConflict.rename;
|
||||
// U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다
|
||||
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
|
||||
// U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소]
|
||||
final go = sameFolderCopy
|
||||
? true
|
||||
: await confirmDialog(
|
||||
context,
|
||||
title: () => move ? tr('이동') : tr('복사'),
|
||||
confirmLabel: () => move ? tr('이동') : tr('복사'),
|
||||
danger: () => conflict == NameConflict.overwrite,
|
||||
body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
|
||||
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
|
||||
'\n${namesOf(sources)}'
|
||||
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}'
|
||||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||||
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
|
||||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||||
// 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림
|
||||
if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)',
|
||||
[splitOptions(task.options).where(isSourceRemovingOption).join(' ')]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
FutureBuilder<List<NameClash>>(
|
||||
future: clashesFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
);
|
||||
}
|
||||
final clashes = snap.data ?? const <NameClash>[];
|
||||
if (clashes.isEmpty) return const SizedBox.shrink();
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const SizedBox(height: 12),
|
||||
Text(trf('같은 이름이 이미 있습니다: {0}', [namesOf([for (final x in clashes) x.src])]),
|
||||
style: TextStyle(color: JjColors.warning)),
|
||||
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
|
||||
for (final x in clashes.take(3)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, left: 8),
|
||||
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
// 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리
|
||||
if (x.there.isDir && !isDav(x.src) && !isDav(dest))
|
||||
FutureBuilder<int>(
|
||||
future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))),
|
||||
builder: (_, n) => Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: Text(
|
||||
n.connectionState != ConnectionState.done
|
||||
? tr('안쪽 파일을 확인하는 중 …')
|
||||
: (n.data ?? 0) == 0
|
||||
? ''
|
||||
: ctl.c.settings.copySkipNewer
|
||||
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
|
||||
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (always != null)
|
||||
Text(
|
||||
trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
|
||||
switch (always) {
|
||||
NameConflict.rename => tr('이름을 바꿉니다'),
|
||||
NameConflict.overwrite => tr('덮어씁니다'),
|
||||
NameConflict.skip => tr('건너뜁니다'),
|
||||
}
|
||||
]),
|
||||
style: const TextStyle(fontSize: 12))
|
||||
else
|
||||
RadioGroup<NameConflict>(
|
||||
groupValue: conflict,
|
||||
onChanged: (x) => setInner(() => conflict = x ?? conflict),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final (v, label) in [
|
||||
(NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')),
|
||||
(NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')),
|
||||
(NameConflict.skip, tr('건너뛰기')),
|
||||
])
|
||||
RadioListTile<NameConflict>(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
if (otherMarks > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
if (go != true || !context.mounted) return false;
|
||||
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
|
||||
if (!await confirmDeletingRun(context, task) || !context.mounted) return false;
|
||||
String? rsync;
|
||||
if (method == CopyMethod.rsync && !viaDav([...sources, dest])) {
|
||||
rsync = await ensureRsync(context);
|
||||
if (rsync == null || !context.mounted) return false;
|
||||
}
|
||||
final job = await center.start(task, rsyncExe: rsync, conflict: conflict);
|
||||
if (job == null || !context.mounted) return false;
|
||||
jobs.start(job);
|
||||
await job.done;
|
||||
final error = job.error;
|
||||
await jobs.followMoved(job);
|
||||
if (!context.mounted) return false;
|
||||
// 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다)
|
||||
pane.sel.clear();
|
||||
await ctl.refreshAfterTransfer(sources, dest);
|
||||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||||
for (final x in ctl.panes) {
|
||||
if (samePath(x.current, dest)) {
|
||||
x.expanded.add(dest);
|
||||
await ctl.load(x, dest, force: true);
|
||||
}
|
||||
x.changed();
|
||||
}
|
||||
final doneText = error is FileOpCancelled
|
||||
? tr('취소했습니다.')
|
||||
: error != null
|
||||
? trf('끝나지 못했습니다: {0}', [error])
|
||||
: joinSentences([
|
||||
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
|
||||
// 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ")
|
||||
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
||||
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]),
|
||||
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]),
|
||||
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [namesOf(job.skipped)]),
|
||||
// M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다
|
||||
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]),
|
||||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]),
|
||||
]);
|
||||
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
|
||||
if (error == null && context.mounted) {
|
||||
// U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게
|
||||
showNotice(ScaffoldMessenger.of(context),
|
||||
text: doneText,
|
||||
action: (
|
||||
tr('폴더 열기'),
|
||||
() {
|
||||
final show = ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? ctl.pane;
|
||||
ctl
|
||||
..active = ctl.panes.indexOf(show)
|
||||
..update();
|
||||
unawaited(ctl.goTo(show, dest, fresh: true));
|
||||
}
|
||||
));
|
||||
} else {
|
||||
ctl.host.snack(doneText);
|
||||
}
|
||||
// 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게)
|
||||
if (error == null && context.mounted) onFinished?.call(job);
|
||||
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
|
||||
await jobs.dropLater([job]);
|
||||
return error == null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../app/archive_browse.dart' show ArchiveRead;
|
||||
import '../../core/file_ops.dart' show samePath;
|
||||
import '../../core/shizuku.dart' show isRootPath, isShizuku;
|
||||
import '../../l10n/tr.dart';
|
||||
import '../archive_ui.dart' show extractArchiveFlow;
|
||||
import 'explorer_controller.dart';
|
||||
|
||||
/// 약속 (E5d-5): 읽기 전용인 곳 (펼친 압축 안 · 시스템 폴더 Shizuku · 루트) 을 바꾸려는 일을 막고 까닭을 알린다 (Host).
|
||||
/// 막았으면 true. 압축 안의 것은 꺼내기만 ([extractOne] · [extractFrom] - 86 의 풀기 창 그대로)
|
||||
class ExplorerGuard {
|
||||
ExplorerGuard(this.ctl);
|
||||
|
||||
final ExplorerController ctl;
|
||||
|
||||
bool insideArchive(String path) => ctl.arc.inside(path);
|
||||
|
||||
/// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때)
|
||||
bool blockArchive(List<String> paths, {bool self = false}) {
|
||||
// X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만)
|
||||
if (paths.any(isShizuku)) {
|
||||
ctl.host.snack(paths.any(isRootPath)
|
||||
? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')
|
||||
: tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
|
||||
return true;
|
||||
}
|
||||
if (!paths.any((x) => insideArchive(x) || (self && ctl.arc.contains(x)))) return false;
|
||||
ctl.host.snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 -
|
||||
/// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남)
|
||||
bool blockShizukuMove(List<String> sources, String? dest, {required bool move}) {
|
||||
if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false;
|
||||
ctl.host.snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null - 까닭을 알림)
|
||||
Future<String?> extractOne(String path) async {
|
||||
final (out, why) = await ctl.arc.extractOne(path, await ctl.c.services.storage.tempDirectory());
|
||||
if (why == ArchiveRead.password) ctl.host.snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요'));
|
||||
if (why == ArchiveRead.failed) ctl.host.snack(trf('압축에서 꺼내지 못했습니다: {0}', [ctl.arc.lastError]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로. 꺼낸 폴더를 다시 읽는다
|
||||
Future<void> extractFrom(BuildContext context, List<String> paths, String? otherDir) async {
|
||||
final arc = ctl.arc.archiveOf(paths.firstWhere(insideArchive))!;
|
||||
final names = [for (final x in paths) if (insideArchive(x) && samePath(ctl.arc.archiveOf(x)!, arc)) ctl.arc.innerName(arc, x)];
|
||||
final done = await extractArchiveFlow(context, ctl.c, arc, otherDir: otherDir, names: names);
|
||||
if (done != null && ctl.host.alive) await ctl.refreshAll([done]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -8,14 +9,20 @@ 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_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 맨 위 약속
|
||||
|
|
@ -46,24 +53,53 @@ void main() {
|
|||
ctl.dispose();
|
||||
});
|
||||
await t.runAsync(() => ctl.goTo(ctl.panes[0], show));
|
||||
return (ExplorerButtons(ctl, sel, host), ctl, sel, host);
|
||||
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));
|
||||
return (b, ctl, sel, host);
|
||||
}
|
||||
|
||||
testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 복사 → 화면의 일 · 길게 = 짝 동작', (t) async {
|
||||
final (b, ctl, sel, host) = await open(t);
|
||||
testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 한 창 복사 = 담기 · 길게 = 짝 동작 (영구 삭제 창)', (t) async {
|
||||
final (b, ctl, sel, _) = await open(t);
|
||||
final q = ctl.pane;
|
||||
await t.runAsync(() => b.press(ExplorerButton.select));
|
||||
expect(sel.selecting, isTrue);
|
||||
host.name = 'New';
|
||||
await t.runAsync(() => b.press(ExplorerButton.newFolder));
|
||||
var made0 = false;
|
||||
b.press(ExplorerButton.newFolder).then((_) => made0 = true);
|
||||
await t.pump();
|
||||
await t.enterText(find.byType(TextField), 'New');
|
||||
await t.tap(find.text('확인'));
|
||||
for (var i = 0; i < 20 && !made0; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||
await t.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
final made = p.join(show, 'New');
|
||||
expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]);
|
||||
host.targets = [ep1];
|
||||
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], [
|
||||
[ep1],
|
||||
false
|
||||
]);
|
||||
await t.runAsync(() => b.longPress(ExplorerButton.copy));
|
||||
await t.runAsync(() => b.longPress(ExplorerButton.delete));
|
||||
expect(b.transfers.clip.move, isTrue, reason: '복사 길게 = 이동');
|
||||
unawaited(b.longPress(ExplorerButton.delete));
|
||||
await t.pump();
|
||||
expect([find.text('영구 삭제').evaluate().isNotEmpty, b.deletes.shiftDelete], [true, false]);
|
||||
await t.tap(find.text('취소'));
|
||||
await t.pump();
|
||||
expect(File(ep1).existsSync(), isTrue, reason: '취소하면 그대로');
|
||||
await t.runAsync(() => b.longPress(ExplorerButton.refresh));
|
||||
expect(host.calls, ['copy $ep1', 'move $ep1', 'delete(영구) $ep1']);
|
||||
await t.runAsync(() => b.press(ExplorerButton.up));
|
||||
expect(samePath(q.current, root), isTrue);
|
||||
await t.runAsync(() => b.press(ExplorerButton.up));
|
||||
|
|
@ -140,11 +176,8 @@ class _Host extends StatefulWidget {
|
|||
State<_Host> createState() => _HostState();
|
||||
}
|
||||
|
||||
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (화면의 일은 [calls] 에 적기만)
|
||||
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패)
|
||||
class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands {
|
||||
final calls = <String>[];
|
||||
List<String> targets = const [];
|
||||
String? name;
|
||||
Widget Function()? child;
|
||||
|
||||
/// [build] 로 그리고, [changes] 가 바뀔 때마다 다시 그린다
|
||||
|
|
@ -165,46 +198,26 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands
|
|||
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
|
||||
|
||||
@override
|
||||
bool shiftDelete = false;
|
||||
String? get otherDir => null;
|
||||
@override
|
||||
List<String> targetsOf(ExplorerPane pane) => targets;
|
||||
bool get dual => false;
|
||||
@override
|
||||
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false}) async => name;
|
||||
@override
|
||||
bool blockArchive(List<String> paths, {bool self = false}) => false;
|
||||
@override
|
||||
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move}) async =>
|
||||
calls.add('${move ? 'move' : 'copy'} ${sources.join(',')}');
|
||||
@override
|
||||
Future<void> deleteItems(ExplorerPane pane, List<String> paths) async {
|
||||
calls.add('delete${shiftDelete ? '(영구)' : ''} ${paths.join(',')}');
|
||||
shiftDelete = false;
|
||||
}
|
||||
Future<void> quickLook(ExplorerPane pane, [String? path]) => _no();
|
||||
|
||||
Never _no() => throw UnimplementedError('이 시험에서 부르지 않음');
|
||||
@override
|
||||
Future<void> runRsync({required bool right, required bool left}) => _no();
|
||||
@override
|
||||
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false}) => _no();
|
||||
@override
|
||||
Future<void> playFolder(String dir) => _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> paste(ExplorerPane pane, String dest) => _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> openTrash() => _no();
|
||||
@override
|
||||
Future<void> sortDialog() => _no();
|
||||
@override
|
||||
Future<void> historyDialog(ExplorerPane pane) => _no();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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;
|
||||
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_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/item_actions.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer/item_menu.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer/open_flow.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer/write_guard.dart';
|
||||
import 'package:jj_mkvmaker/ui/text_viewer.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속
|
||||
/// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다)
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late String root, show, out, ep1, note;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_flow_ui_');
|
||||
root = tmp.path;
|
||||
show = p.join(root, 'Show');
|
||||
out = p.join(root, 'Out');
|
||||
Directory(show).createSync();
|
||||
Directory(out).createSync();
|
||||
ep1 = p.join(show, 'ep1.mkv');
|
||||
note = p.join(show, 'note.txt');
|
||||
File(ep1).writeAsStringSync('1');
|
||||
File(note).writeAsStringSync('글');
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
/// 실제 I/O · 창을 기다린다
|
||||
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<_Flows> open(WidgetTester t) 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 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);
|
||||
addTearDown(() {
|
||||
transfers.dispose();
|
||||
jobs.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);
|
||||
}
|
||||
|
||||
testWidgets('막기: 시스템 폴더 · 루트는 읽기 전용 (까닭 알림) · 시스템 폴더로 옮기기 막음 · 보통 폴더는 그대로', (t) async {
|
||||
final f = await open(t);
|
||||
expect([f.guard.blockArchive([ep1]), f.guard.blockShizukuMove([ep1], out, move: true)], [false, false]);
|
||||
expect(f.guard.blockArchive(['shz://sys/a']), isTrue);
|
||||
expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: true), isTrue);
|
||||
expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: false), isFalse, reason: '복사 (꺼내기) 는 됨');
|
||||
await t.pump();
|
||||
expect(find.textContaining('읽기 전용'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('옮기기: 한 창이면 담기 → 붙여넣기 = 실제 복사 (확인 창 → 진행 목록 → 끝나면 내려감)', (t) async {
|
||||
final f = await open(t);
|
||||
final q = f.ctl.pane;
|
||||
await f.transfers.transfer(f.host.context, q, [], move: false);
|
||||
await t.pump();
|
||||
expect(find.text(ExplorerTransfers.pickFirst(false)), findsOneWidget, reason: '고른 것이 없으면 알림');
|
||||
await f.transfers.transfer(f.host.context, q, [ep1], move: false);
|
||||
expect([f.transfers.clip.items, f.transfers.clip.move], [
|
||||
[ep1],
|
||||
false
|
||||
]);
|
||||
var done = false;
|
||||
unawaited(f.transfers.paste(f.host.context, q, out).then((_) => done = true));
|
||||
await settle(t, () => find.text('복사').evaluate().length > 1);
|
||||
await t.tap(find.widgetWithText(FilledButton, '복사'));
|
||||
await settle(t, () => f.jobs.list.isNotEmpty);
|
||||
expect(f.jobs.list.length, 1);
|
||||
await settle(t, () => done);
|
||||
expect([done, File(p.join(out, 'ep1.mkv')).existsSync(), File(ep1).existsSync(), f.transfers.clip.isEmpty], [true, true, true, false],
|
||||
reason: '복사는 여러 곳에 붙여넣게 남김');
|
||||
expect(f.jobs.list, isEmpty, reason: '끝나면 잠깐 뒤 내려감');
|
||||
});
|
||||
|
||||
testWidgets('지우기: 영구 삭제 (Shift+Delete) 확인 창 → 실제로 지움 · 표시 정리 · [취소] 는 그대로', (t) async {
|
||||
final f = await open(t);
|
||||
final q = f.ctl.pane;
|
||||
f.deletes.shiftDelete = true;
|
||||
unawaited(f.deletes.run(f.host.context, q, [note]));
|
||||
await t.pump();
|
||||
await t.tap(find.text('취소'));
|
||||
await t.pump();
|
||||
expect([File(note).existsSync(), f.deletes.shiftDelete], [true, false]);
|
||||
f.sel.setSelecting(true);
|
||||
await t.runAsync(() => f.sel.toggleMark(q, FileEntry(note, isDir: false, modified: DateTime(0))));
|
||||
f.deletes.shiftDelete = true;
|
||||
var done = false;
|
||||
unawaited(f.deletes.run(f.host.context, q, [note]).then((_) => done = true));
|
||||
await t.pump();
|
||||
await t.tap(find.widgetWithText(FilledButton, '영구 삭제'));
|
||||
await settle(t, () => done);
|
||||
expect([File(note).existsSync(), q.sel.isEmpty, f.sel.selecting], [false, true, false]);
|
||||
});
|
||||
|
||||
testWidgets('열기: 글 뷰어 (앱 안) · 동영상 없는 폴더 재생은 알림 · 열 앱이 없을 때 알림 글', (t) async {
|
||||
final f = await open(t);
|
||||
unawaited(f.opener.viewText(f.host.context, note));
|
||||
await settle(t, () => find.byType(TextViewerPage).evaluate().isNotEmpty);
|
||||
expect(find.byType(TextViewerPage), findsOneWidget);
|
||||
Navigator.of(f.host.context).pop();
|
||||
await t.pump(const Duration(seconds: 1));
|
||||
await settle(t);
|
||||
await t.runAsync(() => f.opener.playFolder(f.host.context, out));
|
||||
await t.pump();
|
||||
expect(find.text('이 폴더에 동영상이 없습니다.'), findsWidgets);
|
||||
expect(ExplorerOpener.noAppMessage(ep1), contains('내장 플레이어'));
|
||||
expect(ExplorerOpener.noAppMessage(note), isNot(contains('내장 플레이어')));
|
||||
});
|
||||
|
||||
testWidgets('줄 메뉴: 파일 항목 · [정보] 창 · 고르지 않으면 아무것도 안 함 · 경로 복사 글', (t) async {
|
||||
final f = await open(t);
|
||||
final e = FileEntry(note, isDir: false, modified: DateTime(2026, 10, 22, 9), size: 3);
|
||||
unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10)));
|
||||
await t.pump();
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
expect([find.text('정보').evaluate().length, find.text('글 뷰어로 보기').evaluate().length, find.text('삭제').evaluate().isNotEmpty], [1, 1, true]);
|
||||
await t.tap(find.text('정보'));
|
||||
await settle(t, () => find.text('닫기').evaluate().isNotEmpty);
|
||||
expect(find.textContaining(note), findsOneWidget);
|
||||
await t.tap(find.text('닫기'));
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10)));
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
await t.tapAt(const Offset(790, 590));
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
expect([File(note).existsSync(), find.text('정보').evaluate().isEmpty], [true, true]);
|
||||
expect(copyablePath(f.ctl, note), note, reason: '이 기기의 경로는 그대로');
|
||||
});
|
||||
}
|
||||
|
||||
class _Flows {
|
||||
_Flows(this.host, this.ctl, this.sel, this.guard, this.jobs, this.transfers, this.deletes, this.opener, this.menu);
|
||||
final _HostState host;
|
||||
final ExplorerController ctl;
|
||||
final ExplorerSelect sel;
|
||||
final ExplorerGuard guard;
|
||||
final ExplorerJobs jobs;
|
||||
final ExplorerTransfers transfers;
|
||||
final ExplorerDeleteFlow deletes;
|
||||
final ExplorerOpener opener;
|
||||
final ExplorerItemMenu menu;
|
||||
}
|
||||
|
||||
class _Host extends StatefulWidget {
|
||||
const _Host({super.key});
|
||||
@override
|
||||
State<_Host> createState() => _HostState();
|
||||
}
|
||||
|
||||
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패)
|
||||
class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands {
|
||||
@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 => 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());
|
||||
}
|
||||
Loading…
Reference in New Issue