E5d-4: 기능 버튼 · 버튼 줄 · 표시 막대 · 진행 패널을 모듈로 (explorer_page 4,229 → 3,532)

- explorer/explorer_buttons.dart: ExplorerButton (옮김) · ExplorerCommands 약속 · ExplorerButtons (누르기 · 길게 = 짝 동작)
- explorer/toolbar.dart: ExplorerToolbar (버튼 줄 · 더 보기 · 고정)
- explorer/marks_bar.dart: ExplorerMarksBar · markPattern · PatternDialog
- explorer/transfer_panel.dart: ExplorerTransferPanel · TransferProgressChip / explorer/job_view.dart: TransferJobView
- ExplorerSelect.clearMarks 더함 (표시 지우기)
- 화면의 일은 이름을 공개로 바꿔 ExplorerCommands 를 바로 채움 (전달 코드 없음)
- 약속 시험: test/explorer_buttons_contract_test.dart
- 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 12:29:35 +09:00
parent b5b1b13d61
commit 77d962bcdf
8 changed files with 1186 additions and 804 deletions

View File

@ -0,0 +1,269 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../core/file_ops.dart' show FileOps, samePath;
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
import '../../core/vfs.dart' show vDirname, vIsDirSync;
import '../../l10n/tr.dart';
import '../app_manager_page.dart' show openAppManager;
import '../monitor_page.dart';
import '../vault_page.dart' show openVault;
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
/// 기능 버튼 (가운데 줄). 이름은 설정 (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;
}
}
/// 약속 (E5d-4): 버튼이 부르는 화면의 일 (지우기 · 옮기기 · 이름 바꾸기 · 창 · 재생 등). 흐름 모듈로 옮겨 갈 때마다 이 목록이 줄어든다
abstract interface class ExplorerCommands {
/// 창 · 메뉴를 띄울 곳
BuildContext get context;
/// 다음 지우기는 휴지통을 거치지 않음 (Shift+Delete · [삭제] 길게 누르기 - 창에서 한 번 더 확인)
abstract bool shiftDelete;
/// 버튼이 다룰 것 (표시한 것, 없으면 지금 줄)
List<String> targetsOf(ExplorerPane pane);
Future<void> runRsync({required bool right, required bool left});
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false});
Future<void> playFolder(String dir);
Future<void> addMkv(List<String> paths);
/// 압축 안이면 막고 알림 (막았으면 true)
bool blockArchive(List<String> paths, {bool self = false});
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false});
Future<void> renameItem(ExplorerPane pane, String path);
Future<void> batchRename(ExplorerPane pane, List<String> paths);
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move});
Future<void> paste(ExplorerPane pane, String dest);
Future<void> deleteItems(ExplorerPane pane, List<String> paths);
Future<void> openSearch(ExplorerPane pane);
Future<void> openStorage(ExplorerPane pane, String dir);
Future<void> openWifiShare(String dir);
Future<void> openTrash();
Future<void> sortDialog();
Future<void> historyDialog(ExplorerPane pane);
Future<void> openBookmarks(ExplorerPane pane);
Future<void> toggleOrientation();
Future<void> layoutDialog();
Future<void> buttonsDialog();
}
/// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다.
/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host)
class ExplorerButtons {
ExplorerButtons(this.ctl, this.sel, this.cmd);
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerCommands cmd;
/// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null)
static String? longPressLabel(ExplorerButton b) => switch (b) {
ExplorerButton.copy => '이동',
ExplorerButton.move => '복사',
ExplorerButton.delete => '영구 삭제',
_ => null,
};
/// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없으면 아무것도 안 함
Future<void> longPress(ExplorerButton b) async {
switch (b) {
case ExplorerButton.copy:
return press(ExplorerButton.move);
case ExplorerButton.move:
return press(ExplorerButton.copy);
case ExplorerButton.delete:
cmd.shiftDelete = true;
return press(ExplorerButton.delete);
default:
return;
}
}
Future<void> press(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(cmd.context, ctl.c);
case ExplorerButton.toRight:
await cmd.runRsync(right: true, left: false);
case ExplorerButton.toLeft:
await cmd.runRsync(right: false, left: true);
case ExplorerButton.both:
await cmd.runRsync(right: true, left: true);
case ExplorerButton.refresh:
// 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게)
await ctl.reloadOpen();
case ExplorerButton.play:
final t = cmd.targetsOf(pane);
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
if (videos.isNotEmpty) {
await cmd.play(videos, keepOrder: !pane.sel.isEmpty);
} else {
await cmd.playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
}
case ExplorerButton.addMkv:
final t = cmd.targetsOf(pane);
await cmd.addMkv(t.isEmpty ? [pane.current] : t);
case ExplorerButton.newFolder:
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
if (cmd.blockArchive([pane.current], self: true)) return;
final name = await cmd.askName(tr('새 폴더'), '');
if (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 (ctl.rsync) {
pane.sel.clear();
await pane.sel.toggle(made, isDir: true, list: (_) async => const []);
}
pane.focused = made;
pane.changed();
ctl.host.reveal(pane, made);
} catch (e) {
ctl.host.snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
}
case ExplorerButton.rename:
final t = cmd.targetsOf(pane);
if (t.isEmpty) {
ctl.host.snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
return;
}
// 78: 여럿을 골랐으면 일괄 이름 바꾸기
if (t.length > 1) return cmd.batchRename(pane, t);
await cmd.renameItem(pane, t.first);
case ExplorerButton.copy:
await cmd.transfer(pane, cmd.targetsOf(pane), move: false);
case ExplorerButton.move:
await cmd.transfer(pane, cmd.targetsOf(pane), move: true);
case ExplorerButton.paste:
await cmd.paste(pane, pane.current);
case ExplorerButton.delete:
await cmd.deleteItems(pane, cmd.targetsOf(pane));
case ExplorerButton.search:
await cmd.openSearch(pane);
case ExplorerButton.storage:
await cmd.openStorage(pane, pane.current);
case ExplorerButton.wifiShare:
await cmd.openWifiShare(pane.current);
case ExplorerButton.apps:
await openAppManager(cmd.context);
case ExplorerButton.vault:
if (!ctl.c.settings.vaultEnabled) {
ctl.host.snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)'));
} else {
await openVault(cmd.context, ctl.c);
}
case ExplorerButton.trash:
await cmd.openTrash();
case ExplorerButton.sort:
await cmd.sortDialog();
case ExplorerButton.hidden:
await ctl.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);
}
}
ctl.host.snack(ctl.c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
case ExplorerButton.history:
await cmd.historyDialog(pane);
case ExplorerButton.bookmarks:
await cmd.openBookmarks(pane);
case ExplorerButton.orient:
await cmd.toggleOrientation();
case ExplorerButton.layout:
await cmd.layoutDialog();
case ExplorerButton.buttons:
await cmd.buttonsDialog();
}
}
}

