import 'package:flutter/material.dart'; 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'; import 'jj_title_bar.dart'; import 'jj_chip.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. /// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값). /// 지우기는 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단) - X-plore 비교 5-1 ⑤ (10/21): 이 화면의 ⋮ · 길게 누르기에서 탐색기의 /// 그 지우기 ([onDelete]) 를 부르고, 지운 것은 다시 훑지 않고 결과에서 뺀다. 이동은 탐색기의 [이동 (붙여넣기로)] ([onCut]) class StorageAnalysisPage extends StatefulWidget { final String root; /// 시험에서 바꿔 끼움 final StorageScanner? scanner; /// 탐색기의 지우기 (확인 창 · 휴지통). 실제로 지워진 것을 돌려준다 (취소면 빈 목록) final Future> Function(List paths)? onDelete; /// 탐색기의 [이동 (붙여넣기로)] 에 담기 final void Function(List paths)? onCut; const StorageAnalysisPage({super.key, required this.root, this.scanner, this.onDelete, this.onCut}); /// 연다. 탐색기에서 갈 자리 (없으면 null) static Future open(BuildContext context, String root, {Future> Function(List paths)? onDelete, void Function(List paths)? onCut}) => Navigator.of(context) .push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root, onDelete: onDelete, onCut: onCut))); @override State createState() => _StorageAnalysisPageState(); } class _StorageAnalysisPageState extends State { StorageScan? _scan; Object? _error; var _dirs = 0, _bytes = 0; var _stop = false; late String _cur = widget.root; var _tab = 0; // 0 큰 폴더 · 1 큰 파일 · 2 그림 (X-plore 비교 P3) @override void initState() { super.initState(); _run(); } @override void dispose() { _stop = true; // 화면을 닫으면 멈춘다 super.dispose(); } Future _run() async { try { final s = await (widget.scanner ?? StorageScanner()).scan(widget.root, cancelled: () => _stop, onProgress: (d, b) { if (mounted) { setState(() { _dirs = d; _bytes = b; }); } }); if (mounted) setState(() => _scan = s); } on StorageScanCancelled { // 닫음 } catch (e) { if (mounted) setState(() => _error = e); } } /// X-plore 비교 5-1 ⑤: 그 자리에서 할 일 - [그 자리로] · [지우기] (탐색기의 확인 · 휴지통) · [이동 (붙여넣기로)] Future _act(String path, String what, {int size = 0}) async { switch (what) { case 'go': Navigator.pop(context, path); case 'delete': final done = await widget.onDelete?.call([path]) ?? const []; if (!mounted || done.isEmpty) return; setState(() { for (final x in done) { _scan?.remove(x, fileSize: size); } if (done.any((x) => isSameOrInside(_cur, x))) _cur = vDirname(done.first); }); case 'cut': widget.onCut?.call([path]); ScaffoldMessenger.maybeOf(context) ?.showSnackBar(SnackBar(content: Text(tr('이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]')))); } } /// 줄의 ⋮ 메뉴 (길게 누르기도 같은 메뉴) Widget _menuButton(String path, {int size = 0}) => PopupMenuButton( tooltip: tr('더 보기'), icon: const Icon(Icons.more_vert, size: 20), onSelected: (w) => _act(path, w, size: size), itemBuilder: (_) => [ PopupMenuItem(value: 'go', child: ListTile(dense: true, leading: const Icon(Icons.folder_open), title: Text(tr('그 자리로')))), if (widget.onDelete != null) PopupMenuItem(value: 'delete', child: ListTile(dense: true, leading: const Icon(Icons.delete_outline), title: Text(tr('지우기 (확인 · 휴지통)')))), if (widget.onCut != null) PopupMenuItem(value: 'cut', child: ListTile(dense: true, leading: const Icon(Icons.content_cut), title: Text(tr('이동 (붙여넣기로)')))), ], ); Future _menuAt(Offset at, String path, {int size = 0}) async { final w = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy), items: [ PopupMenuItem(value: 'go', child: Text(tr('그 자리로'))), if (widget.onDelete != null) PopupMenuItem(value: 'delete', child: Text(tr('지우기 (확인 · 휴지통)'))), if (widget.onCut != null) PopupMenuItem(value: 'cut', child: Text(tr('이동 (붙여넣기로)'))), ], ); if (w != null && mounted) await _act(path, w, size: size); } Widget _bar(double v) => ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: v.clamp(0, 1), minHeight: 6, color: JjColors.accent, backgroundColor: JjColors.border), ); @override Widget build(BuildContext context) { final s = _scan; final canUp = s != null && !samePath(_cur, widget.root); return PopScope( canPop: !canUp, onPopInvokedWithResult: (didPop, _) { if (!didPop && canUp) setState(() => _cur = vDirname(_cur)); }, child: Scaffold( appBar: jjTitleBar( context, title: tr('저장 공간 분석'), actions: [ if (s == null && _error == null) TextButton( onPressed: () { _stop = true; Navigator.pop(context); }, child: Text(tr('취소')), ), ], ), body: s == null ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column(mainAxisSize: MainAxisSize.min, children: [ if (_error == null) const CircularProgressIndicator(), const SizedBox(height: 12), Text(_error != null ? trf('분석하지 못했습니다: {0}', [_error!]) : trf('읽는 중 · 폴더 {0}개 · {1}', [_dirs, formatSize(_bytes)]), textAlign: TextAlign.center), const SizedBox(height: 4), Text(vDisplay(widget.root), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ]), ), ) : Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: Row(children: [ if (canUp) IconButton( tooltip: tr('위 폴더'), icon: const Icon(Icons.arrow_upward), onPressed: () => setState(() => _cur = vDirname(_cur)), ), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(vDisplay(_cur), maxLines: 2, overflow: TextOverflow.ellipsis), Text(trf('{0} · 파일 {1}개', [formatSize(s.sizeOf(_cur)), s.filesOf(_cur)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ]), ), TextButton.icon( onPressed: () => Navigator.pop(context, _cur), icon: const Icon(Icons.folder_open, size: 18), label: Text(tr('그 자리로')), ), ]), ), if (s.unreadable > 0 || s.links > 0) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( [ if (s.unreadable > 0) trf('읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)', [s.unreadable]), if (s.links > 0) trf('링크 {0}개는 세지 않음', [s.links]), ].join(' · '), style: TextStyle(fontSize: 12, color: JjColors.warning), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Wrap(spacing: 6, children: [ JjChip(label: tr('큰 폴더'), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), JjChip(label: tr('큰 파일'), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), JjChip(label: tr('칸 그림'), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)), ]), ), const Divider(height: 1), Expanded(child: switch (_tab) { 0 => _folders(s), 1 => _files(s), _ => _chart(s) }), ]), ), ); } Widget _folders(StorageScan s) { final list = s.biggestIn(_cur); final total = s.sizeOf(_cur); if (list.isEmpty) return Center(child: Text(tr('안에 폴더가 없습니다'))); return ListView.builder( itemCount: list.length, itemBuilder: (_, i) { final d = list[i]; final size = s.sizeOf(d); return GestureDetector( onLongPressStart: (x) => _menuAt(x.globalPosition, d), onSecondaryTapUp: (x) => _menuAt(x.globalPosition, d), child: ListTile( dense: true, leading: Icon(Icons.folder, color: JjColors.amber), title: Text(scanName(d), maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), _bar(total == 0 ? 0 : size / total), const SizedBox(height: 2), Text('${formatSize(size)} · ${trf('파일 {0}개', [s.filesOf(d)])}', style: const TextStyle(fontSize: 11)), ]), trailing: _menuButton(d), onTap: () => setState(() => _cur = d), )); }, ); } /// 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('큰 파일이 없습니다'))); final top = list.first.size; return ListView.builder( itemCount: list.length, itemBuilder: (_, i) { final f = list[i]; return GestureDetector( onLongPressStart: (x) => _menuAt(x.globalPosition, f.path, size: f.size), onSecondaryTapUp: (x) => _menuAt(x.globalPosition, f.path, size: f.size), child: ListTile( dense: true, leading: const Icon(Icons.insert_drive_file_outlined), title: Text(f.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), _bar(top == 0 ? 0 : f.size / top), const SizedBox(height: 2), // 폴더는 분석한 곳 기준으로 짧게 - 전체 경로는 마우스를 올리거나 길게 누르면 Tooltip( message: vDisplay(vDirname(f.path)), triggerMode: TooltipTriggerMode.longPress, child: Text('${formatSize(f.size)} · ${shortUnder(widget.root, vDirname(f.path))}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), ), ]), trailing: _menuButton(f.path, size: f.size), // 누르면 탐색기에서 그 파일 자리로 onTap: () => Navigator.pop(context, f.path), )); }, ); } }