238 lines
9.7 KiB
Dart
238 lines
9.7 KiB
Dart
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';
|
|
|
|
/// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례.
|
|
/// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값).
|
|
/// 지우기는 여기서 하지 않는다 - 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단)
|
|
class StorageAnalysisPage extends StatefulWidget {
|
|
final String root;
|
|
|
|
/// 시험에서 바꿔 끼움
|
|
final StorageScanner? scanner;
|
|
const StorageAnalysisPage({super.key, required this.root, this.scanner});
|
|
|
|
/// 연다. 탐색기에서 갈 자리 (없으면 null)
|
|
static Future<String?> open(BuildContext context, String root) =>
|
|
Navigator.of(context).push<String>(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root)));
|
|
|
|
@override
|
|
State<StorageAnalysisPage> createState() => _StorageAnalysisPageState();
|
|
}
|
|
|
|
class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|
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<void> _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);
|
|
}
|
|
}
|
|
|
|
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: AppBar(
|
|
title: Text(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: const 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: const 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
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: 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 ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.folder, color: Colors.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: IconButton(
|
|
tooltip: tr('그 자리로'),
|
|
icon: const Icon(Icons.drive_file_move_outline, size: 20),
|
|
onPressed: () => Navigator.pop(context, 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<int>(0, (t, d) => t + s.sizeOf(d));
|
|
final own = total - dirs.fold<int>(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 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)),
|
|
),
|
|
]),
|
|
// 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서)
|
|
onTap: () => Navigator.pop(context, f.path),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|