150 lines
6.7 KiB
Dart
150 lines
6.7 KiB
Dart
/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀).
|
|
/// 약속:
|
|
/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음.
|
|
/// - DiskSpaceInfo(path, space?): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체 (space 로 재는 함수를 줄 수 있음 - PlatformServices.diskSpace). 못 재면 "대상 디스크: 알 수 없음".
|
|
/// - 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';
|
|
import '../jj_chip.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;
|
|
|
|
/// 재는 함수 (null 이면 실제 디스크 - [diskSpace])
|
|
final Future<(int, int)?> Function(String path)? space;
|
|
const DiskSpaceInfo({super.key, required this.path, this.space});
|
|
|
|
@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: (space ?? 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'])
|
|
JjChip(label: pruneLabel(v), selected: value == v, onSelected: (_) => onChanged(v)),
|
|
]);
|
|
}
|