360 lines
19 KiB
Dart
360 lines
19 KiB
Dart
/// 실시간 동기화 (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';
|
||
|
||
/// 원본 문제 안내 (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 Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||
decoration: BoxDecoration(
|
||
color: JjColors.accent.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'),
|
||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||
),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'),
|
||
style: const TextStyle(fontSize: 12)),
|
||
const SizedBox(height: 6),
|
||
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 Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||
decoration: BoxDecoration(
|
||
color: JjColors.alert.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'),
|
||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)),
|
||
Text(
|
||
e.empty
|
||
? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. '
|
||
'일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length])
|
||
: tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'),
|
||
style: const TextStyle(fontSize: 12),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}',
|
||
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||
const SizedBox(height: 6),
|
||
Wrap(spacing: 8, runSpacing: 4, children: [
|
||
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),
|
||
]),
|
||
),
|
||
// 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),
|
||
),
|
||
]),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|