JJ_MKVMaker/lib/ui/explorer_look.dart

231 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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, JjColors.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, JjColors.isLight ? const Color(0xFF78909C) : const Color(0xFFB0BEC5));
return (Icons.insert_drive_file, JjColors.isLight ? const Color(0xFF78909C) : 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, JjColors.isLight ? const Color(0xFF8D6E63) : const Color(0xFFBCAAA4));
return (Icons.description, JjColors.isLight ? const Color(0xFF78909C) : 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) {
final 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: 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')],
],
),
);
}
}