From 60398d22e831beafce0d0a15cf6ff720939e0466 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:55:29 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20P3:=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=84=20=EB=B6=84=EC=84=9D=EC=97=90=20[=EC=B9=B8?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=BC]=20(treemap,=2087=20=EC=9C=84)=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 공간 분석 화면에 세 번째 보기 [칸 그림]: 지금 폴더 안을 크기만큼의 칸으로 (squarified treemap). 큰 폴더 30개는 따로, 나머지 폴더는 한 칸, 이 폴더 바로 안의 파일도 한 칸 (회색, 누를 수 없음). 칸 = 이름 · 크기 · %. 폴더 칸을 누르면 그 안으로 (← 로 위로, [그 자리로] 는 예전 그대로). 지우기는 여기서 하지 않음 (87 결정 그대로). - 시험: storage_chart_test (넓이 비례 · 겹치지 않음 · 누르기). - 캡처: integration_test/windows_xplore_tools_shot_test.dart → release\test\shots_xplore_tools (격자 · 글 뷰어 · 일괄 이름 날짜 · 칸 그림, ko/en × 412/960). Co-Authored-By: Claude Opus 5.5 --- .../windows_xplore_tools_shot_test.dart | 123 ++++++++++++++++++ lib/ui/storage_chart.dart | 121 +++++++++++++++++ lib/ui/storage_page.dart | 25 +++- test/storage_chart_test.dart | 50 +++++++ 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 integration_test/windows_xplore_tools_shot_test.dart create mode 100644 lib/ui/storage_chart.dart create mode 100644 test/storage_chart_test.dart diff --git a/integration_test/windows_xplore_tools_shot_test.dart b/integration_test/windows_xplore_tools_shot_test.dart new file mode 100644 index 0000000..d32f3b7 --- /dev/null +++ b/integration_test/windows_xplore_tools_shot_test.dart @@ -0,0 +1,123 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/batch_rename.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/text_codec.dart'; +import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart'; +import 'package:jj_mkvmaker/ui/explorer_grid.dart'; +import 'package:jj_mkvmaker/ui/storage_page.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로 +/// (JJ_SHOT_DIR, 없으면 임시 폴더). 크기 412 · 960, 언어 ko · en. 시험 파일은 임시 폴더에만. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xptools'); + + for (final lang in ['ko', 'en']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('X-plore 도구 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_xptshot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + // 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949) + final pics = []; + for (var i = 0; i < 7; i++) { + final im = img.Image(width: 64, height: 48); + img.fill(im, color: img.ColorRgb8(40 + i * 30, 120, 200 - i * 20)); + if (i == 0) im.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56'); + final f = File(p.join(tmp.path, 'IMG_${1000 + i}.jpg'))..writeAsBytesSync(img.encodeJpg(im)); + pics.add(f.path); + } + File(p.join(tmp.path, 'clip.mkv')).writeAsBytesSync(List.filled(10, 0)); + final txt = File(p.join(tmp.path, 'memo.txt')) + ..writeAsBytesSync(encodeText(List.generate(30, (i) => '${i + 1}번째 줄 - 한글 메모 line $i').join('\n'), 'CP949').bytes); + for (final (n, size) in [('Videos', 900000), ('Photos', 400000), ('Docs', 120000), ('Music', 60000)]) { + final d = Directory(p.join(tmp.path, 'disk', n))..createSync(recursive: true); + File(p.join(d.path, 'big.bin')).writeAsBytesSync(List.filled(size, 0)); + } + File(p.join(tmp.path, 'disk', 'loose.bin')).writeAsBytesSync(List.filled(80000, 0)); + + final shotKey = GlobalKey(); + final nav = GlobalKey(); + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(navigatorKey: nav, theme: buildTheme(), debugShowCheckedModeBanner: false, home: const Scaffold()), + ))); + Future settle([int n = 12]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(4); + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'xptools_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // g 격자 보기 + final entries = [ + for (final x in [...pics, p.join(tmp.path, 'clip.mkv'), txt.path]) + FileEntry(x, isDir: false, size: File(x).lengthSync(), modified: DateTime(2026, 10, 18)), + ]; + nav.currentState!.push(MaterialPageRoute( + builder: (_) => ExplorerGridPage( + title: 'Photos', + entries: entries, + imageExts: const ['jpg'], + videoThumb: (_) async => null, + onOpen: (_) async {}, + ), + )); + await settle(20); + await shot('grid'); + nav.currentState!.pop(); + await settle(); + + // h 글 뷰어 (CP949 를 스스로 알아봄) + nav.currentState!.push(MaterialPageRoute(builder: (_) => TextViewerPage(path: txt.path))); + await settle(20); + await shot('text'); + nav.currentState!.pop(); + await settle(); + + // i 일괄 이름 바꾸기 - [D] (사진은 찍은 때) + showBatchRename(nav.currentContext!, pics.take(4).toList(), + isDir: (_) => false, initial: const RenameRule(template: '[D]_[T]_[C]')); + await settle(30); + await shot('rename_date'); + await t.tap(find.text(lang == 'ko' ? '취소' : 'Cancel').last); + await settle(); + + // P3 저장 공간 그림 + nav.currentState!.push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: p.join(tmp.path, 'disk')))); + await settle(30); + await t.tap(find.text(lang == 'ko' ? '칸 그림' : 'Treemap')); + await settle(); + await shot('storage_chart'); + nav.currentState!.pop(); + await settle(); + await t.pumpWidget(const SizedBox()); + await settle(4); + }); + } + } +} diff --git a/lib/ui/storage_chart.dart b/lib/ui/storage_chart.dart new file mode 100644 index 0000000..2c94bc7 --- /dev/null +++ b/lib/ui/storage_chart.dart @@ -0,0 +1,121 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart' show formatSize; +import 'theme.dart'; + +/// X-plore 비교 P3 (87 위): 저장 공간 그림 - 크기만큼 넓이를 차지하는 칸 (squarified treemap). +/// 칸을 누르면 그 폴더 안으로 ([path] 가 없는 칸 - 이 폴더의 파일 · 나머지 - 은 누를 수 없음). +class StorageBlock { + final String label; + final int size; + final String? path; + const StorageBlock(this.label, this.size, [this.path]); +} + +class StorageTreemap extends StatelessWidget { + final List blocks; + final void Function(StorageBlock b) onTap; + const StorageTreemap({super.key, required this.blocks, required this.onTap}); + + /// 칸 색 (크기 차례대로 돌려 씀) + static const _colors = [ + Color(0xFF2E7D9A), Color(0xFF7E57C2), Color(0xFF43A047), Color(0xFFEF6C00), + Color(0xFFC2185B), Color(0xFF00897B), Color(0xFF5C6BC0), Color(0xFF8D6E63), + ]; + + /// [blocks] (큰 것부터) 를 [area] 안에 나눠 놓은 사각형들. 크기가 0 인 것은 빠짐 + static List<(StorageBlock, Rect)> layout(List blocks, Rect area) { + final items = [for (final b in blocks) if (b.size > 0) b]..sort((a, b) => b.size.compareTo(a.size)); + final total = items.fold(0, (s, b) => s + b.size); + if (items.isEmpty || total == 0 || area.isEmpty) return const []; + final scale = area.width * area.height / total; + final out = <(StorageBlock, Rect)>[]; + var rect = area; + var i = 0; + while (i < items.length) { + final side = math.min(rect.width, rect.height); + // 한 줄에 넣을 것: 가장 나쁜 가로세로 비가 더 나빠지기 전까지 + final row = [items[i]]; + double worst(List r) { + final s = r.fold(0, (t, b) => t + b.size * scale); + final mx = r.map((b) => b.size * scale).reduce(math.max), mn = r.map((b) => b.size * scale).reduce(math.min); + return math.max(side * side * mx / (s * s), (s * s) / (side * side * mn)); + } + + var j = i + 1; + while (j < items.length && worst([...row, items[j]]) <= worst(row)) { + row.add(items[j]); + j++; + } + final rowArea = row.fold(0, (t, b) => t + b.size * scale); + final thick = side == 0 ? 0.0 : rowArea / side; + var at = 0.0; + final horizontal = rect.width >= rect.height; // 넓으면 왼쪽에 세로 줄, 높으면 위에 가로 줄 + for (final b in row) { + final len = thick == 0 ? 0.0 : b.size * scale / thick; + out.add(( + b, + horizontal + ? Rect.fromLTWH(rect.left, rect.top + at, thick, len) + : Rect.fromLTWH(rect.left + at, rect.top, len, thick), + )); + at += len; + } + rect = horizontal + ? Rect.fromLTRB(rect.left + thick, rect.top, rect.right, rect.bottom) + : Rect.fromLTRB(rect.left, rect.top + thick, rect.right, rect.bottom); + i = j; + } + return out; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (_, c) { + final cells = layout(blocks, Offset.zero & Size(c.maxWidth, c.maxHeight)); + if (cells.isEmpty) return const SizedBox(); + final total = blocks.fold(0, (s, b) => s + math.max(0, b.size)); + return Stack(children: [ + for (var k = 0; k < cells.length; k++) + Positioned.fromRect( + rect: cells[k].$2.deflate(1), + child: _cell(cells[k].$1, k, total), + ), + ]); + }); + } + + Widget _cell(StorageBlock b, int k, int total) { + final color = b.path == null ? JjColors.panelHigh : _colors[k % _colors.length]; + final pct = total == 0 ? 0 : (b.size * 100 / total); + return Tooltip( + message: '${b.label} · ${formatSize(b.size)} · ${pct.toStringAsFixed(1)}%', + child: Material( + color: color, + borderRadius: BorderRadius.circular(3), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: b.path == null ? null : () => onTap(b), + child: LayoutBuilder( + builder: (_, c) => c.maxWidth < 44 || c.maxHeight < 30 + ? const SizedBox() + : Padding( + padding: const EdgeInsets.all(4), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(b.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)), + if (c.maxHeight >= 46) + Text('${formatSize(b.size)} · ${pct.toStringAsFixed(pct < 10 ? 1 : 0)}%', + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)), + ]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index 4bef8f6..fdbf208 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -4,6 +4,7 @@ import '../core/file_ops.dart'; import '../core/storage_scan.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; +import 'storage_chart.dart'; import 'theme.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. @@ -30,7 +31,7 @@ class _StorageAnalysisPageState extends State { var _dirs = 0, _bytes = 0; var _stop = false; late String _cur = widget.root; - var _tab = 0; // 0 큰 폴더 · 1 큰 파일 + var _tab = 0; // 0 큰 폴더 · 1 큰 파일 · 2 그림 (X-plore 비교 P3) @override void initState() { @@ -144,10 +145,11 @@ class _StorageAnalysisPageState extends State { child: Wrap(spacing: 6, children: [ ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), + ChoiceChip(label: Text(tr('칸 그림')), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)), ]), ), const Divider(height: 1), - Expanded(child: _tab == 0 ? _folders(s) : _files(s)), + Expanded(child: switch (_tab) { 0 => _folders(s), 1 => _files(s), _ => _chart(s) }), ]), ), ); @@ -183,6 +185,25 @@ class _StorageAnalysisPageState extends State { ); } + /// X-plore 비교 P3: 지금 폴더 안을 크기만큼의 칸으로. 큰 폴더 30개까지 따로, 나머지 폴더는 한 칸, 이 폴더 바로 안의 파일도 한 칸 + Widget _chart(StorageScan s) { + final dirs = s.biggestIn(_cur); + final total = s.sizeOf(_cur); + final shown = dirs.take(30).toList(); + final rest = dirs.skip(30).fold(0, (t, d) => t + s.sizeOf(d)); + final own = total - dirs.fold(0, (t, d) => t + s.sizeOf(d)); + final blocks = [ + for (final d in shown) StorageBlock(scanName(d), s.sizeOf(d), d), + if (rest > 0) StorageBlock(trf('나머지 폴더 {0}개', [dirs.length - shown.length]), rest), + if (own > 0) StorageBlock(tr('이 폴더의 파일'), own), + ]; + if (total == 0 || blocks.every((b) => b.size <= 0)) return Center(child: Text(tr('비어 있습니다'))); + return Padding( + padding: const EdgeInsets.all(8), + child: StorageTreemap(blocks: blocks, onTap: (b) => setState(() => _cur = b.path!)), + ); + } + Widget _files(StorageScan s) { final list = [for (final f in s.bigFiles) if (isSameOrInside(f.path, _cur)) f]; if (list.isEmpty) return Center(child: Text(tr('큰 파일이 없습니다'))); diff --git a/test/storage_chart_test.dart b/test/storage_chart_test.dart new file mode 100644 index 0000000..fb864f7 --- /dev/null +++ b/test/storage_chart_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/storage_chart.dart'; + +/// X-plore 비교 P3 (87 위): 저장 공간 그림 (treemap) - 넓이는 크기에 비례, 겹치지 않고 다 채움, 누르면 그 폴더 +void main() { + test('칸 나누기: 넓이 비례 · 영역 안 · 겹치지 않음 · 0 은 빠짐', () { + const area = Rect.fromLTWH(0, 0, 400, 300); + final blocks = [ + const StorageBlock('a', 600, 'A'), + const StorageBlock('b', 300, 'B'), + const StorageBlock('c', 100, 'C'), + const StorageBlock('d', 200), + const StorageBlock('z', 0, 'Z'), + ]; + final cells = StorageTreemap.layout(blocks, area); + expect(cells.map((c) => c.$1.label), ['a', 'b', 'd', 'c'], reason: '큰 것부터, 0 은 빠짐'); + const total = 1200; + for (final (b, r) in cells) { + expect(r.width * r.height, closeTo(area.width * area.height * b.size / total, 0.5)); + expect(area.inflate(0.01).contains(r.topLeft) && area.inflate(0.01).contains(r.bottomRight - const Offset(0.001, 0.001)), isTrue); + } + for (var i = 0; i < cells.length; i++) { + for (var j = i + 1; j < cells.length; j++) { + final o = cells[i].$2.intersect(cells[j].$2); + expect(o.width <= 0.01 || o.height <= 0.01, isTrue, reason: '${cells[i].$1.label} · ${cells[j].$1.label} 겹침'); + } + } + expect(StorageTreemap.layout(const [], area), isEmpty); + }); + + testWidgets('폴더 칸을 누르면 그 폴더, 경로 없는 칸 (이 폴더의 파일) 은 누를 수 없음', (t) async { + final tapped = []; + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: StorageTreemap( + blocks: const [StorageBlock('Videos', 800, '/v'), StorageBlock('files', 200)], + onTap: (b) => tapped.add(b.path!), + ), + ), + ), + )); + await t.tap(find.text('Videos')); + await t.tap(find.text('files')); + expect(tapped, ['/v']); + }); +}