View File

@ -170,6 +170,12 @@ class ExplorerSelect extends ChangeNotifier {
notifyListeners();
}
/// [표시 지우기]: 이 창의 표시를 모두 풀기 (선택 모드는 그대로)
void clearMarks(ExplorerPane pane) {
pane.sel.clear();
notifyListeners();
}
/// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로
Future<void> toggleMark(ExplorerPane pane, FileEntry e) async {
pane.focused = e.path;

View File

@ -0,0 +1,115 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../app/transfer_job.dart';
import '../../core/file_ops.dart' show formatSize;
import '../../core/vfs.dart' show vBasename;
import '../../l10n/tr.dart';
import '../theme.dart';
/// 약속 (E5d-4): 복사 · 이동 작업 하나의 진행 - 제목 (복사 · 옮기는 중 · 완료 · 멈춤 + 방법) · 대상 · [취소] (도는 동안).
/// 파일 탐색기: 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중. [rsync] (폴더 하나를 맞춤): 위 = 지금 파일 · 아래 = 폴더 전체.
/// 그리기만 한다 (작업이 바뀌면 부르는 쪽이 다시 그림)
class TransferJobView extends StatelessWidget {
const TransferJobView({super.key, required this.job, required this.rsync});
final TransferJob job;
final bool rsync;
@override
Widget build(BuildContext context) {
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(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 (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])}',
),
],
]);
}
}

View File

@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../core/explorer/tree_rows.dart' show globToRegExp;
import '../../core/file_ops.dart' show formatSize;
import '../../core/reader_sources.dart' show extOf;
import '../../core/vfs.dart' show vBasename;
import '../../l10n/tr.dart';
import '../adaptive_row.dart';
import '../theme.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
/// 약속 (E5d-4): 선택 모드의 아래 막대 - 표시한 수 · 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) ·
/// [고르기 도구 (반전 · 패턴 · 같은 확장자)] · [모두 선택] · [표시 지우기] · [선택 끝]. 고르는 일은 [ExplorerSelect].
/// [listDir]: "폴더 + 파일 목록" 의 오른쪽 목록이면 그 폴더 (아니면 창의 지금 폴더)
class ExplorerMarksBar extends StatelessWidget {
const ExplorerMarksBar({super.key, required this.sel, required this.pane, this.listDir});
final ExplorerSelect sel;
final ExplorerPane pane;
final String? listDir;
@override
Widget build(BuildContext context) {
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(context, sel, 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: () => sel.markWhere(pane, listDir ?? pane.current, (_) => true),
child: Text(tr('모두 선택')),
),
TextButton(onPressed: () => sel.clearMarks(pane), 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),
);
}
}
/// 74: 패턴으로 고르기 - 창에서 패턴을 받아 [dir] 에 보이는 것 중 맞는 것을 표시. 맞는 것이 없으면 알림 (Host)
Future<void> markPattern(BuildContext context, ExplorerSelect sel, 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) sel.ctl.host.snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()]));
}
/// 74: 패턴 넣기
class PatternDialog extends StatefulWidget {
const PatternDialog({super.key});
@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('고르기'))),
],
);
}

