JJ_MKVMaker/lib/ui/monitor/live_pair_card.dart

326 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/// 실시간 동기화 (lsync) 카드 위젯 (모니터링 > lsync 탭, 다른 화면도 그대로 넣어 씀).
/// 약속:
/// - LivePairCard(c, live, pair, onFinalize, onShowCopyList): 쌍의 상태 · 일정 · 맞출 것 · 지우기 확인 · 단추.
/// live 가 null 이면 (실시간 동기화 꺼짐) 세기 · 맞추기 단추를 끈다. 설정 저장은 replaceLiveSyncPair 로.
/// 탭 이동 같은 화면 일은 하지 않고 onFinalize · onShowCopyList 로 부르는 쪽에 맡긴다.
/// - LiveSourceProblemPanel(live, pair, error): 원본 문제 안내와 [다시 시도] · [지울 목록 보고 결정].
/// 화면 상태를 읽지 않는다. 설정은 읽기만 (liveSyncPairs 쓰기는 live_sync_flow 를 거침).
library;
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/copy_center.dart';
import '../../app/live_sync.dart';
import '../../app/settings.dart';
import '../../core/secret_gate.dart';
import '../../core/file_ops.dart';
import '../../core/sync_tools.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
import '../path_label.dart';
import '../schedule_editor.dart';
import '../sync_preview_view.dart' show unbreakableOption;
import '../theme.dart';
import 'live_sync_flow.dart';
import 'transfer_widgets.dart';
import '../notice_band.dart';
/// 원본 문제 안내 (68 · 70): 원본을 읽지 못함 · 원본이 빔 · 마스터 비밀번호 기다림
class LiveSourceProblemPanel extends StatelessWidget {
final LiveSync live;
final LiveSyncPair pair;
final SourceUnreadableException error;
const LiveSourceProblemPanel({super.key, required this.live, required this.pair, required this.error});
@override
Widget build(BuildContext context) {
final x = pair;
final e = error;
final k = LiveSync.keyOf(x);
final dels = live.emptyDeletes[k] ?? const <String>[];
final running = live.isRunning(x);
// 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다
if (e.locked) {
return NoticeBand(
icon: Icons.lock_clock_outlined,
color: JjColors.accent,
margin: EdgeInsets.zero,
title: tr('마스터 비밀번호가 필요해 기다리는 중'),
body: tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'),
actions: [
FilledButton.tonalIcon(
onPressed: running
? null
: () async {
if (await SecretGate.pass(force: true)) await live.syncNow(x);
},
icon: const Icon(Icons.lock_open, size: 18),
label: Text(tr('마스터 비밀번호 넣기')),
),
],
);
}
return NoticeBand(
icon: e.empty ? Icons.folder_off_outlined : Icons.error_outline,
margin: EdgeInsets.zero,
title: e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'),
body: [
tr('대상 파일은 지우지 않았습니다.'),
e.empty
? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. '
'일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length])
: tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'),
'${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}',
].join('\n'),
actions: [
FilledButton.tonalIcon(
onPressed: running ? null : () => live.syncNow(x),
icon: const Icon(Icons.refresh, size: 18),
label: Text(tr('다시 시도')),
),
if (e.empty && dels.isNotEmpty)
OutlinedButton(
onPressed: running ? null : () => confirmEmptySourceSync(context, live, x),
child: Text(tr('지울 목록 보고 결정')),
),
],
);
}
}
/// 실시간 동기화 한 쌍의 카드
class LivePairCard extends StatelessWidget {
final AppController c;
final LiveSync? live;
final LiveSyncPair pair;
/// [최종 정리] 를 눌렀을 때
final VoidCallback onFinalize;
/// [복사 · rsync 로 이동] 으로 옮긴 뒤 (모니터링에서는 복사 · rsync 탭으로)
final VoidCallback onShowCopyList;
const LivePairCard(
{super.key, required this.c, required this.live, required this.pair, required this.onFinalize, required this.onShowCopyList});
@override
Widget build(BuildContext context) {
final live = this.live;
final x = pair;
final k = LiveSync.keyOf(x);
final pend = live?.pending[k];
final st = live?.status[k];
final active = LiveSync.activeNow(x);
final running = live?.isRunning(x) ?? false;
final held = live?.isPaused(x) ?? false;
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
final narrow = MediaQuery.sizeOf(context).width < 600;
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
final chip = Tooltip(
message: chipText,
child: Chip(
visualDensity: VisualDensity.compact,
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
),
);
return Card(
color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(
running
? Icons.sync
: !x.enabled
? Icons.sync_disabled
: held
? Icons.pause_circle_outline
: active
? Icons.sync
: Icons.bedtime_outlined,
color: x.enabled && !held ? (active ? JjColors.accent : JjColors.warning) : JjColors.textDim),
const SizedBox(width: 8),
Expanded(
// 67: 끝 폴더가 보이게 짧게 · 69: 길게 누르면 (오른쪽 클릭) 고치기 · 지우기
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (d) => showLivePairMenu(context, c, x, d.globalPosition),
onSecondaryTapDown: (d) => showLivePairMenu(context, c, x, d.globalPosition),
child: Tooltip(
// 길게 누르기는 메뉴에 (마우스를 올리면 전체 경로)
triggerMode: TooltipTriggerMode.manual,
message: '${vDisplay(x.source)}\n→ ${vDisplay(x.target)}',
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(shortPath(x.source), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${shortPath(x.target)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
),
),
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
if (!narrow) Flexible(child: chip),
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
if (live != null && x.enabled)
IconButton(
tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'),
icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null),
onPressed: () => held ? live.resume(x) : live.pause(x),
),
Switch(value: x.enabled, onChanged: (v) => replaceLiveSyncPair(c, x, x.copyWith(enabled: v))),
]),
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
if (held)
Padding(
padding: const EdgeInsets.only(left: 32, top: 2),
child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
// 42: 지우기를 아직 확인하지 않았고 지울 것이 있다 → 지우지 않고 맞추는 중. 목록을 보고 결정
if (live != null && (live.toDelete[k]?.isNotEmpty ?? false))
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Icon(Icons.warning_amber_rounded, size: 16, color: JjColors.warning),
Text(trf('대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)', [live.toDelete[k]!.length]),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
OutlinedButton(
onPressed: () => confirmLiveDeletes(context, live, x),
child: Text(tr('지울 목록 보고 결정')),
),
]),
),
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
if (live != null && live.noAutoDelete[k] != null)
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Icon(Icons.block, size: 16, color: JjColors.alert),
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)),
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: TextStyle(fontSize: 12, color: JjColors.alert)),
OutlinedButton(
onPressed: () => confirmLiveDeleteNow(context, live, x),
child: Text(tr('지금 지우기 (확인)')),
),
]),
),
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
// 일정: 계속 / 시간 지정 (cron) + 작은 격자
InkWell(
onTap: () async {
final r = await editSchedule(context, x.schedule);
if (r != null) await replaceLiveSyncPair(c, x, x.copyWith(schedule: r));
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(mainAxisSize: MainAxisSize.min, children: [
WeekGridPreview(lines: x.schedule, width: 120),
const SizedBox(width: 8),
// 257: 폰 세로에서 글이 줄을 바꿀 수 있게 (넘쳤다)
Flexible(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : JjColors.warning)),
if (x.schedule.isNotEmpty)
Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)),
]),
),
]),
),
),
if (st != null)
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)),
DiskSpaceInfo(path: x.target, space: c.services.diskSpace),
]),
),
// 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도]
if (live?.problems[k] case final prob?)
Padding(padding: const EdgeInsets.only(left: 32, top: 8), child: LiveSourceProblemPanel(live: live!, pair: x, error: prob))
else
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
Padding(
padding: const EdgeInsets.only(left: 32, top: 8),
child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty
// 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로
? Text(
(live?.onlyTarget[k] ?? 0) > 0
? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]])
: tr('맞출 것 없음 (원본과 대상이 같습니다)'),
style: TextStyle(fontSize: 12, color: JjColors.positive))
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
style: TextStyle(fontSize: 12, color: JjColors.warning),
),
const SizedBox(height: 4),
Container(
constraints: const BoxConstraints(maxHeight: 140),
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.all(8),
child: ListView(shrinkWrap: true, children: [
for (final f in pend.take(100))
Text(f,
style: TextStyle(
fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? JjColors.alert : null)),
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
]),
),
]),
),
const SizedBox(height: 6),
// 257: 폰 세로에서 [고치기] · [지우기] 가 화면 밖으로 밀려 누를 수 없었다 - 줄을 바꾼다 (오른쪽 정렬은 그대로)
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 2, runSpacing: 2, children: [
TextButton.icon(
onPressed: live == null ? null : () => live.refreshPending(x),
icon: const Icon(Icons.refresh, size: 18),
label: Text(tr('다시 세기')),
),
FilledButton.icon(
onPressed: live == null || running ? null : () => live.syncNow(x),
icon: const Icon(Icons.sync, size: 18),
label: Text(tr('지금 맞추기')),
),
TextButton.icon(
onPressed: running ? null : onFinalize,
icon: const Icon(Icons.cleaning_services_outlined, size: 18),
label: Text(tr('최종 정리')),
),
TextButton.icon(
onPressed: () async {
await CopyCenter.of(c).fromLiveSync(x);
if (context.mounted) {
onShowCopyList();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.'))));
}
},
icon: const Icon(Icons.swap_horiz, size: 18),
label: Text(tr('복사 · rsync 로 이동')),
),
IconButton(
tooltip: tr('고치기'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => editLiveSyncPairDialog(context, c, x),
),
IconButton(
tooltip: tr('지우기'),
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => removeLiveSyncPair(context, c, x),
),
]),
]),
),
);
}
}