JJ_MKVMaker/lib/ui/vault/vault_session.dart

297 lines
14 KiB
Dart
Raw 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.

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]: 처음 부를 때 [vaultViewRootOf] 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다
/// - [clear]: 그 폴더를 지우고 잊는다 (다음 [dir] 은 새 폴더). 만든 적 없으면 아무것도 하지 않는다
/// - 오류: [dir] 은 폴더를 만들지 못하면 그 오류를 던진다. [clear] 는 던지지 않는다 (남으면 다음에 켤 때 지움)
class VaultViewSession {
Directory? _session;
Future<Directory> dir() async {
final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
await d.create(recursive: true);
return d;
}
Future<void> clear() async {
final d = _session;
_session = null;
if (d == null) return;
await removeTempDirRetry(d);
}
}
/// 약속: 앱 전용 임시 폴더 [d] 를 지운다. Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 -
/// 0.2초 간격으로 10번까지 (남으면 다음에 켤 때 지움). 오류는 던지지 않는다
Future<void> 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<void>.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 = <String>[];
final failed = <String, Object>{};
}
/// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도.
/// 맨 위 폴더 이름이 [cwd] 에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게)
/// - 입력: 열린 보관함 [v] · 넣을 곳 [cwd] · 고른 것 [targets]
/// - 출력: [VaultAddPlan]. 읽지 못한 것은 [VaultAddPlan.failed] 에 (그 폴더는 반만 넣지 않게 파일도 뺌). 던지지 않음
Future<VaultAddPlan> planVaultAdd(Vault v, String cwd, List<String> targets) async {
final plan = VaultAddPlan();
final taken = <String>{};
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<void> 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<String> 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<bool>(
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<bool>(
groupValue: permanent,
onChanged: (v) => set(() => permanent = v ?? false),
child: Column(mainAxisSize: MainAxisSize.min, children: [
RadioListTile<bool>(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))),
RadioListTile<bool>(
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<String> bad})> putVaultAdd(VaultUiState s, List<String> paths, VaultAddPlan plan,
{required Future<String> Function() thumbTemp}) async {
s.setBusy(true);
final bad = <String>{...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 vaultViewRootOf()).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<void> vaultAddFlow(BuildContext context, VaultUiState s, List<String> paths,
{required Future<String> Function() thumbTemp,
Future<void> Function(List<String> 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);
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);
}