JJ_MKVMaker/lib/ui/explorer_page.dart

4230 lines
200 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc;
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/live_sync.dart';
import '../app/settings.dart' show FolderBookmark;
import '../app/transfer_job.dart';
import '../core/secret_gate.dart';
import '../core/sync_tools.dart';
import '../core/file_ops.dart';
import '../core/playlist.dart' show isAudioFile, isVideoFile;
import '../core/vfs.dart';
import '../core/sftp.dart' show SftpRegistry;
import '../core/ftp.dart' show FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import 'app_actions.dart';
import 'explorer_look.dart';
import 'monitor_page.dart';
import 'player_page.dart';
import 'rsync_setup.dart';
import 'theme.dart';
import 'file_error.dart';
import 'folder_picker.dart' show pickFolderOrDav;
import 'trash_page.dart';
import 'dav_external.dart';
import '../platform/windows/recycle_bin.dart';
import '../core/sync_preview.dart';
import 'path_label.dart';
import 'progress_dialog.dart';
import 'sync_preview_view.dart';
import 'sftp_settings.dart';
import 'ftp_settings.dart';
import 'reader_page.dart';
import '../app/component_store.dart';
import 'zip_page.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
import 'tail_text.dart';
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
import '../core/shizuku.dart';
import 'vault_page.dart' show openVault;
import 'app_manager_page.dart' show openAppManager;
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
import 'quick_look.dart';
import 'file_tools.dart' show showFileHash, showFolderProperties;
import '../core/file_attributes.dart' show readFileAttributes;
import 'explorer_drag.dart';
import 'archive_ui.dart';
import 'explorer_grid.dart';
import 'text_viewer.dart';
import 'storage_page.dart';
import 'wifi_share_page.dart';
import '../core/wifi_share.dart' show WifiShare;
import '../core/archive_ops.dart' show archiveKindOf;
import 'batch_rename_dialog.dart';
import '../core/app_trash.dart';
import '../core/batch_rename.dart';
import 'notice.dart';
import 'confirm_dialog.dart';
import '../core/name_clash.dart';
import 'adaptive_row.dart';
import '../app/volumes.dart';
import '../core/explorer/tree_rows.dart';
import '../app/delete_ops.dart';
import '../app/transfer_plan.dart';
import '../core/file_clipboard.dart';
import '../app/archive_browse.dart';
import '../app/file_opener.dart';
import '../core/app_hidden.dart';
import '../app/rsync_runner.dart';
import 'explorer/rsync_run_dialog.dart';
import 'explorer/explorer_controller.dart';
import 'explorer/explorer_host.dart';
import 'explorer/explorer_pane.dart';
import 'explorer/file_thumbs.dart';
import 'explorer/explorer_select.dart';
import 'explorer/path_dialog.dart';
import 'explorer/pane_header.dart';
import 'explorer/pane_view.dart';
import 'explorer/server_flows.dart';
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
///
/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더".
/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램),
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
///
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
const double rsyncTwoPaneMinWidth = 560;
class ExplorerPage extends StatefulWidget {
final AppController c;
final bool rsync;
/// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게
final DateTime Function() clock;
const ExplorerPage({super.key, required this.c, this.rsync = false, this.clock = DateTime.now});
/// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
@visibleForTesting
static Future<bool> Function(String path) probeDrive = (path) => Directory(path).exists();
/// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록)
@visibleForTesting
static List<(String, String)> Function()? debugVolumes;
/// 시험: 경로 입력을 Android 처럼 (SMB 안내, 179). null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidPaths;
/// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidTrash;
/// 시험: 폴더 .nomedia 넣기 · 지우기 (Android 갤러리 · 음악 앱에서 숨기기) 를 Windows 에서. null 이면 Android 만
@visibleForTesting
static bool? debugNomedia;
/// 이번 실행에서 시스템 폴더 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번)
static bool shizukuWarnedThisRun = false;
/// 루트도 같게 (따로 센다)
static bool rootWarnedThisRun = false;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@visibleForTesting
static Future<(String?, String?)> checkPathForTest(String input) =>
checkEnteredPath(input, android: debugAndroidPaths ?? Platform.isAndroid);
static const routeName = 'explorer';
static const rsyncRouteName = 'rsync';
static int _open = 0, _openRsync = 0;
/// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로)
static Future<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
goToRequest.value = dir;
return _show(nav, c, rsync: false);
}
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
static final goToRequest = ValueNotifier<String?>(null);
static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async {
final name = rsync ? rsyncRouteName : routeName;
if ((rsync ? _openRsync : _open) > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == name) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: RouteSettings(name: name),
builder: (_) => ExplorerPage(c: c, rsync: rsync),
));
}
@override
State<ExplorerPage> createState() => _ExplorerPageState();
}
/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다.
enum ExplorerButton {
up(Icons.arrow_upward, '상위 폴더'),
refresh(Icons.refresh, '새로 고침'),
select(Icons.checklist, '선택'),
// ── Rsync 화면에만 ──
toRight(Icons.arrow_forward, '좌 → 우'),
toLeft(Icons.arrow_back, '좌 ← 우'),
both(Icons.compare_arrows, '좌 ⇄ 우'),
monitor(Icons.monitor_heart_outlined, '모니터링'),
// ──
play(Icons.play_circle_outline, '재생'),
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
rename(Icons.drive_file_rename_outline, '이름 변경'),
copy(Icons.copy_outlined, '복사'),
move(Icons.drive_file_move_outline, '이동'),
paste(Icons.content_paste, '붙여넣기'),
delete(Icons.delete_outline, '삭제'),
search(Icons.search, '찾기'),
sort(Icons.sort, '정렬 기준'),
hidden(Icons.visibility_outlined, '숨은 항목 표시'),
history(Icons.history, '내역'),
// 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝)
bookmarks(Icons.star_outline, '즐겨찾기'),
// 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로)
orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'),
layout(Icons.view_quilt_outlined, '창 배치'),
buttons(Icons.tune, '버튼 구성'),
// 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일
storage(Icons.pie_chart_outline, '저장 공간 분석'),
// 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용)
wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'),
// X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기)
trash(Icons.restore_from_trash_outlined, '휴지통'),
// X-plore 비교 5-2 (사용자 결정 10/21): 앱 관리자 (Android 만)
apps(Icons.apps, '앱 관리자'),
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함 (환경 설정 > 보안 에서 켰을 때 열림)
vault(Icons.enhanced_encryption_outlined, '암호 보관함');
final IconData icon;
final String label;
const ExplorerButton(this.icon, this.label);
/// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기)
bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor;
/// 파일 탐색기에서 고를 수 있는 버튼
static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b];
/// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈)
static bool Function() appsButtonSupported = () => Platform.isAndroid;
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
// 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽
static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks];
/// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽
static List<ExplorerButton> get defaultOrder {
const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient];
return [...first, for (final b in explorerValues) if (!first.contains(b)) b];
}
static List<ExplorerButton> fromSettings(List<String> names) {
final out = [
for (final n in names)
for (final b in explorerValues)
if (b.name == n) b,
];
// 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값)
return out.isEmpty ? defaultOrder : out;
}
}
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, ExplorerPaneParts {
AppController get c => widget.c;
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this)..addListener(_onPane);
@override
bool get alive => mounted;
@override
void snack(String text) => _snack(text);
@override
void reveal(ExplorerPane pane, String path) => _reveal(pane, path);
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
Future<void> _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive);
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
Future<void> _reconnect(ExplorerPane pane, String path) async {
await _probeDrive(path);
if (!mounted) return;
if (_ctl.drives.ok[path] == true) {
pane.cache.remove(path);
await _ctl.goTo(pane, path);
} else {
_snack(_ctl.drives.removable.contains(path)
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
}
}
/// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다
final _keys = FocusNode(debugLabel: 'explorer');
@override
void initState() {
// 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나)
thumbMemoryForgetters
..add(VideoThumbs.forget)
..add(BigVideoThumbs.forget);
super.initState();
if (widget.rsync) {
ExplorerPage._openRsync++;
} else {
ExplorerPage._open++;
}
for (final pane in _ctl.panes) {
pane.addListener(_onPane);
}
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true)));
_hiddenSeen = c.settings.explorerHiddenItems;
_kindSeen = c.settings.explorerSortByKind;
c.addListener(_onSettings);
_init();
}
/// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다
late final AppLifecycleListener _resumeReload;
@override
void dispose() {
c.removeListener(_onSettings);
_keys.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
ExplorerPage._open--;
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
}
_sel.dispose();
_ctl.dispose();
if (_allFiles != null) _life.dispose();
_resumeReload.dispose();
super.dispose();
}
void _onPane() {
if (mounted) setState(() {});
}
Future<void> _init() async {
_ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes();
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(
[for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]);
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
final wants = [
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1,
];
// 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신)
final asked = widget.rsync ? null : ExplorerPage.goToRequest.value;
if (asked != null) {
ExplorerPage.goToRequest.value = null;
wants[0] = asked;
}
for (var i = 0; i < 2; i++) {
_ctl.panes[i]
..root = _ctl.volumeOf(wants[i])
..current = wants[i]
// 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게
..focused = null;
}
if (!mounted) return;
setState(() => _ctl.ready = true);
// (이미 열린 화면에 들어온 요청은 그 화면에서)
if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest);
// 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다
final center = CopyCenter.peekOf(c);
if (center != null) {
for (final job in center.jobs.values.where((j) => !j.finished)) {
_adoptJob(job);
}
}
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
if (Platform.isWindows) {
_ctl.drives.classify([for (final (path, _) in _ctl.local) path]);
for (final (path, _) in _ctl.local) {
unawaited(_probeDrive(path));
}
}
for (var i = 0; i < 2; i++) {
final pane = _ctl.panes[i];
unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current)));
}
}
// ───────── 권한 (Android) ─────────
/// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름
bool? _allFiles;
/// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님
bool _dualApp = false;
late final AppLifecycleListener _life = AppLifecycleListener(onResume: () {
if (Platform.isAndroid && _allFiles == false) unawaited(_checkAccess(reload: true));
});
Future<void> _checkAccess({bool reload = false}) async {
_life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인
final ok = await AndroidAccess.hasAllFiles();
var dual = false;
try {
final root = await AndroidAccess.storageRoot();
dual = root.contains('/emulated/') && !root.endsWith('/emulated/0');
} catch (_) {}
if (!mounted) return;
final changed = ok != _allFiles;
setState(() {
_allFiles = ok;
_dualApp = dual;
});
if (reload && changed && ok) {
for (final pane in _ctl.panes) {
pane.cache.clear();
for (final d in pane.expanded.toList()) {
await _ctl.load(pane, d, force: true);
}
}
}
}
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
Widget? _shizukuNotice(ExplorerPane pane) {
if (!isShizuku(pane.current)) return null;
final root = isRootPath(pane.current);
return _notice(
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
body: root
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
actions: const [],
color: JjColors.warning,
);
}
/// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정)
Future<void> _openRoot(ExplorerPane pane) async {
if (!ExplorerPage.rootWarnedThisRun) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: Icon(Icons.security, color: JjColors.warning),
title: Text(tr('루트 폴더 보기 (읽기 전용)')),
content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 '
'보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
],
),
);
if (ok != true || !mounted) return;
ExplorerPage.rootWarnedThisRun = true;
}
try {
final st = await ShizukuRemote.rootBackend.status();
if (!st.running) {
_snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다'));
return;
}
if (!st.granted && !await ShizukuRemote.rootBackend.request()) {
_snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)'));
return;
}
} catch (e) {
_snack(trf('루트에 연결하지 못했습니다: {0}', [e]));
return;
}
if (!mounted) return;
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await _ctl.goTo(pane, rootHome);
}
/// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로
Future<void> _openShizuku(ExplorerPane pane) async {
if (!ExplorerPage.shizukuWarnedThisRun) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning),
title: Text(tr('시스템 폴더 보기 (읽기 전용)')),
content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 '
'있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
],
),
);
if (ok != true || !mounted) return;
ExplorerPage.shizukuWarnedThisRun = true;
}
try {
final st = await ShizukuRemote.backend.status();
if (!st.running) {
_snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요'));
return;
}
if (!st.granted && !await ShizukuRemote.backend.request()) {
_snack(tr('Shizuku 허락이 없어 볼 수 없습니다'));
return;
}
} catch (e) {
_snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e]));
return;
}
if (!mounted) return;
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await _ctl.goTo(pane, shizukuHome(_ctl.local.first.$1));
}
Widget? _accessNotice(ExplorerPane pane) {
if (!Platform.isAndroid || _allFiles != false || isDav(pane.root)) return null;
return _notice(
icon: Icons.lock_outline,
title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'),
body: _dualApp
? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.')
: tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'),
actions: [
FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))),
TextButton(onPressed: () => _checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))),
],
);
}
/// 목록 위 안내 상자
Widget _notice(
{required IconData icon,
required String title,
required String body,
required List<Widget> actions,
Color? color}) =>
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
Widget _noticeBox(IconData icon, String title, String body, List<Widget> actions, Color color) =>
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(6, 6, 6, 0),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 4),
Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Wrap(spacing: 8, children: actions),
]),
);
// ───────── 만들다 만 파일 (71) ─────────
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
List<FileEntry> _partials(ExplorerPane pane, String dir) {
final old = DateTime.now().subtract(const Duration(minutes: 2));
return [
for (final e in pane.cache[dir] ?? const <FileEntry>[])
if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e,
];
}
Widget? _partialNotice(ExplorerPane pane, String dir) {
final list = _partials(pane, dir);
if (list.isEmpty) return null;
return _notice(
icon: Icons.broken_image_outlined,
color: JjColors.warning,
title: trf('만들다 만 파일 {0}개', [list.length]),
body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. '
'같은 파일을 다시 복사하면 새로 만듭니다.'),
actions: [
TextButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)),
if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
final failed = <String>[];
for (final e in list) {
try {
await vDelete(e.path);
} catch (_) {
failed.add(e.name);
}
}
// 같은 폴더를 보는 다른 창도 새로
for (final q in _ctl.panes) {
if (q == pane || q.cache.containsKey(dir)) await _ctl.load(q, dir, force: true);
}
if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
},
child: Text(tr('지우기')),
),
],
);
}
// ───────── 읽을 수 없을 때 (12) ─────────
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
Widget? _errorNotice(ExplorerPane pane, String dir) {
final err = pane.errors[dir] ?? pane.errors[pane.root];
if (err == null) return null;
final where = pane.errors[dir] != null ? dir : pane.root;
final dav = isDav(where);
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
if (ftp != null && cert != null) {
return _notice(
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${_ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
body: cert.message,
actions: [
if (!cert.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
await trustFtpCert(c, ftp.id, cert);
pane.certErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => _servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
if (sftp != null && hostKey != null) {
return _notice(
icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${_ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}',
body: hostKey.message,
actions: [
if (!hostKey.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustHostKey(context, sftp.label, hostKey)) return;
await trustSftpHostKey(c, sftp.id, hostKey);
pane.hostKeyErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => _servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
final (title, body) = explainFileError(err,
dav: dav,
noPassword: (server != null && server.password.isEmpty) ||
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
final locked = isLockedError(err);
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
if (err == androidRestrictedError) {
return _notice(
icon: Icons.lock_outline,
title: '${_ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () {
pane.errors.remove(where);
_ctl.goTo(pane, vDirname(where));
},
child: Text(tr('위 폴더로')),
),
],
);
}
return _notice(
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
title: '${_ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () async {
// 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다
if (locked && !await SecretGate.pass(force: true)) return;
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
for (final d in pane.expanded.toList()) {
if (isSameOrInside(d, where) && d != where) await _ctl.load(pane, d, force: true);
}
},
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
),
// 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다
if (dav && !locked && !isShizuku(where))
TextButton(
onPressed: () => sftp != null
? _servers.editSftp(context, pane, pane.root)
: smb != null
? _servers.editSmb(context, pane, pane.root)
: ftp != null
? _servers.editFtp(context, pane, pane.root)
: _servers.editDav(context, pane, pane.root),
child: Text(tr('서버 설정 고치기'))),
],
);
}
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
String _copyablePath(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);
}
}
/// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
void _revealNow(ExplorerPane pane, String path) {
if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _ctl.rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity));
}
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final pane in _ctl.panes) {
final on = pane.scroll.hasClients;
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
pane.onScreen = on;
}
});
/// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이
double get _listTextScale => c.settings.explorerTextScale / 100;
double _rowH() => _ctl.look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing));
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight;
/// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면
/// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [_body] 가 정한다
bool _splitTight = false;
/// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고
/// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다
bool _narrowSplit = false;
static const narrowSplitWidth = 600.0;
/// 좌우 두 창 (Rsync 화면은 늘)
bool get _dual =>
widget.rsync ||
c.settings.explorerLayout == 'dual' ||
// 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게
(c.settings.explorerLayout == 'auto' && !isCompact(context));
/// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
/// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정)
/// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정)
bool get _alwaysMarks => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정)
bool get _swipePanes => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerSwipePanes;
/// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다
bool _switchPane(int dir) {
if (!_swipePanes) return false;
setState(() {
if (_dual || _split) {
_ctl.active = 1 - _ctl.active;
} else {
final t = _ctl.panes[0];
_ctl.panes[0] = _ctl.panes[1];
_ctl.panes[1] = t;
_ctl.active = 0;
}
});
_ctl.remember(_ctl.pane, _ctl.pane.current);
_snack(trf('창 바꿈: {0}', [_ctl.displayPath(_ctl.pane.current)]));
return true;
}
/// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게
List<String>? _hiddenSeen;
/// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다
bool? _kindSeen;
void _onSettings() {
if (!mounted) return;
if (_kindSeen != null && _kindSeen != c.settings.explorerSortByKind) {
for (final pane in _ctl.panes) {
for (final k in pane.cache.keys.toList()) {
pane.cache[k] = _ctl.sorted(pane.cache[k]!, k);
}
pane.changed();
}
}
_kindSeen = c.settings.explorerSortByKind;
final now = c.settings.explorerHiddenItems;
if (identical(now, _hiddenSeen)) return;
_hiddenSeen = now;
setState(() {});
for (final q in _ctl.panes) {
q.changed();
}
}
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
@override
double get rowHeight => _rowH();
@override
double get listTextScale => _listTextScale;
@override
bool get narrowSplit => _narrowSplit;
@override
bool get alwaysMarks => _alwaysMarks;
@override
Widget frame(int i, List<Widget> children) => _frame(i, children);
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: _ctl,
servers: _servers,
pane: pane,
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
onBookmarks: () => _bookmarks(pane),
onOpenFilter: () => _openFilter(pane),
onCloseFilter: () => _closeFilter(pane),
onOpenRoot: () => _openRoot(pane),
onOpenShizuku: () => _openShizuku(pane),
onReconnect: (path) => _reconnect(pane, path),
);
@override
List<Widget?> notices(ExplorerPane pane, String dir) =>
[_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)];
@override
Widget marksBar(ExplorerPane pane, {String? listDir}) => _marksBar(pane, listDir: listDir);
@override
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _menu(pane, e, at);
@override
List<String> dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e);
@override
Future<void> onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest);
@override
void closeFilter(ExplorerPane pane) => _closeFilter(pane);
@override
void filterChanged() => setState(() {});
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
late final _servers = ExplorerServers(_ctl);
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _open(path, pane: pane))
..addListener(_onPane);
Future<void> _markPattern(ExplorerPane pane, String dir) async {
final pattern = await showDialog<String>(context: context, builder: (_) => const _PatternDialog());
if (pattern == null || pattern.trim().isEmpty) return;
final re = globToRegExp(pattern);
final before = pane.sel.marked.length;
_sel.markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path)));
if (pane.sel.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()]));
}
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
: tr('이 파일을 열 수 있는 앱이 없습니다.');
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> _open(String path, {ExplorerPane? pane}) async {
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
if (_insideArchive(path)) {
final local = await _extractOne(path);
if (local == null || !mounted) return;
path = local;
}
// 여는 길은 file_opener (E5c) 가 고른다
switch (openRouteFor(path,
components: c.settings.components,
imageExts: c.settings.imageExts,
android: Platform.isAndroid,
windows: Platform.isWindows,
hwpEngine: RhwpEngine.available)) {
case OpenRoute.player:
return _play([path]);
case OpenRoute.extractArchive:
return _extractArchive(path, pane ?? _ctl.pane);
case OpenRoute.images:
return _viewImages(path, pane);
case OpenRoute.pdf:
return _viewPdf(path);
case OpenRoute.zip:
return _openZip(path, pane);
// 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로
case OpenRoute.hwp:
if (await _viewHwp(path)) return;
case OpenRoute.docPreview:
return _viewDoc(path);
case OpenRoute.external:
}
final local = await _fetch(path);
if (local == null) return;
final ok = await c.services.shell.openWith(local);
if (ok || !mounted) return;
// X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로
if (textExtensions.contains(extOf(path))) return _viewText(local);
_snack(_noAppMessage(path));
}
/// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤)
Future<void> _viewText(String path) async {
final local = await _fetch(path);
if (local == null || !mounted) return;
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: local)));
}
/// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로
Future<void> _grid(ExplorerPane pane, String dir) async {
await _ctl.load(pane, dir);
if (!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: c.settings.imageExts,
videoThumb: (e) => BigVideoThumbs.of(c, e),
onOpen: (e) => _open(e.path, pane: pane),
),
));
if (at == null || !mounted) return;
await _ctl.goTo(pane, dir);
pane.focused = at;
pane.changed();
}
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart)
Future<String?> _fetch(String path) => fetchDav(context, c, path);
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
if (!mounted || paths.isEmpty) return;
await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder);
}
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
Future<void> _viewImages(String path, ExplorerPane? pane) async {
final dir = vDirname(path);
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
List<FileEntry> list;
try {
list = pane?.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir);
} catch (_) {
list = const [];
}
final images = [
for (final e in list)
if (!e.isDir && 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 c.services.storage.tempDirectory();
if (!mounted) return;
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
Future<void> _slideshow(ExplorerPane pane, String dir) async {
List<FileEntry> list;
try {
list = pane.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir);
} catch (e) {
_snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
return;
}
final images = [
for (final e in list)
if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_ctl.showAppHidden || !_ctl.appHidden(e.path))) e.path,
];
if (images.isEmpty) {
_snack(tr('이 폴더에는 그림이 없습니다'));
return;
}
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!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) {
_snack(trf('미리 볼 수 없습니다: {0}', [error]));
return;
}
PdfSource src;
try {
src = await PdfSource.open(pdf, tempDir: temp);
} catch (e) {
_snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!mounted) return;
await openReader(context, c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> _viewHwp(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!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: c.note);
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
if (dialog.cancelled()) {
await src?.dispose();
return true;
}
dialog.close();
await dialog.done;
if (src == null) {
_snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!mounted) {
await src.dispose();
return true;
}
await openReader(context, c, src);
return true;
}
Future<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory();
PdfSource src;
try {
src = await PdfSource.open(path, tempDir: temp);
} catch (e) {
_snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!mounted) return;
await openReader(context, c, src);
await _ctl.refreshAll([vDirname(path)]);
}
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
Future<void> _storage(ExplorerPane pane, String dir) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
if (isDav(dir)) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('저장 공간 분석')),
content: Text(tr('WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))),
],
),
);
if (ok != true || !mounted) return;
}
// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로)
final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async {
await _delete(pane, paths);
return [for (final x in paths) if (!await vExists(x)) x];
}, onCut: (paths) => _toClipboard(paths, move: true));
if (path == null || !mounted) return;
final isDir = await vIsDir(path);
await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true);
pane.focused = path;
pane.changed();
_reveal(pane, path);
}
/// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림
Future<void> _wifiShare(String dir) async {
final on = WifiShare.instance;
if (on == null && isDav(dir)) {
_snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다'));
return;
}
await WifiSharePage.open(context, c, on?.root ?? dir);
}
/// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게)
Widget _wifiBar(WifiShare s) {
final left = s.endsAt?.difference(DateTime.now());
return Material(
color: JjColors.panelHigh,
child: InkWell(
onTap: () => WifiSharePage.open(context, c, s.root),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [
Icon(Icons.wifi_tethering, size: 18, color: JjColors.success),
const SizedBox(width: 6),
Expanded(
child: Text(
left == null
? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root])
: trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))),
]),
),
),
);
}
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
Future<void> _extractArchive(String path, ExplorerPane pane) async {
final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null;
final out = await extractArchiveFlow(context, c, path, otherDir: other);
if (out != null && mounted) await _ctl.refreshAll([out, vDirname(out)]);
}
Future<void> _openZip(String path, ExplorerPane? pane) async {
try {
if (c.settings.zipComic && await openZipComic(context, c, path)) return;
} catch (e) {
_snack(trf('열 수 없습니다: {0}', [e]));
return;
}
if (!mounted) return;
final other = pane == null || !_dual ? null : _ctl.panes[1 - _ctl.panes.indexOf(pane)].current;
final out = await openZip(context, c, path, otherDir: other);
if (out != null) await _ctl.refreshAll([out, vDirname(out)]);
}
/// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다)
Future<void> _imagesToPdf(List<String> images) async {
if (images.isEmpty) return;
final dir = vDirname(images.first);
final first = vBasename(images.first);
final name = await _askName(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 c.services.storage.tempDirectory();
try {
await imagesToPdf(images, out, tempDir: temp);
_snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)]));
} catch (e) {
_snack(trf('PDF 를 만들지 못했습니다: {0}', [e]));
}
await _ctl.refreshAll([dir]);
}
Future<void> _menu(ExplorerPane pane, FileEntry e, Offset at) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
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 = 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 (c.settings.imageExts.contains(extOf(x))) x];
final ext = extOf(e.path);
final viewable = viewer && !e.isDir &&
(c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
final dual = _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(_insideArchive) || _insideArchive(e.path);
// 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
final items = widget.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 && !widget.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 (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 && (ExplorerPage.debugNomedia ?? Platform.isAndroid))
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 (_clip.isNotEmpty)
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.items.length])),
('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')),
if (e.isDir && !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 = widget.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 (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 || !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);
setState(() => _ctl.active = _ctl.panes.indexOf(other));
case 'playFolder':
await _playFolder(e.path);
case 'slideshow':
await _slideshow(pane, e.path);
case 'playInternal':
await _play([e.path], internal: true);
case 'grid':
await _grid(pane, e.path);
case 'text':
await _viewText(e.path);
case 'sqlite':
await openSqliteViewer(context, c, e.path);
case 'vaultAdd':
await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) {
// 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더)
_shiftDelete = permanent;
return _delete(pane, paths);
});
await _ctl.refreshAll([vDirname(e.path)]);
case 'openWith':
final local = await _fetch(e.path);
if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path));
case 'openDefault':
final local = await _fetch(e.path);
if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path));
case 'addMkv':
await _addMkv([e.path]);
case 'range':
_sel.markRange(pane, e);
case 'select':
setState(() => _sel.selecting = true);
if (!_sel.isMarked(pane, e.path)) await _sel.toggleMark(pane, e);
case 'quickLook':
await _quickLook(pane, e.path);
case 'extract':
await _extractArchive(e.path, pane);
case 'storage':
await _storage(pane, e.path);
case 'wifiShare':
await _wifiShare(e.path);
case 'zip':
final items = outermostPaths(_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]);
final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null;
final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other);
if (made != null && mounted) {
await _ctl.refreshAll([vDirname(made)]);
pane.focused = made;
pane.changed();
}
case 'appHide':
if (c.settings.explorerHideConfirm && !await _confirmAppHide(targets)) return;
await 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();
}
if (mounted) setState(() {});
_snack(_ctl.showAppHidden
? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length])
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
case 'appUnhide':
final keys = {for (final x in targets) HiddenList.keyOf(x)};
await 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();
}
if (mounted) setState(() {});
case 'nomediaOn' || 'nomediaOff':
await _nomedia(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 (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 _batchRename(pane, pane.sel.marked);
} else {
await _rename(pane, e.path);
}
case 'copy':
await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
case 'move':
await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
case 'clipCopy':
_toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
case 'clipMove':
_toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
case 'paste':
await _paste(pane, e.isDir ? e.path : vDirname(e.path));
case 'delete':
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
await _delete(pane, targets);
case 'share':
await _share(targets);
case 'copyPath':
final paths = targets.map(_copyablePath).toList();
await Clipboard.setData(ClipboardData(text: paths.join('\n')));
_snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single]));
case 'bookmark':
final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path);
await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]);
_snack(trf('즐겨찾기에 넣었습니다: {0}', [name]));
setState(() {});
case 'view':
await _open(e.path, pane: pane);
case 'toPdf':
await _imagesToPdf(images);
case 'reveal':
await c.services.shell.revealFile(e.path);
case 'info':
await _info(e);
}
}
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
Future<void> _share(List<String> paths) async {
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
if (files.isEmpty) {
_snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
return;
}
final local = <String>[];
for (final f in files) {
final l = await _fetch(f);
if (l == null || !mounted) return;
local.add(l);
}
if (!await c.services.shell.shareFiles(local)) {
_snack(tr('공유 창을 열 수 없습니다.'));
} else if (files.length < paths.length) {
_snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
}
}
// ───────── 기능 ─────────
ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null;
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
String? _archiveOf(String path) => _ctl.arc.archiveOf(path);
bool _insideArchive(String path) => _ctl.arc.inside(path);
/// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null)
Future<String?> _extractOne(String path) async {
final (out, why) = await _ctl.arc.extractOne(path, await c.services.storage.tempDirectory());
if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요'));
if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_ctl.arc.lastError]));
return out;
}
/// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로
Future<void> _extractFromArchive(List<String> paths, String? otherDir) async {
final arc = _archiveOf(paths.firstWhere(_insideArchive))!;
final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _ctl.arc.innerName(arc, x)];
final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names);
if (done != null && mounted) await _ctl.refreshAll([done]);
}
/// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 -
/// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남). 막았으면 true
bool _blockShizukuMove(List<String> sources, String? dest, {required bool move}) {
if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false;
_snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'));
return true;
}
/// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true
/// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때)
bool _blockArchive(List<String> paths, {bool self = false}) {
// X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만)
if (paths.any(isShizuku)) {
_snack(paths.any(isRootPath)
? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')
: tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
return true;
}
if (!paths.any((x) => _insideArchive(x) || (self && _ctl.arc.contains(x)))) return false;
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return true;
}
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
/// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말)
String _folderName(String dir) =>
_ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _ctl.displayPath(dir) : vBasename(dir))).$2;
/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더]
Widget _targetBar() {
final t = _ctl.panes[1 - _ctl.active];
return Material(
color: JjColors.accent.withValues(alpha: 0.10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(children: [
Icon(Icons.arrow_forward, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
// 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려)
Expanded(
child: Tooltip(
message: _ctl.displayPath(t.current),
triggerMode: TooltipTriggerMode.longPress,
child: LayoutBuilder(builder: (context, box) {
const style = TextStyle(fontSize: 12.5);
final label = trf('갈 곳: {0}', ['']);
final room = box.maxWidth - textWidth(context, label, style);
return Text('$label${middleEllipsis(context, _folderName(t.current), style, room)}', maxLines: 1, overflow: TextOverflow.clip, style: style);
}),
),
),
TextButton(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () => _ctl.goTo(t, _ctl.pane.current),
child: Text(tr('지금 폴더로')),
),
TextButton(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current);
if (d != null && mounted) await _ctl.goTo(t, d);
},
child: Text(tr('다른 폴더')),
),
]),
),
);
}
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
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];
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
Future<void> _buttonLong(ExplorerButton b) async {
switch (b) {
case ExplorerButton.copy:
return _button(ExplorerButton.move);
case ExplorerButton.move:
return _button(ExplorerButton.copy);
case ExplorerButton.delete:
_shiftDelete = true;
return _button(ExplorerButton.delete);
default:
return;
}
}
Future<void> _button(ExplorerButton b) async {
final pane = _ctl.pane;
switch (b) {
case ExplorerButton.up:
if (samePath(pane.current, pane.root)) return;
final up = vDirname(pane.current);
pane.expanded.remove(pane.current);
await _ctl.goTo(pane, up);
case ExplorerButton.select:
_sel.setSelecting(!_sel.selecting);
case ExplorerButton.monitor:
await MonitorPage.open(context, c);
case ExplorerButton.toRight:
await _runRsync(right: true, left: false);
case ExplorerButton.toLeft:
await _runRsync(right: false, left: true);
case ExplorerButton.both:
await _runRsync(right: true, left: true);
case ExplorerButton.refresh:
// 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게)
await _ctl.reloadOpen();
case ExplorerButton.play:
final t = _targets(pane);
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
if (videos.isNotEmpty) {
await _play(videos, keepOrder: !pane.sel.isEmpty);
} else {
await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
}
case ExplorerButton.addMkv:
final t = _targets(pane);
await _addMkv(t.isEmpty ? [pane.current] : t);
case ExplorerButton.newFolder:
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
if (_blockArchive([pane.current], self: true)) return;
final name = await _askName(tr('새 폴더'), '');
if (name == null) return;
try {
final parent = pane.current;
final made = await FileOps.makeFolder(parent, name);
// 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤
pane.expanded.add(parent);
await _ctl.load(pane, parent, force: true);
await _ctl.refreshAll([parent]);
// Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다
if (widget.rsync) {
pane.sel.clear();
await pane.sel.toggle(made, isDir: true, list: (_) async => const []);
}
pane.focused = made;
pane.changed();
_reveal(pane, made);
} catch (e) {
_snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
}
case ExplorerButton.rename:
final t = _targets(pane);
if (t.isEmpty) {
_snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
return;
}
// 78: 여럿을 골랐으면 일괄 이름 바꾸기
if (t.length > 1) return _batchRename(pane, t);
await _rename(pane, t.first);
case ExplorerButton.copy:
await _transfer(pane, _targets(pane), move: false);
case ExplorerButton.move:
await _transfer(pane, _targets(pane), move: true);
case ExplorerButton.paste:
await _paste(pane, pane.current);
case ExplorerButton.delete:
await _delete(pane, _targets(pane));
case ExplorerButton.search:
await _search(pane);
case ExplorerButton.storage:
await _storage(pane, pane.current);
case ExplorerButton.wifiShare:
await _wifiShare(pane.current);
case ExplorerButton.apps:
await openAppManager(context);
case ExplorerButton.vault:
if (!c.settings.vaultEnabled) {
_snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)'));
} else {
await openVault(context, c);
}
case ExplorerButton.trash:
await _openTrash();
case ExplorerButton.sort:
await _sortDialog();
case ExplorerButton.hidden:
await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden);
for (final x in _ctl.panes) {
x.cache.clear();
for (final d in x.expanded.toList()) {
await _ctl.load(x, d, force: true);
}
}
_snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
case ExplorerButton.history:
await _historyDialog(pane);
case ExplorerButton.bookmarks:
await _bookmarks(pane);
case ExplorerButton.orient:
await _toggleOrientation();
case ExplorerButton.layout:
await _layoutDialog();
case ExplorerButton.buttons:
await _buttonsDialog();
}
}
Future<void> _playFolder(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) {
_snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
return;
}
if (list.isEmpty) {
_snack(tr('이 폴더에 동영상이 없습니다.'));
return;
}
if (!mounted) return;
await _play(list);
}
Future<void> _addMkv(List<String> paths) async {
if (paths.any(isDav)) {
_snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[];
final unreadable = <String>[];
for (final x in paths) {
if (vIsDirSync(x)) {
try {
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
} catch (_) {
unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다
}
} else if (isVideoFile(x)) {
videos.add(x);
}
}
if (unreadable.isNotEmpty) _snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')]));
if (videos.isEmpty) {
if (unreadable.isEmpty) _snack(tr('추가할 동영상이 없습니다.'));
return;
}
await c.addVideos(videos, allowOutputFolder: true);
_snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length]));
}
Future<void> _rename(ExplorerPane pane, String path) async {
if (_blockArchive([path])) return;
final name = await _askName(tr('이름 변경'), vBasename(path));
if (name == null || name == vBasename(path)) return;
try {
final now = await FileOps.rename(path, name);
await _ctl.followHidden(moved: [(path, now)]);
await _ctl.refreshAll([vDirname(path)]);
for (final x in _ctl.panes) {
x.sel.rename(path, now);
}
pane.focused = now;
pane.changed();
} catch (e) {
_snack(trf('이름을 바꿀 수 없습니다: {0}', [e]));
}
}
/// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
Future<void> _batchRename(ExplorerPane pane, List<String> paths) async {
if (_blockArchive(paths)) return;
int at(String x) {
final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).indexWhere((e) => samePath(e.path, x));
return i < 0 ? 1 << 30 : i;
}
final ordered = [...paths]..sort((a, b) {
final d = vDirname(a).compareTo(vDirname(b));
return d != 0 ? d : at(a).compareTo(at(b));
});
final done = await showBatchRename(context, ordered,
isDir: vIsDirSync,
initial: RenameRule.fromJson(c.settings.batchRenameLast),
onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson()));
if (done == null || done.isEmpty || !mounted) return;
await _afterBatchRename(pane, done);
if (!mounted) return;
showNotice(ScaffoldMessenger.of(context),
text: trf('{0}개의 이름을 바꿨습니다.', [done.length]),
action: (
tr('되돌리기'),
() async {
try {
final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]);
if (mounted) await _afterBatchRename(pane, back);
} on BatchRenameException catch (e) {
_snack(trf('되돌리지 못했습니다: {0}', [e]));
}
}
));
}
/// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로
Future<void> _afterBatchRename(ExplorerPane pane, List<(String, String)> moved) async {
await _ctl.followHidden(moved: moved);
await _ctl.refreshAll({for (final (a, _) in moved) vDirname(a)});
for (final x in _ctl.panes) {
for (final (from, to) in moved) {
x.sel.rename(from, to);
if (x.focused != null && samePath(x.focused!, from)) x.focused = to;
}
x.changed();
}
}
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
bool _shiftDelete = false;
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
Future<void> _delete(ExplorerPane pane, List<String> paths) async {
if (_blockArchive(paths)) return;
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
final shift = _shiftDelete;
_shiftDelete = false;
if (paths.isEmpty) {
_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 && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
var permanent = !canRecycle || shift;
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
final hiddenInside = [
for (final h in 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}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [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 && 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) && 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)}'];
_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 && 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 => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && 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));
_snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
}
/// 시험: [휴지통] 열기
@visibleForTesting
Future<void> debugOpenTrash() => _openTrash();
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
Future<void> _openTrash() async {
final trash = _appTrash;
if (trash == null) {
if (Platform.isWindows) {
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
} else {
_snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
}
return;
}
final back = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays)));
if (back != null && back.isNotEmpty && 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();
}
_snack(joinSentences([
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
]));
}
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
Future<void> _transfer(ExplorerPane pane, List<String> sources, {required bool move}) async {
if (_blockShizukuMove(sources, null, move: move)) return;
// X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음
if (sources.any(_insideArchive)) {
if (move) {
_blockArchive(sources);
return;
}
await _extractFromArchive(sources, _other?.current);
return;
}
sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만
final other = _other;
if (other == null) {
_toClipboard(sources, move: move);
return;
}
await _runTransfer(pane, sources, other.current, move: move);
}
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b)
final _clip = FileClipboard();
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
String _pickFirst(bool move) => move
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
void _toClipboard(List<String> sources, {required bool move}) {
if (_blockShizukuMove(sources, null, move: move)) return;
if (sources.isEmpty) {
_snack(_pickFirst(move));
return;
}
setState(() => _clip.put(sources, move: move));
_snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
[namesOf(sources), move ? tr('이동') : tr('복사')]));
}
Future<void> _paste(ExplorerPane pane, String dest) async {
if (_clip.isEmpty) {
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
return;
}
final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move);
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
if (mounted) setState(() => _clip.pasted(done: done));
}
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
/// (Rsync 화면의 ⇄ 는 둘이 함께)
List<TransferJob> _jobs = const [];
void _onGoToRequest() {
final dir = ExplorerPage.goToRequest.value;
if (dir == null || !_ctl.ready || !mounted) return;
ExplorerPage.goToRequest.value = null;
unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir)));
}
/// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
/// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로)
Future<void> _followMovedJob(TransferJob job) async {
if (!job.move || job.contents || 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);
}
void _adoptJob(TransferJob job) {
if (_jobs.contains(job)) return;
setState(() => _jobs = [..._jobs, job]);
unawaited(job.done.then((_) async {
if (!mounted) return;
await _followMovedJob(job);
if (!mounted) return;
await _ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]);
for (final x in _ctl.panes) {
x.changed();
}
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);
}));
}
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
bool _viaDav(Iterable<String> paths) => paths.any(isDav);
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> _ensureRsync() async {
final s = c.settings;
final have = await rsyncExecutable(s);
if (have != null) return have;
if (!mounted) return null;
if (s.rsyncSource == 'custom' || !Platform.isWindows) {
_snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
return null;
}
return installRsyncWithDialog(context);
}
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
Future<bool> _runTransfer(ExplorerPane pane, List<String> sources, String dest,
{required bool move, void Function(TransferJob job)? onFinished}) async {
if (sources.isEmpty) {
_snack(_pickFirst(move));
return false;
}
if (_blockShizukuMove(sources, dest, move: move)) return false;
// X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로
if (_blockArchive([dest], self: true)) return false;
if (sources.any(_insideArchive)) {
if (move) return !_blockArchive(sources);
await _extractFromArchive(sources, dest);
return true;
}
// 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다
if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) {
_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 || !mounted) return false;
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
}
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
final problem = plan.problem;
if (problem != null) {
_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(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 (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
? ''
: 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 || !mounted) return false;
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
if (!await confirmDeletingRun(context, task) || !mounted) return false;
String? rsync;
if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) {
rsync = await _ensureRsync();
if (rsync == null || !mounted) return false;
}
final job = await center.start(task, rsyncExe: rsync, conflict: conflict);
if (job == null || !mounted) return false;
setState(() => _jobs = [..._jobs.where((j) => !j.finished), job]);
await job.done;
final error = job.error;
await _followMovedJob(job);
if (!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 && 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;
setState(() => _ctl.active = _ctl.panes.indexOf(show));
unawaited(_ctl.goTo(show, dest, fresh: true));
}
));
} else {
_snack(doneText);
}
// 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게)
if (error == null && mounted) onFinished?.call(job);
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);
return error == null;
}
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
/// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110)
bool _rsyncAsking = false;
Future<void> _runRsync({required bool right, required bool left}) async {
if (_rsyncAsking) return;
_rsyncAsking = true;
try {
await _runRsyncInner(right: right, left: left);
} finally {
_rsyncAsking = false;
}
}
Future<void> _runRsyncInner({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(c),
l: l,
r: r,
right: right,
left: left,
allowInnerToOuter: c.settings.allowInnerToOuter,
jobs: _jobs,
viaDav: l != null && r != null && _viaDav([l, r]),
copySkipNewer: c.settings.copySkipNewer);
if (run == null) {
_snack(problem!);
if (missing) {
for (final x in _ctl.panes) {
x.sel.removeWhere(vMissingSync);
}
await _ctl.refreshAll([vDirname(l!), vDirname(r!)]);
}
return;
}
if (!mounted) return;
final go = await showRsyncRunDialog(context, run);
// 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘)
c.note(trf('Rsync 확인 창 ({0}): {1}', [
run.both ? '⇄' : right ? '→' : '←',
go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'),
]));
if (go != true || !mounted) return;
if (run.nestedDelete()) {
_snack(RsyncRun.nestedDeleteMessage);
return;
}
final exe = run.viaDav ? '' : await _ensureRsync();
if (exe == null || !mounted) return;
final res = await run.execute(
rsyncExe: exe,
alive: () => mounted,
note: c.note,
onStarted: (j) => setState(() => _jobs = [..._jobs.where((x) => !x.finished), j]));
// 205: 시작하지 못하면 이유를 알린다
if (res.notStarted != null && mounted) _snack(trf('시작하지 못했습니다: {0}', [res.notStarted]));
final jobs = res.jobs;
if (jobs.isEmpty || !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;
_snack(error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
// 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것
: jobs.map((j) => j.doneSummary()).join('\n'));
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted) setState(() => _jobs = [for (final j in _jobs) if (!jobs.contains(j)) j]);
}
/// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
Widget _transferPanel() {
final jobs = _jobs;
// 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침)
if (jobs.isNotEmpty && c.settings.explorerProgressHidden) {
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ListenableBuilder(
listenable: Listenable.merge(jobs),
builder: (context, _) {
final run = [for (final j in jobs) if (!j.finished) j];
// 관리자 판단: 숨겨도 멈춘 (실패한) 것은 분명히 - 빨간 표시 (끝난 알림 · 묻는 창은 숨김과 상관없이 늘 뜬다)
final failed = jobs.any((j) => j.finished && j.error != null && j.error is! FileOpCancelled);
final pct = run.isEmpty || run.any((j) => j.counting)
? null
: (run.fold<double>(0, (a, j) => a + j.overall) / run.length * 100).round();
return TransferProgressChip(
running: run.length,
percent: pct,
failed: failed,
onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false));
},
),
),
);
}
return AnimatedSlide(
offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: jobs.isEmpty
? const SizedBox(height: 0)
: Material(
elevation: 12,
color: JjColors.panel,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 12),
// 여러 개가 함께 돌면 (23) 높이를 넘지 않게 넘겨 보기
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.45),
child: SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, children: [
// 85: 숨기기 (아래 작은 표시로) - 다른 파일을 보면서 기다릴 수 있게. Android 는 알림줄에도 진행률
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: 28,
child: TextButton.icon(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true),
icon: const Icon(Icons.expand_more, size: 18),
label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)),
),
),
),
for (final (k, job) in jobs.indexed) ...[
if (k > 0) const Divider(height: 18),
ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)),
],
]),
),
),
),
),
);
}
/// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
Widget _jobView(TransferJob job) {
String pct(double v) => '${(v * 100).round()}%';
final cur = job.index < job.total ? job.index : job.total - 1;
final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)];
final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)];
final title = job.finished
? (job.error == null ? tr('완료') : tr('멈춤'))
: job.move
? tr('옮기는 중')
: tr('복사하는 중');
final labelMax = MediaQuery.sizeOf(context).width * 0.45;
Widget line(String label, double value, String right) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
SizedBox(
width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 8,
value: job.counting ? null : value,
// 빈 곳과 찬 곳이 분명히 다르게
color: JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 150,
child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
);
return Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 8),
Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
// Rsync 화면: 원본 → 대상 (⇄ 는 두 줄)
child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest,
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
]),
// Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수)
if (widget.rsync) ...[
line(
job.counting
? tr('파일 세는 중…')
: job.pruning
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
: job.finished
? tr('끝')
: job.currentFile.isEmpty
? tr('시작하는 중…')
: job.currentFile.split('/').last,
job.finished ? 1.0 : (job.filePercent ?? 0),
[
if (job.filePercent != null && !job.finished) pct(job.filePercent!),
if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s',
].join(' · '),
),
line(
trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]),
job.folderProgress,
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
),
] else ...[
// 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중
line(
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
job.overall,
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'
'${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}',
),
line(
job.counting
? tr('파일 세는 중…')
: job.pruning
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
: trf('{0} 안의 파일', [job.currentName]),
job.current,
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
),
],
]);
}
Future<String?> _askName(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('확인'))),
],
),
);
}
Future<void> _info(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(c, e.path) : null;
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만)
final attrs = isDav(e.path) ? null : readFileAttributes(e.path);
if (!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('만든 날')}: ${_date(attrs!.created!)}',
if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(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('닫기')))],
),
);
}
/// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값)
SearchConditions _lastSearch = const SearchConditions();
/// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
({ExplorerPane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _searchBack;
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
Future<void> _search(ExplorerPane pane) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
final cond = await askSearchConditions(context, pane.current, _lastSearch);
if (cond == null || !mounted) return;
_lastSearch = cond;
setState(() => _searchBack = null);
await _searchPage(pane, pane.current, cond);
}
/// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로
Future<void> _backToSearch() async {
final back = _searchBack;
if (back == null) return;
final now = <FileEntry>[];
for (final e in back.found) {
if (await vExists(e.path)) {
now.add(e);
continue;
}
final dest = back.moved[e.path];
final there = dest == null ? null : vJoin(dest, vBasename(e.path));
if (there != null && await vExists(there)) {
now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified));
}
// 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리
else {
for (final entry in back.moved.entries) {
if (!isSameOrInside(e.path, entry.key)) continue;
// 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도)
final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : '';
final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest);
if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified));
break;
}
}
}
if (!mounted) return;
await _searchPage(back.pane, back.root, back.cond, initial: now, initialDone: back.done);
}
Future<void> _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List<FileEntry>? initial, bool initialDone = true}) async {
final other = _other?.current;
final act = await Navigator.of(context).push<SearchAction>(MaterialPageRoute(
builder: (_) => SearchResultsPage(
root: root,
cond: cond,
showHidden: c.settings.explorerShowHidden,
otherDir: other,
onOpen: (e) => _open(e.path, pane: pane),
initial: initial,
initialDone: initialDone,
),
));
if (!mounted) return;
// 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다
if (act == null || act.paths.isEmpty) {
setState(() => _searchBack = null);
return;
}
// 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임
{
setState(() => _searchBack = (
pane: pane,
root: root,
cond: cond,
found: act.found,
done: act.done,
moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const <String, String>{},
));
}
switch (act.kind) {
case 'reveal':
final path = act.paths.first;
final dir = await vIsDir(path);
await _ctl.goTo(pane, dir ? path : vDirname(path), fresh: true);
pane.focused = path;
pane.changed();
_reveal(pane, path);
// 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통
case 'copy':
await _transfer(pane, act.paths, move: false);
case 'move':
await _transfer(pane, act.paths, move: true);
case 'delete':
await _delete(pane, act.paths);
}
}
/// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄
Widget _searchBackBar() {
final back = _searchBack!;
return Material(
color: JjColors.panelHigh,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [
Icon(Icons.manage_search, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Expanded(
child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))),
IconButton(
tooltip: tr('찾기 결과 닫기'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(() => _searchBack = null),
),
]),
),
);
}
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬
Future<bool> _confirmAppHide(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 c.updateSettings((s) => s.explorerHideConfirm = false);
return true;
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
Future<void> _nomedia(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 || !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) {
_snack(trf('바꾸지 못했습니다: {0}', [err]));
return;
}
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
await c.services.shell.mediaScan([f.path, dir]);
await _ctl.refreshAll([dir]);
pane.changed();
_snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
}
Future<void> _sortDialog() async {
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
final dir = _ctl.pane.current;
var (by, desc) = _ctl.sortOf(dir);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('정렬 기준')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Wrap(spacing: 6, children: [
for (final (v, label) in [
('name', tr('이름')),
('date', tr('바뀐 날')),
('size', tr('크기')),
('type', tr('종류 (확장자)')),
])
ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)),
]),
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
if (c.settings.explorerSortPerFolder)
Text(trf('이 폴더에만: {0}', [_ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
],
),
),
);
if (ok != true) return;
await _ctl.setSort(dir, by, desc);
}
/// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에
Future<void> _bookmarks(ExplorerPane pane) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await showFolderBookmarks(
context,
c,
current: pane.current,
right: widget.rsync ? _ctl.panes[1].current : null,
go: (dir) => _ctl.goTo(pane, dir, fresh: true),
goPair: widget.rsync
? (a, b) {
_ctl.goTo(_ctl.panes[0], a, fresh: true);
_ctl.goTo(_ctl.panes[1], b, fresh: true);
}
: null,
);
if (mounted) setState(() {});
}
Future<void> _historyDialog(ExplorerPane pane) async {
final hist = c.settings.explorerHistory.where(reachable).toList();
final pick = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(tr('내역 (최근 폴더)')),
children: [
if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))),
for (final h in hist)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, h),
child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis),
),
],
),
);
if (pick != null) await _ctl.goTo(pane, pick, fresh: true);
}
/// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로
Future<void> _toggleOrientation() async {
final s = c.settings;
if (!(_dual || _split)) {
await c.updateSettings((x) => x
..explorerLayout = 'dual'
..explorerOrientation = 'side');
} else {
// 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함)
final size = MediaQuery.sizeOf(context);
final nowSide = switch (s.explorerOrientation) {
'side' => true,
'stacked' => false,
_ => size.width >= size.height,
};
await c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side');
}
if (!mounted) return;
setState(() {});
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
Future<void> _layoutDialog() async {
final s = c.settings;
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Widget group(String title, String value, List<(String, String)> items, void Function(String) on) =>
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
),
Wrap(spacing: 6, children: [
for (final (v, label) in items)
ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))),
]),
]);
return AlertDialog(
scrollable: true,
title: Text(tr('창 배치')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
group(tr('창'), layout, [
('auto', tr('화면 크기 따라')),
('dual', tr('두 창 (기본)')),
('split', tr('폴더 + 파일 목록')),
('single', tr('한 창')),
], (v) => layout = v),
if (layout == 'split')
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (layout != 'single')
group(tr('두 창 배치'), orient, [
('side', tr('좌우 (기본)')),
('stacked', tr('위아래')),
('auto', tr('화면 모양 따라')),
], (v) => orient = v),
group(tr('기능 버튼 줄'), bar, [
('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')),
('edge', tr('끝 (오른쪽 · 아래)')),
('hidden', tr('숨김')),
], (v) => bar = v),
if (bar == 'hidden')
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
],
);
},
),
);
if (ok != true) return;
await c.updateSettings((x) => x
..explorerLayout = layout
..explorerOrientation = orient
..explorerToolbar = bar);
if (layout == 'single') setState(() => _ctl.active = 0);
// 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
Future<void> _buttonsDialog() async {
final current = ExplorerButton.fromSettings(c.settings.explorerButtons);
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))];
final on = {...current};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(tr('버튼 구성')),
content: SizedBox(
width: 380,
height: 460,
child: Column(children: [
Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
Expanded(
child: ReorderableListView(
buildDefaultDragHandles: true,
onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))),
children: [
for (final b in order)
CheckboxListTile(
key: ValueKey(b),
dense: true,
value: on.contains(b),
secondary: Icon(b.icon),
title: Text(tr(b.label)),
controlAffinity: ListTileControlAffinity.leading,
onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)),
),
],
),
),
]),
),
actions: [
TextButton(
onPressed: () => set(() {
order
..clear()
..addAll(ExplorerButton.defaultOrder);
on
..clear()
..addAll(ExplorerButton.explorerValues);
}),
child: Text(tr('기본값')),
),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
],
),
),
);
if (ok != true) return;
final names = [for (final b in order) if (on.contains(b)) b.name];
await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names);
}
// ───────── 화면 ─────────
/// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님)
bool get _backHasWork =>
_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root));
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
Future<void> _onBack() async {
if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
return;
}
if (!samePath(_ctl.pane.current, _ctl.pane.root)) {
await _button(ExplorerButton.up);
return;
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_backHasWork,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onBack());
},
child: _buildPage(context),
);
}
Widget _buildPage(BuildContext context) {
// P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가
// 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다
final bindings = <ShortcutActivator, VoidCallback>{
// 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키)
..._keyBindings(),
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다
const SingleActivator(LogicalKeyboardKey.escape): () {
// 76: 열린 빠른 필터부터 닫는다
if (_ctl.panes[_ctl.active].filterOpen) {
_closeFilter(_ctl.panes[_ctl.active]);
} else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
}
},
};
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, ev) => _shortcut(bindings, ev),
child: Focus(
focusNode: _keys,
autofocus: true,
// 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼)
onKeyEvent: (_, ev) => _typeToFilter(ev),
child: Listener(
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame])
onPointerDown: (ev) => _mouseTravel(_ctl.active, ev),
child: Scaffold(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
listenable: c,
builder: (context, _) {
_ctl.dropRemovedServers();
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
_ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]);
return Column(children: [
_topBar(),
if (_searchBack != null) _searchBackBar(),
// 89: Wi-Fi 공유가 켜져 있는 동안
if (!widget.rsync)
ValueListenableBuilder<WifiShare?>(
valueListenable: WifiShare.current,
builder: (_, s, _) => s == null ? const SizedBox.shrink() : _wifiBar(s),
),
Expanded(
child: Stack(children: [
// X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만)
Positioned.fill(
child: !_ctl.ready
? const Center(child: CircularProgressIndicator())
: _swipePanes
? GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
if (v.abs() >= 500) _switchPane(v < 0 ? 1 : -1);
},
child: _body())
: _body()),
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()),
]),
),
]);
},
),
),
),
),
),
);
}
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터)
List<String> _ancestors(ExplorerPane pane) {
final out = <String>[];
var d = pane.current;
for (var i = 0; i < 64; i++) {
out.add(d);
if (samePath(d, pane.root)) break;
final up = vDirname(d);
if (up == d || up.isEmpty) break;
d = up;
}
return out;
}
Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c),
// 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음).
// 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지)
middle: LayoutBuilder(builder: (context, box) => Row(children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)),
child: Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded(
child: !_ctl.ready
? const SizedBox.shrink()
: PopupMenuButton<String>(
tooltip: tr('위 폴더로 가기'),
position: PopupMenuPosition.under,
onSelected: (d) => _ctl.goTo(_ctl.pane, d),
itemBuilder: (_) => [
for (final d in _ancestors(_ctl.pane))
PopupMenuItem(
value: d,
enabled: !samePath(d, _ctl.pane.current),
child: Text(_ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
// 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게
child: TailText(_ctl.displayPath(_ctl.pane.current), style: TextStyle(color: JjColors.textDim)),
),
),
PopupMenuButton<Object>(
tooltip: tr('창 배치 · 버튼 구성'),
icon: const Icon(Icons.more_vert),
onSelected: (v) => v is ExplorerButton ? _button(v) : _ctl.toggleAppHidden(),
itemBuilder: (_) => [
for (final b in [
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
ExplorerButton.sort,
ExplorerButton.hidden,
if (!widget.rsync) ExplorerButton.storage,
if (!widget.rsync) ExplorerButton.wifiShare,
])
PopupMenuItem(
value: b,
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
),
// X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18)
if (!widget.rsync && c.settings.explorerHiddenItems.isNotEmpty)
PopupMenuItem(
value: 'appHidden',
child: Row(children: [
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
const SizedBox(width: 12),
Flexible(
child: Text(_ctl.showAppHidden
? tr('목록에서 숨긴 항목 다시 숨기기')
: trf('목록에서 숨긴 항목 보이기 ({0}개)', [c.settings.explorerHiddenItems.length])),
),
]),
),
],
),
])),
);
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
String _shape = '';
Widget _body() {
final s = c.settings;
final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
if (_ctl.ready && shape != _shape) {
if (_shape.isNotEmpty) {
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
_shape = shape;
}
final dual = _dual;
return LayoutBuilder(builder: (context, box) {
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다
final wantSplit = !widget.rsync && s.explorerLayout == 'split';
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0;
final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
final tightWas = _splitTight;
_splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale;
// 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다
if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null);
final split = _split;
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ctl.ready) _revealNewViews();
final side = !(dual || split) ||
switch (s.explorerOrientation) {
'side' => true,
'stacked' => false,
_ => box.maxWidth >= box.maxHeight,
};
// 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
_narrowSplit = split && side && box.maxWidth < narrowSplitWidth;
final bar = s.explorerToolbar == 'hidden' && !widget.rsync
? null
: _Toolbar(
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
onLongPress: widget.rsync ? null : _buttonLong,
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
vertical: side && !rsyncOne,
panesSide: side,
onPressed: _button,
enabled: (b) => switch (b) {
ExplorerButton.paste => _clip.isNotEmpty,
_ => true,
},
selected: (b) =>
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting),
twoPanes: dual || split,
pinned: {ExplorerButton.orient, if (_clip.isNotEmpty) ExplorerButton.paste},
);
final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle');
final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge';
// 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게)
Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w);
if (rsyncOne) {
final i = _ctl.active.clamp(0, 1);
return Column(children: [
_rsyncPaneSwitch(),
?bar,
Expanded(child: keep(i, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: i, foldersOnly: true))),
]);
}
final children = <Widget>[
if (dual) ...[
Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: widget.rsync))),
if (middle) bar,
Expanded(child: keep(1, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 1, foldersOnly: widget.rsync))),
if (edge) bar,
] else if (split) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
// 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다
if (_narrowSplit)
SizedBox(
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true)))
else
Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))),
if (middle) bar,
Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: _ctl, sel: _sel, parts: this, pane: _ctl.panes[0]))),
if (edge) bar,
] else ...[
if (middle) bar,
Expanded(
child: _hiddenTarget
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))])
: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))),
if (edge) bar,
],
];
return side ? Row(children: children) : Column(children: children);
});
}
/// 191: 좁은 Rsync 화면의 [왼쪽 | 오른쪽] - 그 창에서 고른 폴더 (없으면 지금 폴더) 이름과 함께
Widget _rsyncPaneSwitch() => Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: Row(children: [
for (final (k, side) in [(0, tr('왼쪽')), (1, tr('오른쪽'))])
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: ChoiceChip(
showCheckmark: false,
selected: _ctl.active == k,
onSelected: (_) => setState(() => _ctl.active = k),
label: SizedBox(
width: double.infinity,
child: Text(
'$side · ${vBasename(!_ctl.panes[k].sel.isEmpty ? _ctl.panes[k].sel.marked.first : _ctl.panes[k].current)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
]),
);
/// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯 - [_body])
final _viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()];
/// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로).
/// 창 위의 누름은 창 ([_frame]) 과 화면 Listener 가 둘 다 받는다. 받는 곳마다 좌표를 바꾼 새 이벤트라 객체로는 같은지 알 수 없어
/// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다)
(int, Duration)? _navPress;
void _mouseTravel(int i, PointerDownEvent ev) {
if (i >= _ctl.panes.length) return;
final back = ev.buttons & kBackMouseButton != 0;
if (!back && ev.buttons & kForwardMouseButton == 0) return;
final press = (ev.pointer, ev.timeStamp);
if (press == _navPress) return;
_navPress = press;
_ctl.travel(_ctl.panes[i], forward: !back);
}
Widget _frame(int i, List<Widget> children) {
final active = i == _ctl.active;
return Listener(
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
onPointerDown: (ev) => _mouseTravel(i, ev),
child: GestureDetector(
onTapDown: (_) {
if (_ctl.active != i) setState(() => _ctl.active = i);
},
// 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
child: DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _ctl.panes[i].current),
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _ctl.panes[i].current)),
builder: (context, ok, no) => Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg,
border: Border.all(
color: ok.isNotEmpty
? JjColors.accent
: no.isNotEmpty
? JjColors.danger
: active
? JjColors.accent
: JjColors.border,
width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)),
borderRadius: BorderRadius.circular(6),
),
child: Column(children: children),
),
),
),
);
}
/// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나
List<String> _dragPaths(ExplorerPane pane, FileEntry e) =>
_sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path];
/// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널).
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
Future<void> _onDrop(DragLoad load, int to, String dest) async {
if (!canDropInto(load, to, dest) || !mounted) return;
// X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로
if (_insideArchive(dest) || _ctl.arc.contains(dest)) {
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return;
}
if (load.paths.any(_insideArchive)) {
await _extractFromArchive(load.paths, dest);
return;
}
setState(() => _ctl.active = to);
final mode = c.settings.explorerDropAction;
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
if (move == null || !mounted) return;
// 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게)
final existed = <String>{};
if (move) {
for (final s in load.paths) {
final t = vJoin(dest, vBasename(s));
if (await vExists(t)) existed.add(t);
}
}
if (!mounted) return;
await _runTransfer(_ctl.panes[load.from], load.paths, dest,
move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null);
}
/// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만
/// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다)
void _offerUndoMove(DragLoad load, TransferJob job, Set<String> existed) {
// (원래 폴더 → 지금 경로들)
final back = <String, List<String>>{};
// 되돌리지 않는 것: 합친 폴더 (robocopy - 무엇을 옮겼는지 모름) · 같은 이름을 덮어쓴 파일 (원래 파일은 이미 없음, 15 지적)
var notUndoable = 0, overwritten = 0;
final pairs = job.made.length == load.paths.length
? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])]
: [
for (final m in job.made)
for (final s in load.paths)
if (vBasename(s) == vBasename(m)) (s, m),
];
for (final (src, now) in pairs) {
if (!existed.contains(now)) {
back.putIfAbsent(vDirname(src), () => []).add(now);
continue;
}
// 같은 이름을 덮어쓴 파일: 되돌리면 대상에 아무것도 남지 않으므로 그대로 둔다
if (!vIsDirSync(now)) {
overwritten++;
continue;
}
// 합친 폴더: 그 안에서 실제로 옮긴 파일만
final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f];
if (files.isEmpty) {
notUndoable++;
continue;
}
for (final f in files) {
final rest = f.substring(src.length + 1);
back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest));
}
}
if (back.isEmpty || !mounted) return;
final moved = back.values.fold(0, (a, l) => a + l.length);
showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(),
text: joinSentences([
trf('{0}개를 옮겼습니다.', [moved]),
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]),
]),
action: (tr('되돌리기'), () => unawaited(_undoMove(_ctl.panes[load.from], back))));
}
/// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
Future<void> _undoMove(ExplorerPane pane, Map<String, List<String>> back) async {
for (final MapEntry(key: dir, value: items) in back.entries) {
final there = [for (final x in items) if (await vExists(x)) x];
if (there.isEmpty || !mounted) continue;
try {
if (!await vExists(dir)) await vMkdirs(dir);
} catch (_) {}
if (!mounted) return;
await _runTransfer(pane, there, dir, move: true);
}
}
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
Widget _marksBar(ExplorerPane pane, {String? listDir}) {
final dir = listDir ?? pane.current;
// 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고)
var size = 0, folders = 0;
final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x};
for (final m in pane.sel.marked) {
final x = byPath[m];
if (x == null) continue;
if (x.isDir) {
folders++;
} else {
size += x.size;
}
}
final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!);
final info = <Widget>[
Icon(Icons.checklist, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Flexible(
child: Text(
[
trf('{0}개 표시함', [pane.sel.marked.length]),
if (size > 0) formatSize(size),
if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]),
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
];
final actions = <Widget>[
PopupMenuButton<String>(
tooltip: tr('고르기 도구'),
onSelected: (v) {
switch (v) {
case 'invert':
_sel.invertMarks(pane, dir);
case 'pattern':
_markPattern(pane, dir);
case 'sameExt':
_sel.markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt);
}
},
itemBuilder: (_) => [
PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))),
PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))),
PopupMenuItem(
value: 'sameExt',
enabled: focusedExt.isNotEmpty,
child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)),
Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent),
]),
),
),
TextButton(
onPressed: () => setState(() {
final dir = listDir ?? pane.current;
pane.sel.addAll([for (final x in _sel.shown(pane, dir)) x.path]);
}),
child: Text(tr('모두 선택')),
),
TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))),
TextButton(onPressed: () => _sel.setSelecting(false), child: Text(tr('선택 끝'))),
];
return Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
// 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로
child: AdaptiveRow(lead: info, trail: actions, minWidth: 560),
);
}
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
void _openFilter(ExplorerPane pane, [String? text]) {
setState(() {
_ctl.active = _ctl.panes.indexOf(pane);
pane.filterOpen = true;
if (text != null) {
pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
}
});
WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus());
// 거른 결과 (지금 폴더 아래) 가 보이게
_reveal(pane, pane.current);
}
/// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로
void _closeFilter(ExplorerPane pane) {
setState(() {
pane
..filterOpen = false
..filterCtl.clear();
});
_reveal(pane, pane.focused ?? pane.current);
_keys.requestFocus();
}
/// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼)
bool get _tcKeys => !widget.rsync && Platform.isWindows;
/// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 ·
/// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침),
/// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음)
Map<ShortcutActivator, VoidCallback> _keyBindings() {
final cb = <String, VoidCallback>{
'copy': () => _button(ExplorerButton.copy),
'move': () => _button(ExplorerButton.move),
'newFolder': () => _button(ExplorerButton.newFolder),
'delete': () => _button(ExplorerButton.delete),
// 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인)
'deletePermanent': () {
_shiftDelete = true;
_button(ExplorerButton.delete);
},
'rename': () => _button(ExplorerButton.rename),
'refresh': () => _button(ExplorerButton.refresh),
// 81: 빠른 보기
'quickLook': () => _quickLook(_ctl.pane),
'clipCopy': () => _toClipboard(_targets(_ctl.pane), move: false),
'clipCut': () => _toClipboard(_targets(_ctl.pane), move: true),
'paste': () => _paste(_ctl.pane, _ctl.pane.current),
'selectAll': () {
if (!_sel.selecting) setState(() => _sel.selecting = true);
_sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true);
},
// TC: Space 표시 · Insert 표시하고 다음 줄로
'mark': () => _markFocused(next: false),
'markNext': () => _markFocused(next: true),
'filter': () => _openFilter(_ctl.panes[_ctl.active]),
'up': () => _button(ExplorerButton.up),
// 75: 지금 창의 뒤로 · 앞으로
'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false),
'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true),
'switchPane': () => setState(() => _ctl.active = _dual ? 1 - _ctl.active : 0),
};
final keys = widget.rsync
? {
for (final e in defaultExplorerKeys(tc: false).entries)
if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key))
e.key: e.value,
}
: effectiveExplorerKeys(tc: _tcKeys, overrides: c.settings.explorerKeyOverrides);
return {
for (final e in keys.entries)
if (cb[e.key] != null)
for (final a in e.value) a: cb[e.key]!,
};
}
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
Future<void> _quickLook(ExplorerPane pane, [String? path]) async {
final f = path ?? pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = _sel.shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
await showDialog<void>(
context: context,
builder: (_) => QuickLook(
entries: list,
index: i,
probe: c.services.mediaTool.probe,
thumbnail: (e) => BigVideoThumbs.of(c, e),
imageExts: c.settings.imageExts,
onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)),
),
);
}
/// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로
Future<void> _markFocused({required bool next}) async {
final pane = _ctl.pane;
final f = pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = _sel.shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
if (!_sel.selecting) setState(() => _sel.selecting = true);
await _sel.toggleMark(pane, list[i]);
if (next && i + 1 < list.length) {
pane.focused = list[i + 1].path;
pane.changed();
_reveal(pane, list[i + 1].path);
}
}
/// 글자 칸에 치는 중인지 (그 칸이 키를 받아야 한다)
static bool get _typing => FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
/// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다
KeyEventResult _shortcut(Map<ShortcutActivator, VoidCallback> bindings, KeyEvent ev) {
if (ev is KeyUpEvent || _typing) return KeyEventResult.ignored;
for (final b in bindings.entries) {
if (b.key.accepts(ev, HardwareKeyboard.instance)) {
b.value();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
KeyEventResult _typeToFilter(KeyEvent ev) {
if (ev is! KeyDownEvent) return KeyEventResult.ignored;
final ch = ev.character;
final kb = HardwareKeyboard.instance;
if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored;
// 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자)
if (ch == ' ' && !widget.rsync && !_ctl.panes[_ctl.active].filterOpen) return KeyEventResult.ignored;
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
if (_typing) return KeyEventResult.ignored;
if (!_ctl.ready || _ctl.panes.isEmpty) return KeyEventResult.ignored;
final pane = _ctl.panes[_ctl.active];
_openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch);
return KeyEventResult.handled;
}
}
/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
String _date(DateTime d) => fileDateText(d);
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
class _Toolbar extends StatelessWidget {
final List<ExplorerButton> buttons;
final bool vertical;
final void Function(ExplorerButton) onPressed;
final bool Function(ExplorerButton) enabled;
final bool Function(ExplorerButton) selected;
/// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로"
final bool twoPanes;
/// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기)
final Set<ExplorerButton> pinned;
/// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null
final void Function(ExplorerButton)? onLongPress;
const _Toolbar(
{required this.buttons,
required this.vertical,
required this.onPressed,
required this.enabled,
required this.selected,
this.onLongPress,
this.twoPanes = true,
this.pinned = const {ExplorerButton.orient},
bool? panesSide})
: panesSide = panesSide ?? vertical;
/// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고,
/// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우
final bool panesSide;
/// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null)
static String? longPressLabel(ExplorerButton b) => switch (b) {
ExplorerButton.copy => '이동',
ExplorerButton.move => '복사',
ExplorerButton.delete => '영구 삭제',
_ => null,
};
/// 118: 지금 배치를 보여 준다 (누르면 반대로)
String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅';
String _orientTip() => !twoPanes
? '두 창으로 보기 (좌우)'
: panesSide
? '창 배치: 좌우 (누르면 위아래)'
: '창 배치: 위아래 (누르면 좌우)';
/// 창이 위아래로 놓이면 (버튼 줄이 가로) "좌 → 우" 대신 "위 → 아래"
String _label(ExplorerButton b) => b == ExplorerButton.orient
? _orientLabel()
: panesSide
? b.label
: switch (b) {
ExplorerButton.toRight => '위 → 아래',
ExplorerButton.toLeft => '위 ← 아래',
ExplorerButton.both => '위 ⇄ 아래',
_ => b.label,
};
IconData _icon(ExplorerButton b) => b == ExplorerButton.orient
? (!twoPanes ? Icons.vertical_split_outlined : panesSide ? Icons.swap_horiz : Icons.swap_vert)
: panesSide
? b.icon
: switch (b) {
ExplorerButton.toRight => Icons.arrow_downward,
ExplorerButton.toLeft => Icons.arrow_upward,
ExplorerButton.both => Icons.swap_vert,
_ => b.icon,
};
@override
Widget build(BuildContext context) {
Widget btn(ExplorerButton b) {
final on = enabled(b);
final sel = selected(b);
final pair = longPressLabel(b);
final long = onLongPress;
return Tooltip(
message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) +
(pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''),
child: InkWell(
onTap: on ? () => onPressed(b) : null,
onLongPress: on && pair != null && long != null ? () => long(b) : null,
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 72,
height: 58,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null),
const SizedBox(height: 2),
// 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: SizedBox(
width: 68,
child: Text(tr(_label(b)),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)),
),
),
),
]),
),
),
);
}
// 147: 다 들어가지 않으면 (좁은 · 낮은 화면) 들어가는 만큼 보이고 나머지는 [더 보기 ⋯] 메뉴로 - 밀어야 보이는 것을 몰라 못 찾던 것
Widget more(List<ExplorerButton> rest) => Builder(
builder: (context) => Tooltip(
message: tr('더 보기'),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () async {
final box = context.findRenderObject()! as RenderBox;
final at = box.localToGlobal(Offset.zero);
final pick = await showMenu<ExplorerButton>(
context: context,
position: RelativeRect.fromLTRB(at.dx + box.size.width, at.dy, at.dx + box.size.width, at.dy),
items: [
for (final b in rest)
PopupMenuItem(
value: b,
enabled: enabled(b),
height: 40,
child: Row(children: [
Icon(_icon(b), size: 18, color: selected(b) ? JjColors.accent : null),
const SizedBox(width: 10),
Text(tr(_label(b))),
]),
),
],
);
if (pick != null) onPressed(pick);
},
child: SizedBox(
width: 72,
height: 58,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.more_horiz, size: 22),
const SizedBox(height: 2),
Text(tr('더 보기'), style: const TextStyle(fontSize: 10.5, height: 1.1)),
]),
),
),
),
);
return Container(
color: JjColors.panel,
width: vertical ? 76 : null,
height: vertical ? null : 62,
padding: const EdgeInsets.all(2),
child: LayoutBuilder(builder: (context, box) {
final room = vertical ? box.maxHeight : box.maxWidth;
final each = vertical ? 58.0 : 72.0;
var shown = buttons;
var rest = const <ExplorerButton>[];
if (room.isFinite && buttons.length * each > room) {
final fit = (room / each).floor() - 1; // 하나는 [더 보기] 자리
if (fit >= 1) {
shown = buttons.take(fit).toList();
rest = buttons.skip(fit).toList();
// 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다.
// 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게)
for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) {
final i = shown.lastIndexWhere((x) => !pinned.contains(x));
if (i < 0) break;
final out = shown[i];
shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b];
rest = [out, ...rest.where((x) => x != b)];
}
}
}
final children = [for (final b in shown) btn(b), if (rest.isNotEmpty) more(rest)];
return vertical ? Column(children: children) : Row(children: children);
}),
);
}
}
/// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침
class TransferProgressChip extends StatelessWidget {
/// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지
final int running;
final int? percent;
final bool failed;
final VoidCallback onTap;
const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap});
@override
Widget build(BuildContext context) => Material(
elevation: 6,
color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
// 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다)
if (failed)
Icon(Icons.error_outline, size: 16, color: JjColors.danger)
else
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)),
),
const SizedBox(width: 8),
// 좁은 화면 · 큰 글자면 두 줄로 (412 · 글자 1.6배에서 화면을 넘었다 - 253 시험)
Flexible(
child: Text(
failed
? tr('복사 · 이동 멈춤 - 눌러서 보기')
: running == 0
? tr('복사 · 이동 끝')
: percent == null
? trf('복사 · 이동 {0}개', [running])
: trf('복사 · 이동 {0}개 · {1}%', [running, percent]),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12.5),
),
),
const SizedBox(width: 4),
Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)),
]),
),
),
);
}
/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시)
/// 74: 패턴 넣기
class _PatternDialog extends StatefulWidget {
const _PatternDialog();
@override
State<_PatternDialog> createState() => _PatternDialogState();
}
class _PatternDialogState extends State<_PatternDialog> {
final _ctl = TextEditingController(text: '*.srt');
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(tr('패턴으로 고르기')),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')),
onSubmitted: (v) => Navigator.pop(context, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))),
],
);
}
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
class _MenuIconRow extends PopupMenuEntry<String> {
final List<(String, IconData, String, String, bool)> items;
const _MenuIconRow(this.items);
@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)),
),
]),
),
),
),
),
),
]),
);
}