230 lines
11 KiB
Dart
230 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../core/file_ops.dart';
|
||
import '../core/playlist.dart' show isVideoFile;
|
||
import '../l10n/tr.dart';
|
||
import 'theme.dart';
|
||
|
||
/// 파일 탐색기 모양 (환경 설정 > 파일 탐색기 > 스타일)
|
||
///
|
||
/// - X-plore: 큰 아이콘 · 동영상 썸네일 · 두 줄 (이름 / 크기 · 날짜 · 해상도 · 길이)
|
||
/// - Windows 탐색기: 작은 컬러 아이콘 · 한 줄 · 열 (이름 · 수정한 날짜 · 유형 · 크기)
|
||
/// - Total Commander: 아주 작은 아이콘 · 촘촘한 한 줄 · 폴더는 [이름] · 열 (이름 · 확장자 · 크기 · 날짜), 크기 칸의 `<DIR>`
|
||
enum ExplorerStyle {
|
||
xplore('X-plore'),
|
||
windows('Windows 탐색기'),
|
||
totalcmd('Total Commander');
|
||
|
||
final String label;
|
||
const ExplorerStyle(this.label);
|
||
|
||
static ExplorerStyle of(String name) => values.firstWhere((s) => s.name == name, orElse: () => xplore);
|
||
|
||
double get rowHeight => switch (this) { xplore => 52, windows => 34, totalcmd => 26 };
|
||
|
||
/// 253: 줄 높이 - 기기 글자 크기를 키우면 그만큼 (고정 높이라 1.6배에서 이름 · 크기 두 줄이 넘쳤다).
|
||
/// X-plore 비교 40: [textScale] 목록 글자 크기 (기기 글자 크기와 곱함) · [spacing] 줄 간격 ([rowSpacingFactor])
|
||
double rowHeightOf(BuildContext context, {double textScale = 1, double spacing = 1}) {
|
||
final s = MediaQuery.textScalerOf(context).scale(fontSize) * textScale / fontSize;
|
||
return ((s <= 1 ? rowHeight : rowHeight * s) * spacing).roundToDouble();
|
||
}
|
||
double get iconSize => switch (this) { xplore => 30, windows => 20, totalcmd => 16 };
|
||
double get indent => switch (this) { xplore => 16, windows => 14, totalcmd => 12 };
|
||
double get fontSize => switch (this) { xplore => 14, windows => 13, totalcmd => 12.5 };
|
||
|
||
/// 썸네일 · 두 줄 정보 (X-plore 만)
|
||
bool get rich => this == xplore;
|
||
|
||
/// 열 머리 (이름 · 날짜 · 유형 · 크기)
|
||
bool get columns => this != xplore;
|
||
}
|
||
|
||
const _images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic'};
|
||
const _audio = {'mp3', 'm4a', 'flac', 'wav', 'ogg', 'aac', 'opus', 'wma'};
|
||
const _subs = {'srt', 'ass', 'ssa', 'smi', 'sami', 'vtt'};
|
||
const _archives = {'zip', '7z', 'rar', 'tar', 'gz', 'xz', 'bz2'};
|
||
const _texts = {'txt', 'log', 'md', 'json', 'xml', 'ini', 'csv', 'yaml', 'yml'};
|
||
const _docs = {'doc', 'docx', 'hwp', 'hwpx', 'odt', 'rtf'};
|
||
const _sheets = {'xls', 'xlsx', 'ods'};
|
||
const _slides = {'ppt', 'pptx', 'odp'};
|
||
const _programs = {'exe', 'msi', 'bat', 'cmd', 'ps1', 'apk'};
|
||
|
||
/// X-plore 비교 40: 줄 간격 설정 → 줄 높이 배수 (좁게 · 보통 (지금 그대로) · 넓게)
|
||
double rowSpacingFactor(String spacing) => switch (spacing) { 'compact' => 0.8, 'wide' => 1.25, _ => 1.0 };
|
||
|
||
/// X-plore 비교 40: 목록 글자만 [factor] 배 (앱 화면 크기 · 기기 글자 크기에 곱함). 1 이면 그대로
|
||
Widget scaleListText(BuildContext context, double factor, Widget child) {
|
||
if (factor == 1) return child;
|
||
final mq = MediaQuery.of(context);
|
||
return MediaQuery(data: mq.copyWith(textScaler: _TimesScaler(mq.textScaler, factor)), child: child);
|
||
}
|
||
|
||
class _TimesScaler extends TextScaler {
|
||
final TextScaler base;
|
||
final double factor;
|
||
const _TimesScaler(this.base, this.factor);
|
||
|
||
@override
|
||
double scale(double fontSize) => base.scale(fontSize) * factor;
|
||
|
||
// ignore: deprecated_member_use
|
||
@override
|
||
double get textScaleFactor => base.scale(14) / 14 * factor;
|
||
|
||
@override
|
||
bool operator ==(Object other) => other is _TimesScaler && other.base == base && other.factor == factor;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(base, factor);
|
||
}
|
||
|
||
/// 관리자 지시 10/20: 좁은 창의 열 (Windows 탐색기 · Total Commander) - 이름 열이 120 (× 글자 배율) 보다 좁으면 유형 (TC 는 확장자) →
|
||
/// 날짜 순으로 숨긴다 (크기는 남김). 0 = 다 보임 · 1 = 유형 숨김 · 2 = 유형 · 날짜 숨김. 화면이 모자랄 때만 - 열 설정은 그대로.
|
||
/// [width] 창 폭, [marks] 줄 끝 ✔ 칸 (36) 이 있는지, [textScale] 목록 글자 배율 (기기 × 목록 글자 크기)
|
||
int columnHideLevel(ExplorerStyle style, double width, {bool marks = false, double textScale = 1}) {
|
||
if (!style.columns) return 0;
|
||
// 이름 열 120 (글자가 크면 그만큼 - 1.95 배 글자의 120 은 네 글자쯤이었다)
|
||
final minName = 120 * (textScale < 1 ? 1 : textScale);
|
||
final inner = width - 16 - (marks ? 36 : 0);
|
||
// 열 비율: Windows 이름 6 · 날짜 3 · 유형 2 · 크기 2, TC 이름 6 · 확장자 1 · 크기 2 · (8px) · 날짜 3
|
||
final flexes = style == ExplorerStyle.windows ? [13, 11, 8] : [12, 11, 8];
|
||
final extra = style == ExplorerStyle.totalcmd ? 8 : 0;
|
||
for (var level = 0; level < 2; level++) {
|
||
if ((inner - (level < 2 ? extra : 0)) * 6 / flexes[level] >= minName) return level;
|
||
}
|
||
return 2;
|
||
}
|
||
|
||
/// 파일 종류별 아이콘 · 색 (스타일마다 다르게)
|
||
(IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) {
|
||
final x = e.ext;
|
||
switch (style) {
|
||
case ExplorerStyle.xplore:
|
||
if (e.isDir) return (open ? Icons.folder_open : Icons.folder, Colors.amber);
|
||
return (
|
||
switch (x) {
|
||
_ when _subs.contains(x) => Icons.subtitles_outlined,
|
||
_ when _audio.contains(x) => Icons.audiotrack_outlined,
|
||
'pdf' => Icons.picture_as_pdf_outlined,
|
||
_ when _archives.contains(x) => Icons.folder_zip_outlined,
|
||
'apk' => Icons.android,
|
||
_ when _texts.contains(x) => Icons.description_outlined,
|
||
_ when _images.contains(x) => Icons.image_outlined,
|
||
_ when isVideoFile(e.path) => Icons.movie_outlined,
|
||
_ => Icons.insert_drive_file_outlined,
|
||
},
|
||
JjColors.textDim
|
||
);
|
||
case ExplorerStyle.windows:
|
||
// Windows 11 탐색기 느낌: 노란 폴더, 종류마다 색
|
||
if (e.isDir) return (open ? Icons.folder_open : Icons.folder, const Color(0xFFFFC83D));
|
||
if (isVideoFile(e.path)) return (Icons.video_file, const Color(0xFF5B9BD5));
|
||
if (_images.contains(x)) return (Icons.image, const Color(0xFF3FB3A8));
|
||
if (_audio.contains(x)) return (Icons.audio_file, const Color(0xFFB57EDC));
|
||
if (_subs.contains(x)) return (Icons.closed_caption, const Color(0xFF9FA8DA));
|
||
if (x == 'pdf') return (Icons.picture_as_pdf, const Color(0xFFE5534B));
|
||
if (_archives.contains(x)) return (Icons.folder_zip, const Color(0xFFD8A657));
|
||
if (_docs.contains(x)) return (Icons.article, const Color(0xFF4F8EDC));
|
||
if (_sheets.contains(x)) return (Icons.table_chart, const Color(0xFF4CAF7A));
|
||
if (_slides.contains(x)) return (Icons.slideshow, const Color(0xFFE8804F));
|
||
if (_programs.contains(x)) return (x == 'apk' ? Icons.android : Icons.terminal, const Color(0xFF8BC34A));
|
||
if (_texts.contains(x)) return (Icons.description, const Color(0xFFB0BEC5));
|
||
return (Icons.insert_drive_file, const Color(0xFFB0BEC5));
|
||
case ExplorerStyle.totalcmd:
|
||
// Total Commander 느낌: 단순한 작은 아이콘, 폴더는 노랑, 실행 파일은 초록
|
||
if (e.isDir) return (Icons.folder, const Color(0xFFE8C547));
|
||
if (_programs.contains(x)) return (Icons.settings_applications, const Color(0xFF7CB342));
|
||
if (isVideoFile(e.path)) return (Icons.movie, const Color(0xFF64B5F6));
|
||
if (_images.contains(x)) return (Icons.image, const Color(0xFF64B5F6));
|
||
if (_archives.contains(x)) return (Icons.inventory_2, const Color(0xFFBCAAA4));
|
||
return (Icons.description, const Color(0xFFCFD8DC));
|
||
}
|
||
}
|
||
|
||
/// Windows 탐색기의 "유형" 칸: "파일 폴더" · "MP4 파일"
|
||
String typeLabel(FileEntry e) => e.isDir
|
||
? tr('파일 폴더')
|
||
: e.ext.isEmpty
|
||
? tr('파일')
|
||
: trf('{0} 파일', [e.ext.toUpperCase()]);
|
||
|
||
/// Total Commander 의 이름 칸: 폴더는 [이름], 파일은 확장자를 뺀 이름 (확장자는 따로)
|
||
String totalCmdName(FileEntry e) {
|
||
if (e.isDir) return '[${e.name}]';
|
||
final n = e.name;
|
||
final dot = n.lastIndexOf('.');
|
||
return dot > 0 ? n.substring(0, dot) : n;
|
||
}
|
||
|
||
/// 열 머리 (Windows 탐색기 · Total Commander)
|
||
class ExplorerColumnsHeader extends StatelessWidget {
|
||
final ExplorerStyle style;
|
||
|
||
/// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다
|
||
final bool markColumn;
|
||
|
||
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜)
|
||
final int hide;
|
||
|
||
/// 84: 지금 정렬 ('name' · 'date' · 'size' · 'type') · 거꾸로 - 그 열에 ▲ ▼
|
||
final String? sortBy;
|
||
final bool sortDesc;
|
||
|
||
/// 84: 열 머리를 누르면 (같은 열을 다시 누르면 거꾸로 - 부르는 쪽이 정한다)
|
||
final void Function(String by)? onSort;
|
||
const ExplorerColumnsHeader(
|
||
{super.key, required this.style, this.markColumn = false, this.hide = 0, this.sortBy, this.sortDesc = false, this.onSort});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const st = TextStyle(fontSize: 11.5, color: JjColors.textDim);
|
||
Widget cell(String t, int flex, {TextAlign align = TextAlign.start, String? by}) {
|
||
final on = by != null && by == sortBy;
|
||
final label = Row(
|
||
mainAxisAlignment: align == TextAlign.end ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||
children: [
|
||
Flexible(
|
||
child: Text(t,
|
||
style: on ? st.copyWith(color: JjColors.accent, fontWeight: FontWeight.w600) : st,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.clip),
|
||
),
|
||
if (on) Icon(sortDesc ? Icons.arrow_drop_down : Icons.arrow_drop_up, size: 16, color: JjColors.accent),
|
||
],
|
||
);
|
||
return Expanded(
|
||
flex: flex,
|
||
child: by == null || onSort == null
|
||
? label
|
||
: Tooltip(
|
||
message: on
|
||
? (sortDesc ? tr('누르면 오름차순') : tr('누르면 내림차순'))
|
||
: trf('{0} 순으로 정렬', [t]),
|
||
child: InkWell(onTap: () => onSort!(by), child: SizedBox(height: 24, child: Align(alignment: Alignment.centerLeft, child: label))),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
height: 24,
|
||
padding: EdgeInsets.only(left: 10, right: markColumn ? 40 : 4),
|
||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: JjColors.border))),
|
||
child: Row(
|
||
children: style == ExplorerStyle.windows
|
||
? [
|
||
cell(tr('이름'), 6, by: 'name'),
|
||
if (hide < 2) cell(tr('수정한 날짜'), 3, by: 'date'),
|
||
if (hide < 1) cell(tr('유형'), 2, by: 'type'),
|
||
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
|
||
]
|
||
: [
|
||
cell(tr('이름'), 6, by: 'name'),
|
||
if (hide < 1) cell(tr('확장자'), 1, by: 'type'),
|
||
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
|
||
if (hide < 2) ...[const SizedBox(width: 8), cell(tr('날짜'), 3, by: 'date')],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|