379 lines
17 KiB
Dart
379 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
|
|
|
import '../../app/settings.dart' show FolderBookmark;
|
|
import '../../core/explorer/tree_rows.dart';
|
|
import '../../core/file_ops.dart' show FileEntry, isSameOrInside, samePath;
|
|
import '../../core/vfs.dart' show isDav, vBasename, vDirname;
|
|
import '../../l10n/tr.dart';
|
|
import '../explorer_drag.dart';
|
|
import '../explorer_look.dart';
|
|
import '../key_hint.dart';
|
|
import '../theme.dart';
|
|
import 'explorer_controller.dart';
|
|
import 'explorer_pane.dart';
|
|
import 'explorer_select.dart';
|
|
import 'row_view.dart';
|
|
|
|
/// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 ·
|
|
/// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다)
|
|
abstract interface class ExplorerPaneParts {
|
|
/// 줄 높이 (목록 글자 크기 · 줄 간격을 넣은 것)
|
|
double get rowHeight;
|
|
|
|
/// 목록 글자 배율 (설정 %)
|
|
double get listTextScale;
|
|
|
|
/// 좁은 "폴더 + 파일 목록" 의 트리 (이름만)
|
|
bool get narrowSplit;
|
|
|
|
/// X-plore 스타일 표시 동그라미 늘 보이기
|
|
bool get alwaysMarks;
|
|
|
|
Widget frame(int i, List<Widget> children);
|
|
Widget header(ExplorerPane pane);
|
|
|
|
/// [dir] 위의 안내들 (없는 것은 null)
|
|
List<Widget?> notices(ExplorerPane pane, String dir);
|
|
Widget marksBar(ExplorerPane pane, {String? listDir});
|
|
void onMenu(ExplorerPane pane, FileEntry e, Offset at);
|
|
List<String> dragPaths(ExplorerPane pane, FileEntry e);
|
|
Future<void> onDrop(DragLoad load, int to, String dest);
|
|
void closeFilter(ExplorerPane pane);
|
|
|
|
/// 필터 글자가 바뀜 (화면을 다시 그림)
|
|
void filterChanged();
|
|
}
|
|
|
|
/// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다
|
|
/// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다
|
|
void layoutPaneColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width,
|
|
{required double listTextScale, required bool marks}) {
|
|
final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * listTextScale;
|
|
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2));
|
|
double measure(String t) =>
|
|
(TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1)
|
|
..layout())
|
|
.width;
|
|
final lay = columnLayout(look, width, marks: marks, textScale: scale, measure: measure);
|
|
pane.colHide = lay.$1;
|
|
pane.colDateShort = lay.$2;
|
|
}
|
|
|
|
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
|
|
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
|
|
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
|
|
Key paneRowKey(TreeRow r) =>
|
|
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
|
|
|
|
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
|
|
int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
|
|
Map<Key, int>? index;
|
|
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) paneRowKey(rows[i]): i})[key];
|
|
}
|
|
|
|
/// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다
|
|
Widget? noticeArea(List<Widget?> notices, double paneHeight) {
|
|
final list = notices.whereType<Widget>().toList();
|
|
if (list.isEmpty) return null;
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: paneHeight.isFinite ? paneHeight * 0.35 : 240),
|
|
child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: list)),
|
|
);
|
|
}
|
|
|
|
Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts parts, ExplorerPane pane, TreeRow row,
|
|
{bool list = false, bool compact = false}) =>
|
|
KeyedSubtree(
|
|
key: paneRowKey(row),
|
|
child: row.kind != 0
|
|
? ExplorerExtraRow(ctl: ctl, pane: pane, row: row)
|
|
: ExplorerRowView(
|
|
ctl: ctl,
|
|
sel: sel,
|
|
pane: pane,
|
|
row: row,
|
|
list: list,
|
|
compact: compact,
|
|
rowHeight: parts.rowHeight,
|
|
narrowSplit: parts.narrowSplit,
|
|
alwaysMarks: parts.alwaysMarks,
|
|
onMenu: parts.onMenu,
|
|
dragPaths: parts.dragPaths,
|
|
onDrop: parts.onDrop));
|
|
|
|
/// 트리 창 [index] (저장 장치 고르기 + 트리). [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽 · Rsync)
|
|
class ExplorerPaneView extends StatelessWidget {
|
|
const ExplorerPaneView({super.key, required this.ctl, required this.sel, required this.parts, required this.index, this.foldersOnly = false});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerSelect sel;
|
|
final ExplorerPaneParts parts;
|
|
final int index;
|
|
final bool foldersOnly;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pane = ctl.panes[index];
|
|
final rows = ctl.rows(pane, foldersOnly: foldersOnly);
|
|
final look = ctl.look;
|
|
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
|
return LayoutBuilder(builder: (context, box) {
|
|
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
|
|
if (look.columns && !foldersOnly) {
|
|
layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
|
|
}
|
|
return parts.frame(index, [
|
|
parts.header(pane),
|
|
const Divider(height: 1),
|
|
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
|
|
if (pane.filterOpen)
|
|
ExplorerFilterBar(
|
|
pane: pane,
|
|
shown: rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length,
|
|
onClose: () => parts.closeFilter(pane),
|
|
onChanged: parts.filterChanged),
|
|
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
|
?noticeArea(parts.notices(pane, pane.current), box.maxHeight),
|
|
if (look.columns && !foldersOnly)
|
|
ExplorerColumnsHeader(
|
|
style: look,
|
|
markColumn: sel.selecting,
|
|
hide: pane.colHide,
|
|
sortBy: ctl.sortOf(pane.current).$1,
|
|
sortDesc: ctl.sortOf(pane.current).$2,
|
|
onSort: (by) => ctl.sortByColumn(pane, by),
|
|
),
|
|
Expanded(
|
|
child: Stack(children: [
|
|
scaleListText(context, parts.listTextScale, ListView.builder(
|
|
controller: pane.scroll,
|
|
itemCount: rows.length,
|
|
itemExtent: parts.rowHeight,
|
|
// 76: 줄 위젯은 항목을 따라간다 ([paneRowKey])
|
|
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], compact: foldersOnly),
|
|
findChildIndexCallback: _rowIndexOf(rows),
|
|
)),
|
|
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
|
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
|
]),
|
|
),
|
|
if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane),
|
|
]);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
|
class ExplorerListView extends StatelessWidget {
|
|
const ExplorerListView({super.key, required this.ctl, required this.sel, required this.parts, required this.pane});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerSelect sel;
|
|
final ExplorerPaneParts parts;
|
|
final ExplorerPane pane;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dir = pane.current;
|
|
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
|
final up = !samePath(dir, pane.root);
|
|
final shown = sel.shown(pane, dir);
|
|
final hidden = entries.where((e) => ctl.appHidden(e.path)).length;
|
|
final rows = [
|
|
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
|
for (final e in shown) TreeRow(e, 0),
|
|
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
|
|
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
|
|
];
|
|
final look = ctl.look;
|
|
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
|
return LayoutBuilder(builder: (context, box) {
|
|
if (look.columns) layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
|
|
return parts.frame(0, [
|
|
Container(
|
|
height: 40,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text(ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
|
|
),
|
|
const Divider(height: 1),
|
|
if (pane.filterOpen) ExplorerFilterBar(pane: pane, shown: shown.length, onClose: () => parts.closeFilter(pane), onChanged: parts.filterChanged),
|
|
?noticeArea(parts.notices(pane, dir), box.maxHeight),
|
|
if (look.columns)
|
|
ExplorerColumnsHeader(
|
|
style: look,
|
|
markColumn: sel.selecting,
|
|
hide: pane.colHide,
|
|
sortBy: ctl.sortOf(dir).$1,
|
|
sortDesc: ctl.sortOf(dir).$2,
|
|
onSort: (by) => ctl.sortByColumn(pane, by),
|
|
),
|
|
Expanded(
|
|
// 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다)
|
|
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
|
|
? const Center(child: CircularProgressIndicator())
|
|
: entries.isEmpty && !up
|
|
? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
|
|
: scaleListText(context, parts.listTextScale, ListView.builder(
|
|
controller: pane.listScroll,
|
|
itemCount: rows.length,
|
|
itemExtent: parts.rowHeight,
|
|
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true),
|
|
findChildIndexCallback: _rowIndexOf(rows),
|
|
)),
|
|
),
|
|
if (sel.selecting) parts.marksBar(pane, listDir: dir),
|
|
]);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
|
|
class ExplorerFilterBar extends StatelessWidget {
|
|
const ExplorerFilterBar({super.key, required this.pane, required this.shown, required this.onClose, required this.onChanged});
|
|
|
|
final ExplorerPane pane;
|
|
final int shown;
|
|
final VoidCallback onClose, onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final none = pane.filter.isNotEmpty && shown == 0;
|
|
return Container(
|
|
height: 40,
|
|
color: JjColors.panel,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(children: [
|
|
Icon(Icons.filter_alt, size: 16, color: JjColors.accent),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: CallbackShortcuts(
|
|
bindings: {const SingleActivator(LogicalKeyboardKey.escape): onClose},
|
|
child: TextField(
|
|
controller: pane.filterCtl,
|
|
focusNode: pane.filterFocus,
|
|
style: const TextStyle(fontSize: 13),
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
border: InputBorder.none,
|
|
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
|
|
),
|
|
onChanged: (_) => onChanged(),
|
|
),
|
|
),
|
|
),
|
|
if (pane.filter.isNotEmpty)
|
|
Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]),
|
|
style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)),
|
|
IconButton(
|
|
tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'),
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(Icons.close, size: 16),
|
|
onPressed: onClose,
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기.
|
|
/// X-plore 숨기기: [숨긴 항목 n개 보이기] 줄도 (누르면 보이기 · 다시 숨기기)
|
|
class ExplorerExtraRow extends StatelessWidget {
|
|
const ExplorerExtraRow({super.key, required this.ctl, required this.pane, required this.row});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerPane pane;
|
|
final TreeRow row;
|
|
|
|
Widget _hiddenToggle() {
|
|
final look = ctl.look;
|
|
final label = ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
|
|
return Semantics(
|
|
button: true,
|
|
label: label,
|
|
excludeSemantics: true,
|
|
onTap: ctl.toggleAppHidden,
|
|
child: InkWell(
|
|
onTap: ctl.toggleAppHidden,
|
|
child: Row(children: [
|
|
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
|
|
Icon(ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(label,
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggle();
|
|
final look = ctl.look;
|
|
final path = row.entry.path;
|
|
final volume = ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
|
|
final fav = row.kind == TreeRow.favorite
|
|
? ctl.c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
|
|
: null;
|
|
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
|
|
TreeRow.otherRoot => (
|
|
isDav(path)
|
|
? Icons.cloud_outlined
|
|
: Platform.isWindows
|
|
? Icons.storage
|
|
: Icons.sd_storage_outlined,
|
|
JjColors.accent,
|
|
volume,
|
|
false,
|
|
),
|
|
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
|
|
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
|
|
};
|
|
void tap() {
|
|
ctl
|
|
..active = ctl.panes.indexOf(pane)
|
|
..update();
|
|
if (row.kind == TreeRow.favorites) {
|
|
pane.favoritesOpen = !pane.favoritesOpen;
|
|
pane.changed();
|
|
return;
|
|
}
|
|
unawaited(ctl.goTo(pane, path, fresh: true).then((_) => ctl.host.reveal(pane, path)));
|
|
}
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label: label,
|
|
excludeSemantics: true,
|
|
onTap: tap,
|
|
child: InkWell(
|
|
onTap: tap,
|
|
child: Row(children: [
|
|
SizedBox(width: look.indent * row.depth + 4),
|
|
SizedBox(
|
|
width: treeChevronWidth(defaultTargetPlatform),
|
|
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
|
|
),
|
|
Icon(icon, size: look.iconSize * 0.8, color: color),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
|
|
if (fav != null)
|
|
Text(ctl.displayPath(path),
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|