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; /// E6 (결정 10/22): 창 폭이 이보다 좁으면 (폰 세로의 좌우 두 창 등) 줄을 좁은 모양으로 - 아이콘 칸 줄임 · 끌기 손잡이 숨김 · /// 열 대신 이름 먼저 (크기 · 날짜는 이름 아래 작은 글). 두 창 좌우 배치는 그대로 const narrowRowWidth = 200.0; /// 탐색기 줄 하나 (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, this.narrow = 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; /// 좁은 창의 줄 ([narrowRowWidth]) final bool narrow; final double rowHeight; final bool narrowSplit, alwaysMarks; /// 길게 누르기 · 오른쪽 클릭 메뉴 final void Function(ExplorerPane pane, FileEntry e, Offset at) onMenu; /// 끌 항목 (표시한 것 중 하나면 표시한 것 모두) final List Function(ExplorerPane pane, FileEntry e) dragPaths; /// 폴더 줄 위에 놓음 final Future 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; // E6: 좁은 창의 줄 (트리 · 목록 모두, 폴더 트리 (compact) 는 이미 이름만) final tight = narrow && !compact; // 크기 · 날짜 줄은 두 줄이 들어가는 줄 높이에서만 (Total Commander 26 에는 이름만 - 넘치지 않게) final ts = MediaQuery.textScalerOf(context); // 글 한 줄의 실제 높이는 글자 크기의 1.4배 안팎 (1.2 로 재면 큰 글자 · 넓은 줄 간격에서 1px 넘쳤다) final metaFits = rowHeight >= ts.scale(look.fontSize) * 1.4 + ts.scale(look.fontSize - 2) * 1.4 + 4; // 이 줄은 목록 글자 배율을 이미 곱한 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) // TC: 확장자 칸이 있으면 이름에서 뺀다. 칸이 없으면 (좁은 줄 · 좁아 숨긴 열) 이름에 붙여 정보를 숨기지 않는다 (관리자 10/23) : look == ExplorerStyle.totalcmd ? (!e.isDir && (tight || pane.colHide >= 1) ? e.name : 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 small = slim || tight; final iconW = math.min(small ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room); final gapW = math.min(small ? 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() // E6: 좁은 창은 열이 없으니 파일의 크기 · 날짜를 이름 아래에 else if (tight && metaFits && !e.isDir && !row.isUp && !row.isRoot) Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis) 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 || tight || !real ? [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) ?? '') : 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, doubleOpens: sel.doubleOpens(list: list)) == 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 && !tight) Draggable( 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( 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(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.explorer.remoteThumbs != '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); } }