324 lines
17 KiB
Dart
324 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/explorer/tree_rows.dart';
|
|
import '../../core/file_ops.dart' show FileEntry, formatSize, samePath;
|
|
import '../../core/name_clash.dart' show fileDateText;
|
|
import '../../core/playlist.dart' show isVideoFile;
|
|
import '../../core/selection.dart';
|
|
import '../../core/vfs.dart' show isDav;
|
|
import '../../l10n/tr.dart';
|
|
import '../explorer_drag.dart';
|
|
import '../explorer_look.dart';
|
|
import '../remote_thumb_view.dart';
|
|
import '../theme.dart';
|
|
import 'explorer_controller.dart';
|
|
import 'explorer_pane.dart';
|
|
import 'explorer_select.dart';
|
|
import 'file_thumbs.dart';
|
|
|
|
/// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역)
|
|
double treeChevronWidth(TargetPlatform platform) =>
|
|
platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20;
|
|
|
|
/// 탐색기 줄 하나 (E5d-2). 상태 · 일은 [ExplorerController] · [ExplorerSelect], 메뉴 · 끌어 놓기는 부르는 쪽 콜백.
|
|
/// [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
|
|
class ExplorerRowView extends StatelessWidget {
|
|
const ExplorerRowView({
|
|
super.key,
|
|
required this.ctl,
|
|
required this.sel,
|
|
required this.pane,
|
|
required this.row,
|
|
this.list = false,
|
|
this.compact = false,
|
|
required this.rowHeight,
|
|
required this.narrowSplit,
|
|
required this.alwaysMarks,
|
|
required this.onMenu,
|
|
required this.dragPaths,
|
|
required this.onDrop,
|
|
});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerSelect sel;
|
|
final ExplorerPane pane;
|
|
final TreeRow row;
|
|
final bool list, compact;
|
|
final double rowHeight;
|
|
final bool narrowSplit, alwaysMarks;
|
|
|
|
/// 길게 누르기 · 오른쪽 클릭 메뉴
|
|
final void Function(ExplorerPane pane, FileEntry e, Offset at) onMenu;
|
|
|
|
/// 끌 항목 (표시한 것 중 하나면 표시한 것 모두)
|
|
final List<String> Function(ExplorerPane pane, FileEntry e) dragPaths;
|
|
|
|
/// 폴더 줄 위에 놓음
|
|
final Future<void> Function(DragLoad load, int to, String dest) onDrop;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final e = row.entry;
|
|
final look = ctl.look;
|
|
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
|
|
final slim = compact && narrowSplit && !list;
|
|
// 이 줄은 목록 글자 배율을 이미 곱한 MediaQuery 안에 있다 (scaleListText) - 배율을 다시 곱하지 않는다
|
|
final nameLines = slim && rowHeight >= MediaQuery.textScalerOf(context).scale(look.fontSize) * 2.5 + 6 ? 2 : 1;
|
|
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
|
|
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
|
|
final marked = sel.isMarked(pane, e.path);
|
|
final open = (e.isDir || ctl.arc.contains(e.path)) && !list && pane.expanded.contains(e.path);
|
|
final loading = pane.loading.contains(e.path);
|
|
final name = row.isUp
|
|
? '..'
|
|
: row.isRoot
|
|
? (ctl.volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2)
|
|
: look == ExplorerStyle.totalcmd
|
|
? totalCmdName(e)
|
|
: e.name;
|
|
final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim);
|
|
final nameStyle = TextStyle(
|
|
fontSize: look.fontSize,
|
|
fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal,
|
|
color: isCurrent ? JjColors.accent : null,
|
|
);
|
|
|
|
// 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림)
|
|
final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform);
|
|
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
|
|
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게
|
|
Widget chevron() => SizedBox(
|
|
width: chevronWidth,
|
|
height: rowHeight,
|
|
child: (e.isDir || sel.canBrowseArchive(e)) && !list && !row.isUp
|
|
? (loading
|
|
? const Center(
|
|
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
|
|
// 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기")
|
|
: Semantics(
|
|
button: true,
|
|
label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]),
|
|
excludeSemantics: true,
|
|
onTap: () => sel.toggle(pane, e),
|
|
child: InkResponse(
|
|
radius: chevronWidth / 2 + 4,
|
|
containedInkWell: false,
|
|
onTap: () => sel.toggle(pane, e),
|
|
child: Center(
|
|
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)),
|
|
),
|
|
))
|
|
: null,
|
|
);
|
|
|
|
// 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게)
|
|
Widget nameCell() => LayoutBuilder(builder: (context, box) {
|
|
// 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다)
|
|
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4);
|
|
// 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게
|
|
final indent = list
|
|
? 0.0
|
|
: slim
|
|
? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble()
|
|
: (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble();
|
|
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
|
|
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
|
|
// 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
|
|
final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
|
|
final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
|
|
return Row(children: [
|
|
SizedBox(width: indent),
|
|
if (!list) chevron(),
|
|
SizedBox(
|
|
width: iconW,
|
|
height: rowHeight - 8,
|
|
child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))),
|
|
),
|
|
SizedBox(width: gapW),
|
|
Expanded(
|
|
child: look.rich && !e.isDir && !row.isUp
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
|
|
// 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도
|
|
// 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다)
|
|
isVideoFile(e.path)
|
|
? VideoMetaText(c: ctl.c, path: e.path, lead: '${formatSize(e.size)} · ${fileDateText(e.modified)}', leadStyle: dim)
|
|
: Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
],
|
|
)
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
|
|
if (slim)
|
|
const SizedBox.shrink()
|
|
else if (look.rich && e.isDir && pane.errors[e.path] != null)
|
|
Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert))
|
|
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
|
|
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && ctl.dirSize(pane, e) != null)
|
|
Text(ctl.dirSize(pane, e)!, style: dim, maxLines: 1),
|
|
],
|
|
),
|
|
),
|
|
]);
|
|
});
|
|
|
|
final real = !row.isRoot && !row.isUp;
|
|
// 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy])
|
|
final menu = ClickPolicy.decide(Press.longPress, ExplorerSelect.kindOf(row), selectMode: sel.selecting) == ClickAction.menu;
|
|
Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded(
|
|
flex: flex,
|
|
child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
);
|
|
// 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel])
|
|
final hide = pane.colHide;
|
|
final cells = !look.columns || compact || !real
|
|
? <Widget>[Expanded(child: nameCell())]
|
|
: look == ExplorerStyle.windows
|
|
? [
|
|
Expanded(flex: 6, child: nameCell()),
|
|
if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3),
|
|
if (hide < 1) cell(typeLabel(e), 2),
|
|
cell(e.isDir ? (ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
|
|
]
|
|
: [
|
|
Expanded(flex: 6, child: nameCell()),
|
|
if (hide < 1) cell(e.isDir ? '' : e.ext, 1),
|
|
cell(e.isDir ? (ctl.dirSize(pane, e) ?? '<DIR>') : formatSize(e.size), 2, align: TextAlign.end),
|
|
if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)],
|
|
];
|
|
|
|
final paneIndex = ctl.panes.indexOf(pane);
|
|
final drag = real && !ctl.rsync;
|
|
Widget line = GestureDetector(
|
|
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
|
|
// 25: Rsync 화면에서도 (폴더 메뉴)
|
|
onSecondaryTapUp: menu ? (d) => onMenu(pane, e, d.globalPosition) : null,
|
|
onLongPressStart: menu ? (d) => onMenu(pane, e, d.globalPosition) : null,
|
|
child: Material(
|
|
// U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대
|
|
color: marked
|
|
? JjColors.accent.withValues(alpha: 0.18)
|
|
: isFocused
|
|
? JjColors.accent.withValues(alpha: 0.09)
|
|
: Colors.transparent,
|
|
shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null,
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () => sel.onTap(pane, row, list: list),
|
|
// 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
|
|
// 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게)
|
|
onDoubleTap: ClickPolicy.decide(Press.doubleTap, ExplorerSelect.kindOf(row), selectMode: sel.selecting, tapOpens: !sel.selectMode) ==
|
|
ClickAction.open
|
|
? () => sel.onOpen(pane, row, list: list)
|
|
: null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 6, right: 4),
|
|
child: Row(children: cells),
|
|
),
|
|
),
|
|
),
|
|
// 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로)
|
|
if (drag && sel.selecting && marked)
|
|
Draggable<DragLoad>(
|
|
data: DragLoad(paneIndex, dragPaths(pane, e)),
|
|
feedback: dragFeedback(dragPaths(pane, e)),
|
|
child: Tooltip(
|
|
message: tr('끌어서 다른 창으로'),
|
|
child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
|
|
),
|
|
),
|
|
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
|
|
if (slim)
|
|
const SizedBox.shrink()
|
|
else if ((real || (ctl.rsync && row.isRoot)) && (sel.selecting || ctl.rsync || alwaysMarks))
|
|
SizedBox(
|
|
width: 36,
|
|
child: IconButton(
|
|
tooltip: ctl.rsync
|
|
? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기'))
|
|
: marked
|
|
? tr('표시 지우기')
|
|
: tr('표시 (여러 개 고르기)'),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
// 규칙 4: V 모양 - 고르지 않은 것은 흐리게, 고른 것은 진하게
|
|
icon: Icon(Icons.check,
|
|
size: look.rich ? 24 : 20,
|
|
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
|
|
onPressed: () {
|
|
ctl
|
|
..active = ctl.panes.indexOf(pane)
|
|
..update();
|
|
ctl.rsync ? sel.pick(pane, e) : sel.toggleMark(pane, e);
|
|
},
|
|
),
|
|
)
|
|
else if (sel.selecting || ctl.rsync || alwaysMarks)
|
|
const SizedBox(width: 36),
|
|
]),
|
|
),
|
|
);
|
|
// X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게
|
|
if (real && ctl.showAppHidden && ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line);
|
|
if (!drag) return line;
|
|
// 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조)
|
|
if (e.isDir) {
|
|
final inner = line;
|
|
line = DragTarget<DragLoad>(
|
|
onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path),
|
|
onAcceptWithDetails: (d) => unawaited(onDrop(d.data, paneIndex, e.path)),
|
|
builder: (context, ok, _) => ok.isEmpty
|
|
? inner
|
|
: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: JjColors.accent.withValues(alpha: 0.15),
|
|
border: Border.all(color: JjColors.accent, width: 1.5),
|
|
),
|
|
child: inner,
|
|
),
|
|
);
|
|
}
|
|
// 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로)
|
|
final paths = dragPaths(pane, e);
|
|
return MouseDraggable<DragLoad>(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line);
|
|
}
|
|
|
|
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
|
|
final look = ctl.look;
|
|
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
|
|
if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
|
|
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘
|
|
if (look.rich && isDav(e.path) && ctl.c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) {
|
|
final (icon, color) = fileIcon(look, e, open: open);
|
|
return RemoteThumb(
|
|
key: ValueKey('rthumb:${e.path}'), c: ctl.c, entry: e, fallback: Icon(icon, size: 28, color: color));
|
|
}
|
|
// X-plore: 동영상 썸네일 · 그림 미리 보기
|
|
if (look.rich && !e.isDir && !isDav(e.path)) {
|
|
if (isVideoFile(e.path)) return VideoThumb(c: ctl.c, entry: e);
|
|
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
|
if (images.contains(e.ext)) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(3),
|
|
child: Image.file(File(e.path),
|
|
width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
|
|
errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
|
|
);
|
|
}
|
|
}
|
|
final (icon, color) = fileIcon(look, e, open: open);
|
|
return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color);
|
|
}
|
|
}
|