JJ_MKVMaker/lib/ui/monitor/transfer_widgets.dart

146 lines
6.4 KiB
Dart

/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀).
/// 약속:
/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음.
/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음".
/// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림.
/// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩.
/// 설정 · 화면 상태를 읽지 않는다.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/copy_center.dart';
import '../../app/transfer_job.dart';
import '../../core/file_ops.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 실행 시각 (ISO) 을 "월/일 시:분" 으로. 날짜가 아니면 빈 글.
String formatRunStamp(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return '';
String two(int x) => x.toString().padLeft(2, '0');
return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}';
}
/// 대상 디스크 남은 용량 / 전체
class DiskSpaceInfo extends StatelessWidget {
final String path;
const DiskSpaceInfo({super.key, required this.path});
@override
Widget build(BuildContext context) {
var dir = path;
while (!Directory(dir).existsSync() && p.dirname(dir) != dir) {
dir = p.dirname(dir);
}
return FutureBuilder<(int, int)?>(
future: diskSpace(dir),
builder: (_, s) {
final d = s.data;
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim));
final (free, total) = d;
final used = total == 0 ? 0.0 : (total - free) / total;
return Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4),
// 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다
Flexible(
child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
const SizedBox(width: 8),
SizedBox(
width: 90,
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 6,
value: used,
color: used > 0.9 ? JjColors.alert : JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
]);
},
);
}
}
/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일)
class TransferProgressLines extends StatelessWidget {
final TransferJob job;
const TransferProgressLines({super.key, required this.job});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: job,
builder: (_, _) {
final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1);
String pct(double v) => '${(v * 100).round()}%';
Widget line(String label, double v, String right) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border),
),
),
SizedBox(
width: 130,
child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
]),
);
final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : '';
// 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수)
final fileLabel = job.counting
? tr('파일 세는 중…')
: job.pruning
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
: job.finished
? tr('끝')
: job.currentFile.isEmpty
? tr('시작하는 중…')
: job.currentFile.split('/').last;
final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0);
return Column(children: [
Tooltip(
message: job.currentFile,
child: line(fileLabel, fileValue,
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
),
line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress,
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'),
]);
},
);
}
/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름
String pruneLabel(String v) => switch (v) {
'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'),
'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'),
_ => tr('빈 폴더 그대로'),
};
/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete)
class PruneChoice extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
final bool allowNone;
const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false});
@override
Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [
for (final v in [if (allowNone) '', 'keep', 'all'])
ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)),
]);
}