View File

@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
import 'explorer_buttons.dart';
/// 약속 (E5d-4): 기능 버튼 줄 - 버튼 · 이름 · 아이콘 (창 배치 · 위아래면 "위 → 아래"), 다 들어가지 않으면 [더 보기 ⋯] (고정한 것은 늘 줄에).
/// 누르기 · 길게 누르기는 부르는 쪽 ([ExplorerButtons.press] · [ExplorerButtons.longPress]). 못 누르는 버튼은 흐리게
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
class ExplorerToolbar 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 ExplorerToolbar(
{super.key,
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;
/// 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 = ExplorerButtons.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);
}),
);
}
}

View File

@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/transfer_job.dart';
import '../../core/file_ops.dart' show FileOpCancelled;
import '../../l10n/tr.dart';
import '../theme.dart';
import 'job_view.dart';
/// 약속 (E5d-4): 아래에서 올라오는 복사 · 이동 진행 패널 (작업마다 [TransferJobView]). 작업이 없으면 내려가 숨는다.
/// 설정 explorerProgressHidden 이면 오른쪽 아래 작은 표시 ([TransferProgressChip], 멈춘 것이 있으면 빨갛게) - 누르면 다시 펼침
class ExplorerTransferPanel extends StatelessWidget {
const ExplorerTransferPanel({super.key, required this.c, required this.jobs, required this.rsync});
final AppController c;
final List<TransferJob> jobs;
/// Rsync 화면 (작업 줄 모양이 다름)
final bool rsync;
@override
Widget build(BuildContext context) {
// 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, _) => TransferJobView(job: job, rsync: rsync)),
],
]),
),
),
),
),
);
}
}
/// 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)),
]),
),
),
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,222 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show samePath;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart';
import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart';
import 'package:jj_mkvmaker/ui/explorer/toolbar.dart';
import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart';
import 'package:path/path.dart' as p;
/// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속
/// (실제 시험 폴더 · 실제 화면 · 실제 복사 작업. 화면의 일 [ExplorerCommands] 은 이 시험 화면이 받아 적는다)
void main() {
late Directory tmp;
late String root, show, ep1;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_btn_ui_');
root = tmp.path;
show = p.join(root, 'Show');
Directory(show).createSync();
ep1 = p.join(show, 'ep1.mkv');
File(ep1).writeAsStringSync('1');
File(p.join(show, 'ep2.mkv')).writeAsStringSync('2');
});
tearDown(() => tmp.deleteSync(recursive: true));
Future<(ExplorerButtons, ExplorerController, ExplorerSelect, _HostState)> open(WidgetTester t) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final key = GlobalKey<_HostState>();
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
final host = key.currentState!;
final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')];
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
addTearDown(() {
sel.dispose();
ctl.dispose();
});
await t.runAsync(() => ctl.goTo(ctl.panes[0], show));
return (ExplorerButtons(ctl, sel, host), ctl, sel, host);
}
testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 복사 → 화면의 일 · 길게 = 짝 동작', (t) async {
final (b, ctl, sel, host) = await open(t);
final q = ctl.pane;
await t.runAsync(() => b.press(ExplorerButton.select));
expect(sel.selecting, isTrue);
host.name = 'New';
await t.runAsync(() => b.press(ExplorerButton.newFolder));
final made = p.join(show, 'New');
expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]);
host.targets = [ep1];
await t.runAsync(() => b.press(ExplorerButton.copy));
await t.runAsync(() => b.longPress(ExplorerButton.copy));
await t.runAsync(() => b.longPress(ExplorerButton.delete));
await t.runAsync(() => b.longPress(ExplorerButton.refresh));
expect(host.calls, ['copy $ep1', 'move $ep1', 'delete(영구) $ep1']);
await t.runAsync(() => b.press(ExplorerButton.up));
expect(samePath(q.current, root), isTrue);
await t.runAsync(() => b.press(ExplorerButton.up));
expect(samePath(q.current, root), isTrue, reason: '맨 위에서는 그대로');
expect([ExplorerButtons.longPressLabel(ExplorerButton.copy), ExplorerButtons.longPressLabel(ExplorerButton.up)], ['이동', null]);
});
testWidgets('버튼 줄: 누르기 · 못 누르는 버튼 · 넘치면 [더 보기] (고정한 것은 줄에)', (t) async {
final pressed = <ExplorerButton>[];
Future<void> bar(double height) async {
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(
height: height,
child: ExplorerToolbar(
buttons: const [ExplorerButton.copy, ExplorerButton.move, ExplorerButton.paste, ExplorerButton.delete, ExplorerButton.orient],
vertical: true,
onPressed: pressed.add,
enabled: (b) => b != ExplorerButton.paste,
selected: (_) => false,
))))));
}
await bar(600);
await t.tap(find.text('복사'));
await t.tap(find.text('붙여넣기'));
expect([pressed, find.text('더 보기').evaluate().isEmpty], [
[ExplorerButton.copy],
true
]);
await bar(200);
expect([find.text('더 보기').evaluate().length, find.byIcon(Icons.swap_horiz).evaluate().length], [1, 1]);
});
testWidgets('표시 막대: 수 · 모두 선택 · 표시 지우기 · 선택 끝', (t) async {
final (_, ctl, sel, host) = await open(t);
final q = ctl.pane;
sel.setSelecting(true);
await t.runAsync(() => sel.toggleMark(q, sel.shown(q, show).first));
host.show(() => ExplorerMarksBar(sel: sel, pane: q), sel);
await t.pump();
expect(find.textContaining('1개 표시함'), findsOneWidget);
await t.tap(find.text('모두 선택'));
await t.pump();
expect(find.textContaining('2개 표시함'), findsOneWidget);
await t.tap(find.text('표시 지우기'));
await t.pump();
expect(q.sel.isEmpty, isTrue);
await t.tap(find.text('선택 끝'));
expect(sel.selecting, isFalse);
});
testWidgets('진행 패널: 실제 복사 작업 · 끝나면 완료 · 숨기면 작은 표시', (t) async {
final (_, ctl, _, host) = await open(t);
final dest = p.join(root, 'Out');
Directory(dest).createSync();
final job = TransferJob(sources: [ep1], dest: dest, move: false);
await t.runAsync(job.run);
expect(File(p.join(dest, 'ep1.mkv')).existsSync(), isTrue);
host.show(() => ExplorerTransferPanel(c: ctl.c, jobs: [job], rsync: false), ctl.c);
await t.pump();
expect(find.textContaining('완료'), findsOneWidget);
await t.runAsync(() => ctl.c.updateSettings((s) => s.explorerProgressHidden = true));
await t.pump();
expect([find.byType(TransferProgressChip).evaluate().length, find.textContaining('완료').evaluate().length], [1, 0]);
});
}
class _Host extends StatefulWidget {
const _Host({super.key});
@override
State<_Host> createState() => _HostState();
}
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (화면의 일은 [calls] 에 적기만)
class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands {
final calls = <String>[];
List<String> targets = const [];
String? name;
Widget Function()? child;
/// [build] 로 그리고, [changes] 가 바뀔 때마다 다시 그린다
void show(Widget Function() build, Listenable changes) {
changes.addListener(() {
if (mounted) setState(() {});
});
setState(() => child = build);
}
@override
bool get alive => mounted;
@override
void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
@override
void reveal(ExplorerPane pane, String path) {}
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
@override
bool shiftDelete = false;
@override
List<String> targetsOf(ExplorerPane pane) => targets;
@override
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false}) async => name;
@override
bool blockArchive(List<String> paths, {bool self = false}) => false;
@override
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move}) async =>
calls.add('${move ? 'move' : 'copy'} ${sources.join(',')}');
@override
Future<void> deleteItems(ExplorerPane pane, List<String> paths) async {
calls.add('delete${shiftDelete ? '(영구)' : ''} ${paths.join(',')}');
shiftDelete = false;
}
Never _no() => throw UnimplementedError('이 시험에서 부르지 않음');
@override
Future<void> runRsync({required bool right, required bool left}) => _no();
@override
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false}) => _no();
@override
Future<void> playFolder(String dir) => _no();
@override
Future<void> addMkv(List<String> paths) => _no();
@override
Future<void> renameItem(ExplorerPane pane, String path) => _no();
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) => _no();
@override
Future<void> paste(ExplorerPane pane, String dest) => _no();
@override
Future<void> openSearch(ExplorerPane pane) => _no();
@override
Future<void> openStorage(ExplorerPane pane, String dir) => _no();
@override
Future<void> openWifiShare(String dir) => _no();
@override
Future<void> openTrash() => _no();
@override
Future<void> sortDialog() => _no();
@override
Future<void> historyDialog(ExplorerPane pane) => _no();
@override
Future<void> openBookmarks(ExplorerPane pane) => _no();
@override
Future<void> toggleOrientation() => _no();
@override
Future<void> layoutDialog() => _no();
@override
Future<void> buttonsDialog() => _no();
@override
Widget build(BuildContext context) => Scaffold(body: child?.call() ?? const SizedBox());
}