/// 실시간 동기화 (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 []; 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), ), ]), ]), ), ); } }