302 lines
14 KiB
Dart
302 lines
14 KiB
Dart
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<Directory> Function() root;
|
||
Directory? _session;
|
||
|
||
Future<Directory> dir() async {
|
||
final d = _session ??= Directory(p.join((await root()).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, required Future<Directory> Function() viewRoot}) 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 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<void> vaultAddFlow(BuildContext context, VaultUiState s, List<String> paths,
|
||
{required Future<String> Function() thumbTemp,
|
||
required Future<Directory> Function() viewRoot,
|
||
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, 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);
|
||
}
|