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: 아주 작은 아이콘 · 촘촘한 한 줄 · 폴더는 [이름] · 열 (이름 · 확장자 · 크기 · 날짜), 크기 칸의 `` 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); } /// 파일 종류별 아이콘 · 색 (스타일마다 다르게) (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; /// 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.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'), cell(tr('수정한 날짜'), 3, by: 'date'), cell(tr('유형'), 2, by: 'type'), cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), ] : [ cell(tr('이름'), 6, by: 'name'), cell(tr('확장자'), 1, by: 'type'), cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), const SizedBox(width: 8), cell(tr('날짜'), 3, by: 'date'), ], ), ); } }