/// 모니터링 공용 작은 부품 (복사 · 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 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)), ]); }