import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../../core/thumb_cache.dart' show removeThumbCaches; import '../../core/vault_store.dart'; import '../../core/vault_view.dart'; import '../../core/vfs.dart' show isDav, vBasename, vIsDir, vList, vLocalCopy, vStat; import '../../l10n/tr.dart'; import '../theme.dart'; /// 약속 (보관함 나누기): 보관함 화면이 기능 모듈에 넘기는 작은 상태. /// - 입력: [changed] (화면을 다시 그림 - 화면이 살아 있을 때만 그리는 것은 화면이 맡음) · [snack] (아래 알림 한 줄) /// - [vault]: 열린 보관함 (null = 잠김). 모듈은 오래 걸리는 일 중간에 늘 이 값을 다시 읽어, 잠겼으면 멈춘다 /// - [busy] · [progress]: 위 진행 막대와 그 아래 한 줄 ("3 / 10 · 사진.jpg") /// - 오류: 없음 (값만 담는다) class VaultUiState { VaultUiState({required this.changed, required this.snack}); final void Function() changed; final void Function(String text) snack; Vault? vault; bool busy = false; String? progress; /// 지금 보는 보관함 안 폴더 ("" = 맨 위) String cwd = ''; /// [busy] 를 바꾸고 다시 그린다 ([progress] 는 그대로) void setBusy(bool on) { busy = on; changed(); } /// [busy] 와 [progress] 를 함께 바꾸고 다시 그린다 void setBusyProgress(bool on, String? text) { busy = on; progress = text; changed(); } /// 진행 한 줄만 바꾸고 다시 그린다 void setProgress(String? text) { progress = text; changed(); } } /// 보관함 안 폴더 이름을 화면 글로 ("보관함 맨 위" · "보관함 › 가 › 나") String vaultFolderLabel(String folder) => folder.isEmpty ? tr('보관함 맨 위') : '${tr('보관함')} › ${folder.split('/').join(' › ')}'; /// 보관함 안 폴더의 위 폴더 ("가/나" → "가", "가" → "") String vaultParentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf('/')) : ''; /// 보관함 안 폴더 [parent] 안의 [name] 경로 String vaultJoin(String parent, String name) => parent.isEmpty ? name : '$parent/$name'; /// 약속 (보관함 나누기): 보관함을 한 번 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) /// - [dir]: 처음 부를 때 [root] (VaultLocation.viewRoot) 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다 /// - [clear]: 그 폴더를 지우고 잊는다 (다음 [dir] 은 새 폴더). 만든 적 없으면 아무것도 하지 않는다 /// - 오류: [dir] 은 폴더를 만들지 못하면 그 오류를 던진다. [clear] 는 던지지 않는다 (남으면 다음에 켤 때 지움) class VaultViewSession { VaultViewSession(this.root); /// 푼 파일을 둘 앱 전용 폴더 (VaultLocation.viewRoot) final Future Function() root; Directory? _session; Future dir() async { final d = _session ??= Directory(p.join((await root()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); await d.create(recursive: true); return d; } Future clear() async { final d = _session; _session = null; if (d == null) return; await removeTempDirRetry(d); } } /// 약속: 앱 전용 임시 폴더 [d] 를 지운다. Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 - /// 0.2초 간격으로 10번까지 (남으면 다음에 켤 때 지움). 오류는 던지지 않는다 Future removeTempDirRetry(Directory d) async { for (var i = 0; i < 10; i++) { try { if (await d.exists()) await d.delete(recursive: true); return; } catch (_) { await Future.delayed(const Duration(milliseconds: 200)); } } } /// 약속 (보관함 나누기): 보관함에 넣기 - 펼치기 [planVaultAdd] → 확인 창 [askVaultAdd] → 넣기 [putVaultAdd]. /// [vaultAddFlow] 는 셋을 차례로 부르고, 다 넣은 것만 원본을 지운다. 오류는 알림 ([VaultUiState.snack]) 으로만 (던지지 않음) /// 2단계: 넣을 것 (폴더를 펼친 것). [failed] 읽지 못한 고른 것 → 오류 (그 원본은 지우지 않음) class VaultAddPlan { final files = <({String src, String folder, String top})>[]; final folders = []; final failed = {}; } /// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도. /// 맨 위 폴더 이름이 [cwd] 에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게) /// - 입력: 열린 보관함 [v] · 넣을 곳 [cwd] · 고른 것 [targets] /// - 출력: [VaultAddPlan]. 읽지 못한 것은 [VaultAddPlan.failed] 에 (그 폴더는 반만 넣지 않게 파일도 뺌). 던지지 않음 Future planVaultAdd(Vault v, String cwd, List targets) async { final plan = VaultAddPlan(); final taken = {}; for (final t in targets) { try { if (!await vIsDir(t)) { plan.files.add((src: t, folder: cwd, top: t)); continue; } var name = v.freeName(cwd, vBasename(t)); for (var i = 2; taken.contains(name); i++) { name = v.freeName(cwd, '${vBasename(t)} ($i)'); } taken.add(name); final root = cwd.isEmpty ? name : '$cwd/$name'; if (Vault.cleanFolder(root) == null) throw FileSystemException(tr('쓸 수 없는 이름입니다'), t); plan.folders.add(root); Future walk(String dir, String folder) async { for (final x in await vList(dir, strict: true)) { final n = vBasename(x.path); if (!Vault.validName(n)) throw FileSystemException(tr('쓸 수 없는 이름입니다'), x.path); if (x.isDir) { plan.folders.add('$folder/$n'); await walk(x.path, '$folder/$n'); } else { plan.files.add((src: x.path, folder: folder, top: t)); } } } await walk(t, root); } catch (e) { plan.failed[t] = e; } } // 읽지 못한 폴더는 반만 넣지 않는다 plan.files.removeWhere((x) => plan.failed.containsKey(x.top)); return plan; } /// 넣기 확인 창 - [원본 지우기 (이동)] 체크 (처음 끔, [canMove] 가 아니면 꺼진 채). /// 출력: [넣기] 면 (move, permanent), [취소] · 바깥이면 null Future<({bool move, bool permanent})?> askVaultAdd(BuildContext context, List paths, VaultAddPlan plan, {required String cwd, required bool canMove}) async { final remote = paths.any(isDav); var move = false; // 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게) var permanent = false; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(trf('보관함에 넣기 · {0}개', [paths.length])), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final x in paths.take(8)) Text('· ${vBasename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis), if (paths.length > 8) Text(trf('… 외 {0}개', [paths.length - 8])), const SizedBox(height: 6), Text(trf('파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)', [plan.files.length, plan.folders.length]), style: const TextStyle(fontSize: 12.5)), Text(trf('넣을 곳: {0}', [vaultFolderLabel(cwd)]), style: const TextStyle(fontSize: 12.5)), if (remote) Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), for (final f in plan.failed.entries.take(3)) Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]), style: TextStyle(fontSize: 12, color: JjColors.warning), maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 8), CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, value: move, onChanged: !canMove ? null : (v) => set(() => move = v ?? false), title: Text(tr('원본 지우기 (이동)')), subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')), ), if (move) ...[ Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'), style: TextStyle(fontSize: 12, color: JjColors.warning)), RadioGroup( groupValue: permanent, onChanged: (v) => set(() => permanent = v ?? false), child: Column(mainAxisSize: MainAxisSize.min, children: [ RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))), RadioListTile( dense: true, contentPadding: EdgeInsets.zero, value: true, title: Text(tr('영구 삭제')), subtitle: Text(tr('휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다'))), ]), ), ], ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( onPressed: plan.files.isEmpty && plan.folders.isEmpty ? null : () => Navigator.pop(ctx, true), child: Text(tr('넣기'))), ], ), ), ); return go == true ? (move: move, permanent: permanent) : null; } /// 펼친 [plan] 을 [s] 의 보관함에 넣는다 (넣는 동안 잠그면 ([VaultUiState.vault] 가 null) 멈춤). /// - 원격은 앱 전용 폴더에 받아 넣고 바로 지운다. 넣은 원본의 썸네일 캐시 ([thumbTemp] 아래) 도 지운다 /// - 출력: 넣은 수 [done] · 다 넣지 못한 고른 것 [bad] (plan.failed · 잠가서 멈춘 뒤 남은 것 포함). 오류는 [VaultUiState.snack] 으로 (던지지 않음) Future<({int done, Set bad})> putVaultAdd(VaultUiState s, List paths, VaultAddPlan plan, {required Future Function() thumbTemp, required Future Function() viewRoot}) async { s.setBusy(true); final bad = {...plan.failed.keys}; var done = 0; Directory? tmp; try { for (final f in plan.folders) { await s.vault!.mkdir(f); } for (var i = 0; i < plan.files.length; i++) { final x = plan.files[i]; if (s.vault == null) { // 넣는 동안 잠갔으면 멈춤 - 넣지 못한 남은 것은 bad 로 (그 원본은 [원본 지우기 (이동)] 로 지우지 않게) for (final rest in plan.files.skip(i)) { bad.add(rest.top); } break; } s.setProgress('${i + 1} / ${plan.files.length} · ${vBasename(x.src)}'); String? fetched; try { var src = x.src; // 관리자 지시 (10/21, 28 지적): 넣은 원본의 썸네일 캐시 (이름이 경로 · 크기 · 바뀐 때) 를 지우려고 미리 봄 final st = await vStat(x.src); if (isDav(src)) { // 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다 tmp ??= await Directory(p.join((await viewRoot()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}')) .create(recursive: true); src = fetched = await vLocalCopy(x.src, tmp.path); } await s.vault!.add(src, name: vBasename(x.src), folder: x.folder); if (st != null) await removeThumbCaches(await thumbTemp(), x.src, st.size, st.modified); done++; } catch (e) { bad.add(x.top); if (bad.length == 1 + plan.failed.length) s.snack(trf('넣지 못했습니다: {0}', [e])); } finally { if (fetched != null) await File(fetched).delete().catchError((_) => File(fetched!)); } } } catch (e) { bad.addAll(paths); s.snack(trf('넣지 못했습니다: {0}', [e])); } finally { // 화면 그림 기억 (Flutter 메모리) 에 남은 원본 그림도 비움 if (done > 0) { PaintingBinding.instance.imageCache ..clear() ..clearLiveImages(); } if (tmp != null) await removeTempDirRetry(tmp); s.setBusyProgress(false, null); } return (done: done, bad: bad); } /// 탐색기에서 고른 [paths] 넣기 (펼치기 → 확인 창 → 넣기 → 알림 → [deleteOriginals] 로 다 넣은 것만 원본 지우기). /// 보관함이 잠겼거나 [paths] 가 비면 아무것도 하지 않는다 Future vaultAddFlow(BuildContext context, VaultUiState s, List paths, {required Future Function() thumbTemp, required Future Function() viewRoot, Future Function(List paths, {required bool permanent})? deleteOriginals}) async { if (paths.isEmpty || s.vault == null) return; s.setBusyProgress(true, tr('넣을 것을 세는 중…')); final plan = await planVaultAdd(s.vault!, s.cwd, paths); if (!context.mounted) return; s.setBusyProgress(false, null); final pick = await askVaultAdd(context, paths, plan, cwd: s.cwd, canMove: deleteOriginals != null); if (pick == null || !context.mounted) return; final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp, viewRoot: viewRoot); final done = r.done, bad = r.bad; if (done > 0 || bad.isEmpty) { s.snack(bad.isEmpty ? trf('{0}개를 보관함에 넣었습니다', [done]) : trf('{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)', [done, bad.length])); } // 다 넣은 것만 원본을 지운다 (폴더는 안의 것이 모두 들어갔을 때만) final ok = [for (final x in paths) if (!bad.contains(x)) x]; if (pick.move && ok.isNotEmpty && deleteOriginals != null) await deleteOriginals(ok, permanent: pick.permanent); }