diff --git a/lib/ui/vault/vault_actions.dart b/lib/ui/vault/vault_actions.dart new file mode 100644 index 0000000..9077bf1 --- /dev/null +++ b/lib/ui/vault/vault_actions.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/app_controller.dart'; +import '../../app/master_lock.dart'; +import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/reader_sources.dart'; +import '../../core/secret_gate.dart'; +import '../../core/vault_crypto.dart'; +import '../../core/vault_store.dart'; +import '../../core/vault_view.dart'; +import '../../core/vfs.dart' show vDisplay; +import '../../l10n/tr.dart'; +import '../confirm.dart' show confirmAction; +import '../player_page.dart'; +import '../reader_page.dart' show openReader; +import '../theme.dart'; +import 'vault_views.dart'; +import 'vault_session.dart'; + +/// 약속 (보관함 나누기): 보관함 안 파일 하나에 하는 일 - 보기 · 꺼내기 · 지우기 · 이름 변경 · 깨진 항목 알림. +/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) · 항목 [e] +/// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림) +/// - 오류: 던지지 않고 알림. 내용 파일이 빠지거나 바뀐 항목 ([VaultAuthException]) 은 [vaultBrokenEntry] 창으로 + +/// 그림은 메모리에서 바로 (파일로 풀지 않음) +const vaultImageMax = 256 * 1024 * 1024; + +/// 보기: 그림은 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21), 동영상 · 음악은 [session] 폴더에 잠깐 풀고 본 뒤 지운다 +Future vaultViewEntry(BuildContext context, AppController c, VaultUiState s, VaultViewSession session, VaultEntry e) async { + final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); + if (c.settings.imageExts.contains(ext) && e.size <= vaultImageMax) { + try { + s.setBusy(true); + final bytes = await s.vault!.readBytes(e, maxBytes: vaultImageMax); + if (context.mounted) s.setBusy(false); + if (!context.mounted) return; + await openReader(context, c, VaultImageSource(e.name, bytes)); + } catch (err) { + if (context.mounted) s.setBusy(false); + if (err is VaultAuthException) { + if (context.mounted) return vaultBrokenEntry(context, s, e, err); + return; + } + s.snack(trf('열 수 없습니다: {0}', [err])); + } + return; + } + if (!isVideoFile(e.name) && !isAudioFile(e.name)) { + s.snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); + return; + } + try { + // 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다 + final d = await session.dir(); + final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); + s.setBusy(true); + await s.vault!.export(e, out); + if (context.mounted) s.setBusy(false); + if (!context.mounted) return; + { + final create = c.services.createMediaPlayer; + if (create != null) { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); + } + } + // 본 뒤 풀어 둔 것은 지운다 + await File(out).delete().catchError((_) => File(out)); + } catch (err) { + if (context.mounted) s.setBusy(false); + if (err is VaultAuthException) { + if (context.mounted) return vaultBrokenEntry(context, s, e, err); + return; + } + s.snack(trf('열 수 없습니다: {0}', [err])); + } +} + +/// 내용 파일이 없거나 바뀐 항목 (관리자 지시 10/21, 28 지적): 이유 · [자세히] (원문) · [목록에서 빼기] (확인) +Future vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e, Object err) async { + if (!context.mounted) return; + var detail = false; + final drop = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + icon: Icon(Icons.report_gmailerrorred_outlined, color: JjColors.danger), + title: Text(trf('"{0}" 을 꺼낼 수 없습니다', [e.name])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)')), + TextButton(onPressed: () => set(() => detail = !detail), child: Text(detail ? tr('자세히 닫기') : tr('자세히'))), + if (detail) + SelectableText('${e.blob}.jjv · $err', style: TextStyle(fontSize: 12, color: JjColors.textDim, fontFamily: 'Consolas')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('목록에서 빼기'))), + FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), + ], + ), + ), + ); + if (drop != true || !context.mounted) return; + final ok = await confirmAction(context, + title: tr('목록에서 빼기'), + body: trf('"{0}" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)', [e.name]), + ok: tr('빼기'), + danger: false); + if (!ok || s.vault == null) return; + await s.vault!.remove(e); + if (context.mounted) s.changed(); +} + +/// 꺼내기: [pickFolder] 로 고른 곳에 (이름이 겹치면 " (2)"). 쓰는 동안의 평문 임시 파일은 꺼내기 기록에 남긴다 +Future vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, Future Function() pickFolder) async { + final dir = await pickFolder(); + if (dir == null || !context.mounted) return; + var out = p.join(dir, e.name); + for (var i = 2; await File(out).exists(); i++) { + out = p.join(dir, '${p.basenameWithoutExtension(e.name)} ($i)${p.extension(e.name)}'); + } + s.setBusy(true); + try { + final j = VaultExportJournal((await vaultViewRootOf()).path); + await s.vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); + s.snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); + } catch (err) { + if (context.mounted) s.setBusy(false); + if (err is VaultAuthException) { + if (context.mounted) return vaultBrokenEntry(context, s, e, err); + return; + } + s.snack(trf('꺼내지 못했습니다: {0}', [err])); + } finally { + if (context.mounted) s.setBusy(false); + } +} + +/// 보관함에서 지우기 (확인 창 - 되돌릴 수 없음) +Future vaultRemoveEntry(BuildContext context, VaultUiState s, VaultEntry e) async { + final ok = await confirmAction(context, + title: tr('보관함에서 지우기'), + body: trf('"{0}" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', [e.name]), + ok: tr('지우기'), + danger: false); + if (!ok) return; + await s.vault!.remove(e); + if (context.mounted) s.changed(); +} + +/// 이름 변경 (같은 이름 · 쓸 수 없는 이름은 알림) +Future vaultRenameEntry(BuildContext context, VaultUiState s, VaultEntry e) async { + final name = await askVaultName(context, tr('이름 변경'), text: e.name); + if (name == null || name == e.name) return; + try { + await s.vault!.rename(e, name); + } on StateError { + s.snack(tr('같은 이름이 이미 있습니다')); + } on ArgumentError { + s.snack(tr('쓸 수 없는 이름입니다')); + } + if (context.mounted) s.changed(); +} + +/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) +class VaultImageSource extends ReaderSource { + final String name; + final Uint8List bytes; + VaultImageSource(this.name, this.bytes); + @override + String get title => name; + @override + int get length => 1; + @override + String pageName(int i) => name; + @override + Future load(int i, {required int maxWidth}) async => + ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); +} + +/// 약속 (보관함 나누기, 2단계): 보관함 안 폴더 일 - 새 폴더 · 옮기기 (파일 · 폴더) · 이름 변경 · 지우기 · 폴더째 꺼내기. +/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 지금 폴더 [VaultUiState.cwd] · 진행 막대 · 알림) +/// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림) +/// - 오류: 같은 이름 · 쓸 수 없는 이름 · 꺼내지 못함은 알림으로 (던지지 않음) + +/// 지금 폴더 안 새 폴더 +Future vaultMakeFolder(BuildContext context, VaultUiState s) async { + final name = await askVaultName(context, tr('새 폴더')); + if (name == null) return; + if (!Vault.validName(name)) return s.snack(tr('쓸 수 없는 이름입니다')); + final f = vaultJoin(s.cwd, name); + if (s.vault!.folders.contains(f)) return s.snack(tr('같은 이름이 이미 있습니다')); + try { + await s.vault!.mkdir(f); + } on StateError { + s.snack(tr('같은 이름이 이미 있습니다')); + } + if (context.mounted) s.changed(); +} + +/// 파일 하나를 다른 폴더로 (같은 이름이 있으면 보관함이 붙인 새 이름을 알림) +Future vaultMoveEntry(BuildContext context, VaultUiState s, VaultEntry e) async { + final to = await pickVaultTarget(context, s.vault!.folders, from: e.folder); + if (to == null || to == e.folder) return; + final moved = await s.vault!.moveEntry(e, to); + if (moved.name != e.name) s.snack(trf('같은 이름이 있어 "{0}" 로 옮겼습니다', [moved.name])); + if (context.mounted) s.changed(); +} + +/// 폴더째 옮기기 (자기 안으로는 고를 수 없음) +Future vaultMoveFolder(BuildContext context, VaultUiState s, String f) async { + final to = await pickVaultTarget(context, s.vault!.folders, except: f, from: vaultParentOf(f)); + if (to == null || to == vaultParentOf(f)) return; + final name = f.split('/').last; + try { + await s.vault!.moveFolder(f, vaultJoin(to, name)); + } on StateError { + s.snack(tr('옮길 곳에 같은 이름이 이미 있습니다')); + } + if (context.mounted) s.changed(); +} + +/// 폴더 이름 변경 +Future vaultRenameFolder(BuildContext context, VaultUiState s, String f) async { + final name = await askVaultName(context, tr('이름 변경'), text: f.split('/').last); + if (name == null || name == f.split('/').last) return; + if (!Vault.validName(name)) return s.snack(tr('쓸 수 없는 이름입니다')); + final parent = vaultParentOf(f); + try { + await s.vault!.moveFolder(f, vaultJoin(parent, name)); + } on StateError { + s.snack(tr('같은 이름이 이미 있습니다')); + } + if (context.mounted) s.changed(); +} + +/// 폴더와 안의 파일을 보관함에서 지우기 (확인 창 - 되돌릴 수 없음) +Future vaultRemoveFolder(BuildContext context, VaultUiState s, String f) async { + final n = s.vault!.filesUnder(f).length; + final ok = await confirmAction(context, + title: tr('보관함에서 지우기'), + body: trf('폴더 "{0}" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', + [f.split('/').last, n]), + ok: tr('지우기'), + danger: false); + if (!ok) return; + await s.vault!.removeFolder(f); + if (context.mounted) s.changed(); +} + +/// 폴더째 꺼내기 - [pickFolder] 로 고른 곳에 같은 구조로 (이름이 겹치면 " (2)"). 깨진 파일은 세어 알림 (나머지는 꺼냄) +Future vaultExportFolder(BuildContext context, VaultUiState s, String f, Future Function() pickFolder) async { + final dir = await pickFolder(); + if (dir == null || !context.mounted) return; + final name = f.split('/').last; + var root = p.join(dir, name); + for (var i = 2; await FileSystemEntity.type(root) != FileSystemEntityType.notFound; i++) { + root = p.join(dir, '$name ($i)'); + } + s.setBusy(true); + var done = 0, failed = 0; + try { + String local(String folder) => p.joinAll([root, ...folder.substring(f.length).split('/').where((x) => x.isNotEmpty)]); + for (final sub in s.vault!.folders.where((x) => x == f || x.startsWith('$f/'))) { + await Directory(local(sub)).create(recursive: true); + } + final files = s.vault!.filesUnder(f); + final journal = VaultExportJournal((await vaultViewRootOf()).path); + for (var i = 0; i < files.length; i++) { + final e = files[i]; + if (s.vault == null) break; + if (context.mounted) s.setProgress('${i + 1} / ${files.length} · ${e.name}'); + try { + await s.vault!.export(e, p.join(local(e.folder), e.name), onTemp: journal.add, onTempDone: journal.remove); + done++; + } catch (_) { + failed++; + } + } + s.snack(failed == 0 + ? trf('꺼냈습니다: {0}', [vDisplay(root)]) + : trf('{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}', [done, failed, vDisplay(root)])); + } catch (err) { + s.snack(trf('꺼내지 못했습니다: {0}', [err])); + } finally { + if (context.mounted) s.setBusyProgress(false, null); + } +} + +/// 약속 (보관함 나누기): 열린 보관함의 열쇠 일 - 비밀번호 바꾸기 · [마스터로도 열기] 켜고 끄기. +/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) +/// - 출력: 없음. 오류: [vaultChangePassword] 는 보관함이 쓰지 못한 오류를 그대로 던진다 (진행 막대는 끔) + +/// 보관함 비밀번호 바꾸기 (새 비밀번호 두 번 → PBKDF2 [iterations] 번으로 머리 다시 씀) +Future vaultChangePassword(BuildContext context, VaultUiState s, {required int iterations}) async { + final pw = await askVaultNewPassword(context); + if (pw != null && context.mounted) { + s.setBusy(true); + try { + await s.vault!.changePassword(pw, iterations: iterations); + s.snack(tr('보관함 비밀번호를 바꿨습니다')); + } finally { + if (context.mounted) s.setBusy(false); + } + } +} + +/// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬). 켜면 보관함 열쇠 한 벌을 안전 저장소에, 끄면 지운다 +Future vaultToggleMasterCopy(BuildContext context, AppController c, VaultUiState s, bool on) async { + final lock = MasterLock.instance; + if (lock == null) return; + if (on) { + if (!lock.hasMaster) { + s.snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요')); + return; + } + final ok = await confirmAction(context, + title: tr('마스터로도 열기'), + body: tr('보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. ' + '보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. ' + '마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.'), + ok: tr('켜기'), + danger: false); + if (!ok || !await SecretGate.pass(force: true)) return; + await lock.secrets.write(MasterLock.vaultCopyKey, base64.encode(s.vault!.keyForMasterCopy)); + } else { + await lock.secrets.delete(MasterLock.vaultCopyKey); + } + await c.updateSettings((x) => x.vaultMasterCopy = on); + if (context.mounted) s.changed(); +} diff --git a/lib/ui/vault/vault_session.dart b/lib/ui/vault/vault_session.dart new file mode 100644 index 0000000..69829cd --- /dev/null +++ b/lib/ui/vault/vault_session.dart @@ -0,0 +1,290 @@ +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 dir() async { + final d = _session ??= Directory(p.join((await vaultViewRootOf()).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}) 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) 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 vaultAddFlow(BuildContext context, VaultUiState s, List paths, + {required Future Function() thumbTemp, + 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); + 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); +} diff --git a/lib/ui/vault/vault_views.dart b/lib/ui/vault/vault_views.dart new file mode 100644 index 0000000..71ccdca --- /dev/null +++ b/lib/ui/vault/vault_views.dart @@ -0,0 +1,319 @@ + +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart' show formatSize; +import '../../core/vault_store.dart'; +import '../../core/vfs.dart' show vDisplay; +import '../../l10n/tr.dart'; +import '../tail_text.dart'; +import '../theme.dart'; +import 'vault_session.dart'; + +/// 약속 (보관함 나누기): 잠긴 보관함 화면의 두 칸 - 새 보관함 만들기 [VaultCreateForm] · 열기 [VaultUnlockForm]. +/// 글자 칸 ([TextEditingController]) 과 값은 화면이 갖고, 이 모듈은 그리기만 한다 (비밀번호를 읽거나 남기지 않음). +/// 오류: 없음 (넘겨받은 [error] 글을 빨갛게 보여 줌) + +/// 작은 회색 (또는 [color]) 안내 한 줄 +Widget vaultNote(String t, {Color? color}) => + Padding(padding: const EdgeInsets.only(top: 6), child: Text(t, style: TextStyle(fontSize: 12.5, color: color ?? JjColors.textDim))); + +/// 새 보관함 만들기: 비밀번호 두 번 · "잊으면 되살릴 수 없음" 체크가 있어야 [만들기] 가 켜진다 +class VaultCreateForm extends StatelessWidget { + const VaultCreateForm({ + super.key, + required this.dirPath, + required this.pw, + required this.pw2, + required this.understood, + required this.busy, + required this.error, + required this.onChanged, + required this.onUnderstood, + required this.onCreate, + }); + + final String dirPath; + final TextEditingController pw, pw2; + final bool understood, busy; + final String? error; + + /// 글자를 바꿀 때 (화면을 다시 그림) + final VoidCallback onChanged; + final ValueChanged onUnderstood; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [ + Text(tr('새 보관함 만들기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + vaultNote(trf('자리: {0}', [vDisplay(dirPath)])), + vaultNote(tr('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다 (내용 · 이름은 보이지 않습니다)')), + vaultNote(tr('이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)')), + vaultNote(tr('비밀번호를 잊으면 되살릴 수 없습니다 (뒷문 없음)'), color: JjColors.danger), + const SizedBox(height: 12), + TextField( + controller: pw, obscureText: true, decoration: InputDecoration(labelText: tr('보관함 비밀번호')), onChanged: (_) => onChanged()), + TextField(controller: pw2, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => onChanged()), + if (pw2.text.isNotEmpty && pw.text != pw2.text) vaultNote(tr('두 번 넣은 비밀번호가 다릅니다'), color: JjColors.danger), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: understood, + onChanged: (v) => onUnderstood(v ?? false), + title: Text(tr('비밀번호를 잊으면 되살릴 수 없음을 압니다')), + ), + if (error != null) vaultNote(error!, color: JjColors.danger), + const SizedBox(height: 8), + FilledButton( + onPressed: !busy && understood && pw.text.isNotEmpty && pw.text == pw2.text ? onCreate : null, + child: Text(tr('만들기')), + ), + ]); +} + +/// 보관함 열기: 비밀번호 칸 · [열기] · (켰으면) [마스터 비밀번호로 열기]. [waiting] 이면 (여러 번 틀림) 모두 꺼진 채 +class VaultUnlockForm extends StatelessWidget { + const VaultUnlockForm({ + super.key, + required this.dirPath, + required this.pw, + required this.waiting, + required this.error, + required this.masterCopy, + required this.onUnlock, + required this.onUnlockWithMaster, + }); + + final String dirPath; + final TextEditingController pw; + final bool waiting, masterCopy; + final String? error; + final VoidCallback onUnlock, onUnlockWithMaster; + + @override + Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [ + Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), + const SizedBox(height: 8), + TextField( + controller: pw, + obscureText: true, + autofocus: true, + enabled: !waiting, + decoration: InputDecoration(labelText: tr('보관함 비밀번호')), + onSubmitted: (_) => onUnlock(), + ), + if (error != null) vaultNote(error!, color: JjColors.danger), + const SizedBox(height: 12), + FilledButton(onPressed: waiting ? null : onUnlock, child: Text(tr('열기'))), + if (masterCopy) TextButton(onPressed: waiting ? null : onUnlockWithMaster, child: Text(tr('마스터 비밀번호로 열기'))), + vaultNote(trf('자리: {0}', [vDisplay(dirPath)])), + ]); +} + +/// 약속 (보관함 나누기): 열린 보관함의 목록 - 지금 폴더 [cwd] 의 폴더 (이름 순) 와 파일 ([sort]: 'added' 넣은 날 새것 먼저 · 'name' 이름 순). +/// - 입력: 열린 보관함 [vault] (그 안에 [cwd] 가 있어야 함 - 화면이 맞춰 넘김) · [busy] 면 누르기 · 메뉴가 꺼짐 +/// - 출력: 누른 것은 콜백으로 - [onCwd] 폴더 열기 · 위로, [onSort] 정렬, [onFolder] 폴더 메뉴 (export · rename · move · remove), +/// [onEntry] 파일 메뉴 (view · export · rename · move · remove, 줄을 누르면 view) +/// - 오류: 없음 (그리기만) +class VaultListView extends StatelessWidget { + const VaultListView({ + super.key, + required this.vault, + required this.cwd, + required this.sort, + required this.busy, + required this.onCwd, + required this.onSort, + required this.onFolder, + required this.onEntry, + }); + + final Vault vault; + final String cwd, sort; + final bool busy; + final ValueChanged onCwd, onSort; + final void Function(String folder, String action) onFolder; + final void Function(VaultEntry e, String action) onEntry; + + static String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + + Widget _sortBox() => DropdownButton( + value: sort, + items: [ + DropdownMenuItem(value: 'added', child: Text(tr('넣은 날'))), + DropdownMenuItem(value: 'name', child: Text(tr('이름'))), + ], + onChanged: (v) => onSort(v!), + ); + + @override + Widget build(BuildContext context) { + final v = vault; + final list = v.filesIn(cwd); + final dirs = v.subfolders(cwd)..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + if (sort == 'name') { + list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } else { + list.sort((a, b) => b.added.compareTo(a.added)); + } + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 관리자 지시 (10/21, 28 지적): 폴더 안에서는 경로 줄과 정렬 상자를 다른 줄로, 경로는 좁으면 앞을 "…" 로 (지금 폴더가 늘 보이게) + if (cwd.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row(children: [ + IconButton(tooltip: tr('위 폴더로'), icon: const Icon(Icons.arrow_upward), onPressed: () => onCwd(vaultParentOf(cwd))), + Expanded(child: TailText(vaultFolderLabel(cwd), style: const TextStyle(fontWeight: FontWeight.w600))), + ]), + ), + Padding(padding: const EdgeInsets.only(right: 16), child: Align(alignment: Alignment.centerRight, child: _sortBox())), + ] else + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row(children: [ + const SizedBox(width: 8), + Expanded( + child: Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + _sortBox(), + ]), + ), + if (list.isEmpty && dirs.isEmpty) Expanded(child: Center(child: Text(cwd.isEmpty ? tr('보관함이 비어 있습니다') : tr('이 폴더는 비어 있습니다')))), + if (list.isNotEmpty || dirs.isNotEmpty) + Expanded( + child: ListView.builder( + itemCount: dirs.length + list.length, + itemBuilder: (_, i) { + if (i < dirs.length) { + final f = vaultJoin(cwd, dirs[i]); + final n = v.filesUnder(f).length; + return ListTile( + leading: Icon(Icons.folder, color: JjColors.accent), + title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(trf('파일 {0}개', [n])), + onTap: busy ? null : () => onCwd(f), + trailing: PopupMenuButton( + tooltip: tr('더 보기'), + enabled: !busy, + onSelected: (w) => onFolder(f, w), + itemBuilder: (_) => [ + PopupMenuItem(value: 'export', child: Text(tr('꺼내기 (폴더째)'))), + PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))), + PopupMenuItem(value: 'move', child: Text(tr('옮기기'))), + PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))), + ], + ), + ); + } + final e = list[i - dirs.length]; + return ListTile( + leading: Icon(Icons.lock, color: JjColors.accent), + title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'), + onTap: busy ? null : () => onEntry(e, 'view'), + trailing: PopupMenuButton( + tooltip: tr('더 보기'), + enabled: !busy, + onSelected: (w) => onEntry(e, w), + itemBuilder: (_) => [ + PopupMenuItem(value: 'view', child: Text(tr('보기'))), + PopupMenuItem(value: 'export', child: Text(tr('꺼내기'))), + PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))), + PopupMenuItem(value: 'move', child: Text(tr('옮기기'))), + PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))), + ], + ), + ); + }, + ), + ), + ]); + } +} + +/// 약속 (보관함 나누기): 보관함 화면의 묻는 창. 모두 값만 돌려주고 보관함은 건드리지 않는다. 오류: 없음 +/// - [askVaultName]: 이름 하나 (빈 칸 · [취소] 는 null) +/// - [pickVaultTarget]: 옮길 곳 (맨 위 "" + [folders]) - 고른 폴더, 닫으면 null +/// - [askVaultNewPassword]: 새 비밀번호 두 번 (같아야 [바꾸기] 가 켜짐) - 넣은 비밀번호, [취소] 면 null + +/// 이름 묻기 (글자 칸은 창이 닫히는 그림이 끝난 뒤 창과 함께 버린다 - 닫는 중에 버리면 오류) +Future askVaultName(BuildContext context, String title, {String text = ''}) async { + final name = await showDialog(context: context, builder: (_) => VaultNameDialog(title: title, text: text)); + return name == null || name.isEmpty ? null : name; +} + +class VaultNameDialog extends StatefulWidget { + final String title, text; + const VaultNameDialog({super.key, required this.title, required this.text}); + @override + State createState() => _VaultNameDialogState(); +} + +class _VaultNameDialogState extends State { + late final _ctl = TextEditingController(text: widget.text); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.title), + content: TextField(controller: _ctl, autofocus: true, onSubmitted: (v) => Navigator.pop(context, v.trim())), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(context, _ctl.text.trim()), child: Text(tr('확인'))), + ], + ); +} + +/// 옮길 곳 고르기 (맨 위 + [folders]). [except] 폴더와 그 안은 뺀다 (폴더를 자기 안으로 옮기지 않게). [from] 은 꺼진 채 "지금 있는 곳" +Future pickVaultTarget(BuildContext context, List folders, {String? except, required String from}) { + final all = ['', ...folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList(); + return showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(tr('옮길 곳')), + children: [ + for (final f in all) + ListTile( + dense: true, + enabled: f != from, + contentPadding: EdgeInsets.only(left: 16.0 + 16 * (f.isEmpty ? 0 : f.split('/').length), right: 16), + leading: Icon(f.isEmpty ? Icons.lock_outline : Icons.folder_outlined, size: 20), + title: Text(f.isEmpty ? tr('보관함 맨 위') : f.split('/').last), + subtitle: f == from ? Text(tr('지금 있는 곳')) : null, + onTap: () => Navigator.pop(ctx, f), + ), + ], + ), + ); +} + +/// 보관함 비밀번호 바꾸기 창. 글자 칸은 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류) +Future askVaultNewPassword(BuildContext context) async { + final a = TextEditingController(), b = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('보관함 비밀번호 바꾸기')), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})), + TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: a.text.isNotEmpty && a.text == b.text ? () => Navigator.pop(ctx, true) : null, child: Text(tr('바꾸기'))), + ], + ), + ), + ); + final pw = ok == true ? a.text : null; + Future.delayed(const Duration(seconds: 1), () { + a.dispose(); + b.dispose(); + }); + return pw; +} diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index 9c0146c..995e9cd 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -10,22 +10,17 @@ import 'package:path_provider/path_provider.dart'; import '../app/app_controller.dart'; import '../app/master_lock.dart'; -import '../core/file_ops.dart' show formatSize; -import '../core/playlist.dart' show isAudioFile, isVideoFile; -import '../core/reader_sources.dart'; import '../core/secret_gate.dart'; import '../core/vault_crypto.dart'; import '../core/vault_store.dart'; -import '../core/vault_view.dart'; -import '../core/thumb_cache.dart' show removeThumbCaches; -import '../core/vfs.dart' show isDav, vBasename, vDisplay, vIsDir, vList, vLocalCopy, vStat; import '../l10n/tr.dart'; -import 'player_page.dart'; -import 'reader_page.dart' show openReader; -import 'tail_text.dart'; -import 'theme.dart'; +import 'vault/vault_actions.dart'; +import 'vault/vault_session.dart'; +import 'vault/vault_views.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) +/// 약속 (보관함 나누기): 이 화면은 단계 (확인 · 만들기 · 잠김 · 여는 중 · 열림) 와 열고 잠그기만 맡고, +/// 넣기 · 파일 · 폴더 · 열쇠 일은 lib/ui/vault/ 의 모듈을 부른다 (작은 상태 [VaultUiState] 를 넘김) Future openVault(BuildContext context, AppController c, {List add = const [], Future Function(List paths, {required bool permanent})? deleteOriginals}) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals))); @@ -64,16 +59,25 @@ enum _Stage { checking, create, locked, opening, open } class _VaultPageState extends State with WidgetsBindingObserver { _Stage _stage = _Stage.checking; Directory? _dir; - Vault? _vault; String? _error; int _fails = 0; DateTime? _waitUntil, _hiddenAt; final _pw = TextEditingController(), _pw2 = TextEditingController(); bool _understood = false; - bool _busy = false; late List _pendingAdd = [...widget.add]; String _sort = 'added'; + /// 모듈에 넘기는 작은 상태 (열린 보관함 · 진행 막대 · 지금 폴더 · 알림) + late final _s = VaultUiState( + changed: () { + if (mounted) setState(() {}); + }, + snack: _snack, + ); + + /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) + final _session = VaultViewSession(); + AppController get c => widget.c; @override @@ -100,49 +104,22 @@ class _VaultPageState extends State with WidgetsBindingObserver { } else if (state == AppLifecycleState.resumed) { final at = _hiddenAt; _hiddenAt = null; - if (at != null && _vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) { + if (at != null && _s.vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) { setState(_lock); } } } - /// 그림은 메모리에서 바로 (파일로 풀지 않음) - static const _imageMax = 256 * 1024 * 1024; - /// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고) ModalRoute? _route; /// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21) void _lock({bool closeViews = true}) { - _vault = null; + _s.vault = null; if (_stage == _Stage.open) _stage = _Stage.locked; final r = _route; if (closeViews && r != null && mounted && !r.isCurrent) Navigator.of(context).popUntil((x) => x == r); - unawaited(_clearViewDir()); - } - - /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) - Directory? _session; - - Future _viewDir() async { - final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); - await d.create(recursive: true); - return d; - } - - Future _clearViewDir() async { - final d = _session; - _session = null; - if (d == null) return; - // Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 - 몇 번 더 (남으면 다음에 켤 때 지움) - 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)); - } - } + unawaited(_session.clear()); } Future _check() async { @@ -165,7 +142,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { Future _create() async { if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return; setState(() { - _busy = true; + _s.busy = true; _stage = _Stage.opening; }); try { @@ -174,7 +151,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { _pw2.clear(); if (!mounted) return; setState(() { - _vault = v; + _s.vault = v; _stage = _Stage.open; }); await _addPending(); @@ -186,7 +163,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { }); } } finally { - if (mounted) setState(() => _busy = false); + if (mounted) setState(() => _s.busy = false); } } @@ -203,7 +180,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { _fails = 0; if (!mounted) return; setState(() { - _vault = v; + _s.vault = v; _stage = _Stage.open; }); await _addPending(); @@ -251,7 +228,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { final v = await Vault.unlockWithKey(_dir!, base64.decode(key)); if (!mounted) return; setState(() { - _vault = v; + _s.vault = v; _stage = _Stage.open; }); await _addPending(); @@ -265,558 +242,39 @@ class _VaultPageState extends State with WidgetsBindingObserver { } } - /// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도. - /// 맨 위 폴더 이름이 지금 폴더에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게) - Future<_AddPlan> _plan(List targets) async { - final plan = _AddPlan(); - final v = _vault!; - 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; - } - - /// 넣는 동안 위에 보이는 줄 ("3 / 10 · 사진.jpg") - String? _progress; - - /// 탐색기에서 고른 것 넣기 - [원본 지우기 (이동)] 체크 (처음 끔) + /// 탐색기에서 고른 것 넣기 (한 번만 - 넣을 것은 비움) Future _addPending() async { final paths = _pendingAdd; - if (paths.isEmpty || _vault == null) return; + if (paths.isEmpty || _s.vault == null) return; _pendingAdd = []; - setState(() { - _busy = true; - _progress = tr('넣을 것을 세는 중…'); - }); - final plan = await _plan(paths); - if (!mounted) return; - setState(() { - _busy = false; - _progress = null; - }); - 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}', [_cwdLabel()]), 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: widget.deleteOriginals == null ? 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('넣기'))), - ], - ), - ), - ); - if (go != true || !mounted) return; - setState(() => _busy = true); - final bad = {...plan.failed.keys}; - var done = 0; - Directory? tmp; - try { - for (final f in plan.folders) { - await _vault!.mkdir(f); - } - for (var i = 0; i < plan.files.length; i++) { - final x = plan.files[i]; - if (_vault == null) break; // 넣는 동안 잠갔으면 멈춤 - if (mounted) setState(() => _progress = '${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 _vault!.add(src, name: vBasename(x.src), folder: x.folder); - if (st != null) await removeThumbCaches(await c.services.storage.tempDirectory(), x.src, st.size, st.modified); - done++; - } catch (e) { - bad.add(x.top); - if (bad.length == 1 + plan.failed.length) _snack(trf('넣지 못했습니다: {0}', [e])); - } finally { - if (fetched != null) await File(fetched).delete().catchError((_) => File(fetched!)); - } - } - } catch (e) { - bad.addAll(paths); - _snack(trf('넣지 못했습니다: {0}', [e])); - } finally { - // 화면 그림 기억 (Flutter 메모리) 에 남은 원본 그림도 비움 - if (done > 0) { - PaintingBinding.instance.imageCache - ..clear() - ..clearLiveImages(); - } - if (tmp != null) await _removeDir(tmp); - if (mounted) { - setState(() { - _busy = false; - _progress = null; - }); - } - } - if (done > 0 || bad.isEmpty) { - _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 (move && ok.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(ok, permanent: permanent); + await vaultAddFlow(context, _s, paths, thumbTemp: c.services.storage.tempDirectory, deleteOriginals: widget.deleteOriginals); } - /// 앱 전용 임시 폴더 지우기 (백신 · 색인이 잠깐 잡고 있으면 몇 번 더) - static Future _removeDir(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)); - } - } - } + void _onFolder(String f, String action) => switch (action) { + 'export' => vaultExportFolder(context, _s, f, vaultPickFolder), + 'rename' => vaultRenameFolder(context, _s, f), + 'move' => vaultMoveFolder(context, _s, f), + 'remove' => vaultRemoveFolder(context, _s, f), + _ => null, + }; - Future _view(VaultEntry e) async { - final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); - // 그림: 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21) - if (c.settings.imageExts.contains(ext) && e.size <= _imageMax) { - try { - setState(() => _busy = true); - final bytes = await _vault!.readBytes(e, maxBytes: _imageMax); - if (mounted) setState(() => _busy = false); - if (!mounted) return; - await openReader(context, c, _VaultImageSource(e.name, bytes)); - } catch (err) { - if (mounted) setState(() => _busy = false); - if (err is VaultAuthException) return _broken(e, err); - _snack(trf('열 수 없습니다: {0}', [err])); - } - return; - } - if (!isVideoFile(e.name) && !isAudioFile(e.name)) { - _snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); - return; - } - try { - // 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다 - final d = await _viewDir(); - final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); - setState(() => _busy = true); - await _vault!.export(e, out); - if (mounted) setState(() => _busy = false); - if (!mounted) return; - { - final create = c.services.createMediaPlayer; - if (create != null) { - await Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); - } - } - // 본 뒤 풀어 둔 것은 지운다 - await File(out).delete().catchError((_) => File(out)); - } catch (err) { - if (mounted) setState(() => _busy = false); - if (err is VaultAuthException) return _broken(e, err); - _snack(trf('열 수 없습니다: {0}', [err])); - } - } - - /// 내용 파일이 없거나 바뀐 항목 (관리자 지시 10/21, 28 지적): 이유 · [자세히] (원문) · [목록에서 빼기] (확인) - Future _broken(VaultEntry e, Object err) async { - if (!mounted) return; - var detail = false; - final drop = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - icon: Icon(Icons.report_gmailerrorred_outlined, color: JjColors.danger), - title: Text(trf('"{0}" 을 꺼낼 수 없습니다', [e.name])), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tr('보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)')), - TextButton(onPressed: () => set(() => detail = !detail), child: Text(detail ? tr('자세히 닫기') : tr('자세히'))), - if (detail) - SelectableText('${e.blob}.jjv · $err', style: TextStyle(fontSize: 12, color: JjColors.textDim, fontFamily: 'Consolas')), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('목록에서 빼기'))), - FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), - ], - ), - ), - ); - if (drop != true || !mounted) return; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('목록에서 빼기')), - content: Text(trf('"{0}" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)', [e.name])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('빼기'))), - ], - ), - ); - if (ok != true || _vault == null) return; - await _vault!.remove(e); - if (mounted) setState(() {}); - } - - Future _export(VaultEntry e) async { - final dir = await vaultPickFolder(); - if (dir == null || !mounted) return; - var out = p.join(dir, e.name); - for (var i = 2; await File(out).exists(); i++) { - out = p.join(dir, '${p.basenameWithoutExtension(e.name)} ($i)${p.extension(e.name)}'); - } - setState(() => _busy = true); - try { - final j = VaultExportJournal((await vaultViewRootOf()).path); - await _vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); - _snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); - } catch (err) { - if (mounted) setState(() => _busy = false); - if (err is VaultAuthException) return _broken(e, err); - _snack(trf('꺼내지 못했습니다: {0}', [err])); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _remove(VaultEntry e) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('보관함에서 지우기')), - content: Text(trf('"{0}" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', [e.name])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), - ], - ), - ); - if (ok != true) return; - await _vault!.remove(e); - if (mounted) setState(() {}); - } - - Future _rename(VaultEntry e) async { - final name = await _askName(tr('이름 변경'), text: e.name); - if (name == null || name == e.name) return; - try { - await _vault!.rename(e, name); - } on StateError { - _snack(tr('같은 이름이 이미 있습니다')); - } on ArgumentError { - _snack(tr('쓸 수 없는 이름입니다')); - } - if (mounted) setState(() {}); - } - - /// 지금 보는 보관함 안 폴더 ("" = 맨 위) - String _cwd = ''; - - String _cwdLabel([String? f]) { - final x = f ?? _cwd; - return x.isEmpty ? tr('보관함 맨 위') : '${tr('보관함')} › ${x.split('/').join(' › ')}'; - } - - String _parentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf('/')) : ''; - - /// 이름 묻기 (글자 칸은 창이 닫히는 그림이 끝난 뒤 창과 함께 버린다 - 닫는 중에 버리면 오류) - Future _askName(String title, {String text = ''}) async { - final name = await showDialog(context: context, builder: (_) => _NameDialog(title: title, text: text)); - return name == null || name.isEmpty ? null : name; - } - - /// 2단계: 보관함 안 새 폴더 (지금 폴더 안에) - Future _mkdir() async { - final name = await _askName(tr('새 폴더')); - if (name == null) return; - if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다')); - final f = _cwd.isEmpty ? name : '$_cwd/$name'; - if (_vault!.folders.contains(f)) return _snack(tr('같은 이름이 이미 있습니다')); - try { - await _vault!.mkdir(f); - } on StateError { - _snack(tr('같은 이름이 이미 있습니다')); - } - if (mounted) setState(() {}); - } - - /// 옮길 곳 고르기 (맨 위 + 모든 폴더). [except] 폴더와 그 안은 뺀다 (폴더를 자기 안으로 옮기지 않게) - Future _pickTarget({String? except, required String from}) { - final all = ['', ..._vault!.folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList(); - return showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: Text(tr('옮길 곳')), - children: [ - for (final f in all) - ListTile( - dense: true, - enabled: f != from, - contentPadding: EdgeInsets.only(left: 16.0 + 16 * (f.isEmpty ? 0 : f.split('/').length), right: 16), - leading: Icon(f.isEmpty ? Icons.lock_outline : Icons.folder_outlined, size: 20), - title: Text(f.isEmpty ? tr('보관함 맨 위') : f.split('/').last), - subtitle: f == from ? Text(tr('지금 있는 곳')) : null, - onTap: () => Navigator.pop(ctx, f), - ), - ], - ), - ); - } - - Future _moveEntry(VaultEntry e) async { - final to = await _pickTarget(from: e.folder); - if (to == null || to == e.folder) return; - final moved = await _vault!.moveEntry(e, to); - if (moved.name != e.name) _snack(trf('같은 이름이 있어 "{0}" 로 옮겼습니다', [moved.name])); - if (mounted) setState(() {}); - } - - Future _moveFolder(String f) async { - final to = await _pickTarget(except: f, from: _parentOf(f)); - if (to == null || to == _parentOf(f)) return; - final name = f.split('/').last; - try { - await _vault!.moveFolder(f, to.isEmpty ? name : '$to/$name'); - } on StateError { - _snack(tr('옮길 곳에 같은 이름이 이미 있습니다')); - } - if (mounted) setState(() {}); - } - - Future _renameFolder(String f) async { - final name = await _askName(tr('이름 변경'), text: f.split('/').last); - if (name == null || name == f.split('/').last) return; - if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다')); - final parent = _parentOf(f); - try { - await _vault!.moveFolder(f, parent.isEmpty ? name : '$parent/$name'); - } on StateError { - _snack(tr('같은 이름이 이미 있습니다')); - } - if (mounted) setState(() {}); - } - - Future _removeFolder(String f) async { - final n = _vault!.filesUnder(f).length; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('보관함에서 지우기')), - content: Text(trf('폴더 "{0}" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', - [f.split('/').last, n])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), - ], - ), - ); - if (ok != true) return; - await _vault!.removeFolder(f); - if (mounted) setState(() {}); - } - - /// 폴더째 꺼내기 - 고른 곳에 같은 구조로 (이름이 겹치면 " (2)") - Future _exportFolder(String f) async { - final dir = await vaultPickFolder(); - if (dir == null || !mounted) return; - final name = f.split('/').last; - var root = p.join(dir, name); - for (var i = 2; await FileSystemEntity.type(root) != FileSystemEntityType.notFound; i++) { - root = p.join(dir, '$name ($i)'); - } - setState(() => _busy = true); - var done = 0, failed = 0; - try { - String local(String folder) => p.joinAll([root, ...folder.substring(f.length).split('/').where((x) => x.isNotEmpty)]); - for (final sub in _vault!.folders.where((x) => x == f || x.startsWith('$f/'))) { - await Directory(local(sub)).create(recursive: true); - } - final files = _vault!.filesUnder(f); - final journal = VaultExportJournal((await vaultViewRootOf()).path); - for (var i = 0; i < files.length; i++) { - final e = files[i]; - if (_vault == null) break; - if (mounted) setState(() => _progress = '${i + 1} / ${files.length} · ${e.name}'); - try { - await _vault!.export(e, p.join(local(e.folder), e.name), onTemp: journal.add, onTempDone: journal.remove); - done++; - } catch (_) { - failed++; - } - } - _snack(failed == 0 - ? trf('꺼냈습니다: {0}', [vDisplay(root)]) - : trf('{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}', [done, failed, vDisplay(root)])); - } catch (err) { - _snack(trf('꺼내지 못했습니다: {0}', [err])); - } finally { - if (mounted) { - setState(() { - _busy = false; - _progress = null; - }); - } - } - } - - Future _changePassword() async { - final a = TextEditingController(), b = TextEditingController(); - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - title: Text(tr('보관함 비밀번호 바꾸기')), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})), - TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: a.text.isNotEmpty && a.text == b.text ? () => Navigator.pop(ctx, true) : null, child: Text(tr('바꾸기'))), - ], - ), - ), - ); - if (ok == true && mounted) { - setState(() => _busy = true); - try { - await _vault!.changePassword(a.text, iterations: vaultIterationsForTest); - _snack(tr('보관함 비밀번호를 바꿨습니다')); - } finally { - if (mounted) setState(() => _busy = false); - } - } - // 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류) - Future.delayed(const Duration(seconds: 1), () { - a.dispose(); - b.dispose(); - }); - } - - /// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬) - Future _toggleMasterCopy(bool on) async { - final lock = MasterLock.instance; - if (lock == null) return; - if (on) { - if (!lock.hasMaster) { - _snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요')); - return; - } - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('마스터로도 열기')), - content: Text(tr('보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. ' - '보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. ' - '마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('켜기'))), - ], - ), - ); - if (ok != true || !await SecretGate.pass(force: true)) return; - await lock.secrets.write(MasterLock.vaultCopyKey, base64.encode(_vault!.keyForMasterCopy)); - } else { - await lock.secrets.delete(MasterLock.vaultCopyKey); - } - await c.updateSettings((x) => x.vaultMasterCopy = on); - if (mounted) setState(() {}); - } + void _onEntry(VaultEntry e, String action) => switch (action) { + 'view' => vaultViewEntry(context, c, _s, _session, e), + 'export' => vaultExportEntry(context, _s, e, vaultPickFolder), + 'rename' => vaultRenameEntry(context, _s, e), + 'move' => vaultMoveEntry(context, _s, e), + 'remove' => vaultRemoveEntry(context, _s, e), + _ => null, + }; @override Widget build(BuildContext context) { _route ??= ModalRoute.of(context); // 2단계: 보관함 안 폴더에서 ← 는 위 폴더로 return PopScope( - canPop: _stage != _Stage.open || _cwd.isEmpty, + canPop: _stage != _Stage.open || _s.cwd.isEmpty, onPopInvokedWithResult: (did, _) { - if (!did && _cwd.isNotEmpty) setState(() => _cwd = _parentOf(_cwd)); + if (!did && _s.cwd.isNotEmpty) setState(() => _s.cwd = vaultParentOf(_s.cwd)); }, child: _scaffold(), ); @@ -831,9 +289,9 @@ class _VaultPageState extends State with WidgetsBindingObserver { PopupMenuButton( tooltip: tr('더 보기'), onSelected: (w) => switch (w) { - 'mkdir' => _mkdir(), - 'pw' => _changePassword(), - 'master' => _toggleMasterCopy(!c.settings.vaultMasterCopy), + 'mkdir' => vaultMakeFolder(context, _s), + 'pw' => vaultChangePassword(context, _s, iterations: vaultIterationsForTest), + 'master' => vaultToggleMasterCopy(context, c, _s, !c.settings.vaultMasterCopy), _ => null, }, itemBuilder: (_) => [ @@ -848,8 +306,26 @@ class _VaultPageState extends State with WidgetsBindingObserver { body: Stack(children: [ switch (_stage) { _Stage.checking => const Center(child: CircularProgressIndicator()), - _Stage.create => _createForm(), - _Stage.locked => _unlockForm(), + _Stage.create => VaultCreateForm( + dirPath: _dir?.path ?? '', + pw: _pw, + pw2: _pw2, + understood: _understood, + busy: _s.busy, + error: _error, + onChanged: () => setState(() {}), + onUnderstood: (v) => setState(() => _understood = v), + onCreate: _create, + ), + _Stage.locked => VaultUnlockForm( + dirPath: _dir?.path ?? '', + pw: _pw, + waiting: _waitUntil != null && DateTime.now().isBefore(_waitUntil!), + error: _error, + masterCopy: c.settings.vaultMasterCopy, + onUnlock: _unlock, + onUnlockWithMaster: _unlockWithMaster, + ), _Stage.opening => Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), @@ -859,19 +335,19 @@ class _VaultPageState extends State with WidgetsBindingObserver { ), _Stage.open => _list(), }, - if (_busy && _stage == _Stage.open) + if (_s.busy && _stage == _Stage.open) Positioned( left: 0, right: 0, top: 0, child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const LinearProgressIndicator(), - if (_progress != null) + if (_s.progress != null) ColoredBox( color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text(_progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), + child: Text(_s.progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), ), ), ]), @@ -879,217 +355,21 @@ class _VaultPageState extends State with WidgetsBindingObserver { ]), ); - Widget _note(String t, {Color? color}) => - Padding(padding: const EdgeInsets.only(top: 6), child: Text(t, style: TextStyle(fontSize: 12.5, color: color ?? JjColors.textDim))); - - Widget _createForm() => ListView(padding: const EdgeInsets.all(16), children: [ - Text(tr('새 보관함 만들기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - _note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])), - _note(tr('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다 (내용 · 이름은 보이지 않습니다)')), - _note(tr('이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)')), - _note(tr('비밀번호를 잊으면 되살릴 수 없습니다 (뒷문 없음)'), color: JjColors.danger), - const SizedBox(height: 12), - TextField( - controller: _pw, obscureText: true, decoration: InputDecoration(labelText: tr('보관함 비밀번호')), onChanged: (_) => setState(() {})), - TextField(controller: _pw2, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => setState(() {})), - if (_pw2.text.isNotEmpty && _pw.text != _pw2.text) _note(tr('두 번 넣은 비밀번호가 다릅니다'), color: JjColors.danger), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: _understood, - onChanged: (v) => setState(() => _understood = v ?? false), - title: Text(tr('비밀번호를 잊으면 되살릴 수 없음을 압니다')), - ), - if (_error != null) _note(_error!, color: JjColors.danger), - const SizedBox(height: 8), - FilledButton( - onPressed: !_busy && _understood && _pw.text.isNotEmpty && _pw.text == _pw2.text ? _create : null, - child: Text(tr('만들기')), - ), - ]); - - Widget _unlockForm() { - final waiting = _waitUntil != null && DateTime.now().isBefore(_waitUntil!); - return ListView(padding: const EdgeInsets.all(16), children: [ - Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), - const SizedBox(height: 8), - TextField( - controller: _pw, - obscureText: true, - autofocus: true, - enabled: !waiting, - decoration: InputDecoration(labelText: tr('보관함 비밀번호')), - onSubmitted: (_) => _unlock(), - ), - if (_error != null) _note(_error!, color: JjColors.danger), - const SizedBox(height: 12), - FilledButton(onPressed: waiting ? null : _unlock, child: Text(tr('열기'))), - if (c.settings.vaultMasterCopy) - TextButton(onPressed: waiting ? null : _unlockWithMaster, child: Text(tr('마스터 비밀번호로 열기'))), - _note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])), - ]); - } - - String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; - - Widget _sortBox() => DropdownButton( - value: _sort, - items: [ - DropdownMenuItem(value: 'added', child: Text(tr('넣은 날'))), - DropdownMenuItem(value: 'name', child: Text(tr('이름'))), - ], - onChanged: (v) => setState(() => _sort = v!), - ); - Widget _list() { - final v = _vault!; + final v = _s.vault!; // 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로 - while (_cwd.isNotEmpty && !v.folders.contains(_cwd)) { - _cwd = _parentOf(_cwd); + while (_s.cwd.isNotEmpty && !v.folders.contains(_s.cwd)) { + _s.cwd = vaultParentOf(_s.cwd); } - final list = v.filesIn(_cwd); - final dirs = v.subfolders(_cwd)..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); - if (_sort == 'name') { - list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - } else { - list.sort((a, b) => b.added.compareTo(a.added)); - } - return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 관리자 지시 (10/21, 28 지적): 폴더 안에서는 경로 줄과 정렬 상자를 다른 줄로, 경로는 좁으면 앞을 "…" 로 (지금 폴더가 늘 보이게) - if (_cwd.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), - child: Row(children: [ - IconButton( - tooltip: tr('위 폴더로'), icon: const Icon(Icons.arrow_upward), onPressed: () => setState(() => _cwd = _parentOf(_cwd))), - Expanded(child: TailText(_cwdLabel(), style: const TextStyle(fontWeight: FontWeight.w600))), - ]), - ), - Padding(padding: const EdgeInsets.only(right: 16), child: Align(alignment: Alignment.centerRight, child: _sortBox())), - ] else - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), - child: Row(children: [ - const SizedBox(width: 8), - Expanded( - child: Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - _sortBox(), - ]), - ), - if (list.isEmpty && dirs.isEmpty) - Expanded(child: Center(child: Text(_cwd.isEmpty ? tr('보관함이 비어 있습니다') : tr('이 폴더는 비어 있습니다')))), - if (list.isNotEmpty || dirs.isNotEmpty) - Expanded( - child: ListView.builder( - itemCount: dirs.length + list.length, - itemBuilder: (_, i) { - if (i < dirs.length) { - final f = _cwd.isEmpty ? dirs[i] : '$_cwd/${dirs[i]}'; - final n = v.filesUnder(f).length; - return ListTile( - leading: Icon(Icons.folder, color: JjColors.accent), - title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(trf('파일 {0}개', [n])), - onTap: _busy ? null : () => setState(() => _cwd = f), - trailing: PopupMenuButton( - tooltip: tr('더 보기'), - enabled: !_busy, - onSelected: (w) => switch (w) { - 'export' => _exportFolder(f), - 'rename' => _renameFolder(f), - 'move' => _moveFolder(f), - 'remove' => _removeFolder(f), - _ => null, - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'export', child: Text(tr('꺼내기 (폴더째)'))), - PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))), - PopupMenuItem(value: 'move', child: Text(tr('옮기기'))), - PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))), - ], - ), - ); - } - final e = list[i - dirs.length]; - return ListTile( - leading: Icon(Icons.lock, color: JjColors.accent), - title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'), - onTap: _busy ? null : () => _view(e), - trailing: PopupMenuButton( - tooltip: tr('더 보기'), - enabled: !_busy, - onSelected: (w) => switch (w) { - 'view' => _view(e), - 'export' => _export(e), - 'rename' => _rename(e), - 'move' => _moveEntry(e), - 'remove' => _remove(e), - _ => null, - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'view', child: Text(tr('보기'))), - PopupMenuItem(value: 'export', child: Text(tr('꺼내기'))), - PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))), - PopupMenuItem(value: 'move', child: Text(tr('옮기기'))), - PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))), - ], - ), - ); - }, - ), - ), - ]); + return VaultListView( + vault: v, + cwd: _s.cwd, + sort: _sort, + busy: _s.busy, + onCwd: (f) => setState(() => _s.cwd = f), + onSort: (x) => setState(() => _sort = x), + onFolder: _onFolder, + onEntry: _onEntry, + ); } } - -class _NameDialog extends StatefulWidget { - final String title, text; - const _NameDialog({required this.title, required this.text}); - @override - State<_NameDialog> createState() => _NameDialogState(); -} - -class _NameDialogState extends State<_NameDialog> { - late final _ctl = TextEditingController(text: widget.text); - - @override - void dispose() { - _ctl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AlertDialog( - title: Text(widget.title), - content: TextField(controller: _ctl, autofocus: true, onSubmitted: (v) => Navigator.pop(context, v.trim())), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(context, _ctl.text.trim()), child: Text(tr('확인'))), - ], - ); -} - -/// 2단계: 넣을 것 (폴더를 펼친 것). [failed] 읽지 못한 고른 것 → 오류 (그 원본은 지우지 않음) -class _AddPlan { - final files = <({String src, String folder, String top})>[]; - final folders = []; - final failed = {}; -} - -/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) -class _VaultImageSource extends ReaderSource { - final String name; - final Uint8List bytes; - _VaultImageSource(this.name, this.bytes); - @override - String get title => name; - @override - int get length => 1; - @override - String pageName(int i) => name; - @override - Future load(int i, {required int maxWidth}) async => - ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); -} diff --git a/test/vault_modules_test.dart b/test/vault_modules_test.dart new file mode 100644 index 0000000..3e72980 --- /dev/null +++ b/test/vault_modules_test.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/vault_store.dart'; +import 'package:jj_mkvmaker/core/vault_view.dart'; +import 'package:jj_mkvmaker/ui/vault/vault_session.dart'; +import 'package:jj_mkvmaker/ui/vault/vault_views.dart'; +import 'package:path/path.dart' as p; + +/// 보관함 나누기 (vault_page 1,095줄 → lib/ui/vault/) 의 모듈 약속 시험. 실제 시험 폴더 · 실제 보관함 (PBKDF2 1000번) 만 쓴다. +/// 화면 전체 흐름 (만들기 · 열기 · 넣기 · 보기 · 꺼내기 · 폴더 · 열쇠) 은 vault_page_test 가 본다 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tmp; + final wasView = vaultViewRootOf; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_vaultmod_'); + vaultViewRootOf = () async => Directory(p.join(tmp.path, 'view')); + }); + tearDown(() async { + vaultViewRootOf = wasView; + await removeTempDirRetry(tmp); + }); + + test('vault_session (상태): 폴더 글 · 위 폴더 · 잇기 · 상태를 바꾸면 다시 그림', () { + expect(vaultParentOf('가/나/다'), '가/나'); + expect(vaultParentOf('가'), ''); + expect(vaultJoin('', '가'), '가'); + expect(vaultJoin('가', '나'), '가/나'); + expect(vaultFolderLabel('가/나'), contains('가 › 나')); + var n = 0; + final said = []; + final s = VaultUiState(changed: () => n++, snack: said.add); + s.setBusy(true); + s.setProgress('1 / 2'); + expect((s.busy, s.progress, n), (true, '1 / 2', 2)); + s.setBusy(false); + expect(s.progress, '1 / 2', reason: 'setBusy 는 진행 글을 그대로 둔다'); + s.setBusyProgress(false, null); + expect((s.busy, s.progress, n), (false, null, 4)); + }); + + test('vault_session (보기 폴더): 한 번 연 동안 같은 폴더 · 지우면 잊고 다음은 새 폴더 · 없는 폴더 지우기는 조용히', () async { + final v = VaultViewSession(); + await v.clear(); // 만든 적 없음 + final a = await v.dir(), b = await v.dir(); + expect(a.path, b.path); + expect(p.basename(a.path), startsWith(vaultViewPrefix)); + expect(p.dirname(a.path), p.join(tmp.path, 'view')); + File(p.join(a.path, 'x.bin')).writeAsBytesSync([1, 2, 3]); + await v.clear(); + expect(a.existsSync(), isFalse); + final c = await v.dir(); + expect(c.path == a.path, isFalse); + await removeTempDirRetry(Directory(p.join(tmp.path, '없음'))); + }); + + test('vault_session (넣기): 펼치기 (폴더 구조 · 겹치는 이름 " (2)" · 읽지 못한 것은 빼고) → 넣기 → 잠그면 멈춤', () async { + final vault = await Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000); + final src = Directory(p.join(tmp.path, 'src'))..createSync(); + final one = File(p.join(src.path, 'a.txt'))..writeAsStringSync('하나'); + final dir = Directory(p.join(src.path, '사진'))..createSync(); + File(p.join(dir.path, 'b.txt')).writeAsStringSync('둘'); + Directory(p.join(dir.path, '안')).createSync(); + File(p.join(dir.path, '안', 'c.txt')).writeAsStringSync('셋'); + Directory(p.join(dir.path, '빈')).createSync(); + await vault.mkdir('사진'); // 맨 위에 같은 이름이 이미 있음 + final missing = p.join(src.path, '없는 폴더'); + Directory(missing).createSync(); + Directory(missing).deleteSync(); + + final plan = await planVaultAdd(vault, '', [one.path, dir.path, missing]); + expect(plan.folders.first, '사진 (2)'); + expect(plan.folders, unorderedEquals(['사진 (2)', '사진 (2)/안', '사진 (2)/빈']), reason: '안의 순서는 폴더 목록 순서'); + // 없는 것은 폴더가 아니라 파일로 보여 펼치기에서는 실패하지 않는다 - 넣기에서 실패해 bad 에 남는다 + expect([for (final x in plan.files) '${x.folder}|${p.basename(x.src)}'], + unorderedEquals(['|a.txt', '사진 (2)|b.txt', '사진 (2)/안|c.txt', '|없는 폴더'])); + final said = []; + final s = VaultUiState(changed: () {}, snack: said.add)..vault = vault; + final thumbs = Directory(p.join(tmp.path, 'thumbs'))..createSync(); + final r = await putVaultAdd(s, [one.path, dir.path, missing], plan, thumbTemp: () async => thumbs.path); + expect(r.done, 3); + expect(r.bad, {missing}, reason: '넣지 못한 것만 (그 원본은 화면이 지우지 않음)'); + expect(said, hasLength(1), reason: '처음 실패만 알림'); + expect((s.busy, s.progress), (false, null)); + expect([for (final e in vault.filesUnder('사진 (2)')) e.path], unorderedEquals(['사진 (2)/b.txt', '사진 (2)/안/c.txt'])); + expect(vault.folders, contains('사진 (2)/빈')); + expect(one.existsSync(), isTrue, reason: '넣기는 원본을 지우지 않는다 (지우기는 화면이 deleteOriginals 로)'); + + // 보관함이 잠겨 있으면 (vault = null) 하나도 넣지 않고 멈춘다 + final p3 = VaultAddPlan()..files.add((src: one.path, folder: '', top: one.path)); + final s3 = VaultUiState(changed: () {}, snack: said.add)..vault = null; + final r3 = await putVaultAdd(s3, [one.path], p3, thumbTemp: () async => thumbs.path); + expect(r3.done, 0); + }); + + testWidgets('vault_views (묻는 창): 옮길 곳은 자기와 그 안을 빼고 · 지금 있는 곳은 꺼짐 · 이름 창의 빈 칸은 null', (t) async { + String? picked = 'x', named = 'x'; + late BuildContext ctx; + await t.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(); + }))); + final f = pickVaultTarget(ctx, ['가', '가/나', '다'], except: '가', from: ''); + await t.pumpAndSettle(); + expect(find.text('나'), findsNothing); + expect(find.text('가'), findsNothing); + expect(t.widget(find.widgetWithText(ListTile, '보관함 맨 위')).enabled, isFalse); + await t.tap(find.text('다')); + await t.pumpAndSettle(); + picked = await f; + expect(picked, '다'); + final n = askVaultName(ctx, '새 폴더'); + await t.pumpAndSettle(); + await t.tap(find.text('확인')); + await t.pumpAndSettle(); + named = await n; + expect(named, isNull); + }); + + testWidgets('vault_views (목록): 폴더 먼저 (이름 순) · 파일은 정렬 따라 · 바쁘면 누르기 꺼짐 · 누른 것은 콜백으로', (t) async { + final vault = await t.runAsync(() async { + final v = await Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000); + final src = Directory(p.join(tmp.path, 'src'))..createSync(); + for (final n in ['A.txt', 'b.txt']) { + File(p.join(src.path, n)).writeAsStringSync(n); + await v.add(p.join(src.path, n), name: n, folder: ''); + await Future.delayed(const Duration(milliseconds: 20)); + } + await v.mkdir('나'); + await v.mkdir('가'); + return v; + }); + final got = []; + Future show(String sort, {bool busy = false}) => t.pumpWidget(MaterialApp( + home: Scaffold( + body: VaultListView( + vault: vault!, + cwd: '', + sort: sort, + busy: busy, + onCwd: (f) => got.add('cwd $f'), + onSort: (x) => got.add('sort $x'), + onFolder: (f, w) => got.add('$w $f'), + onEntry: (e, w) => got.add('$w ${e.name}'))))); + List titles() => [ + for (final w in t.widgetList(find.byType(ListTile))) ((w.title as Text).data ?? ''), + ]; + await show('name'); + expect(titles(), ['가', '나', 'A.txt', 'b.txt']); + await show('added'); + expect(titles(), ['가', '나', 'b.txt', 'A.txt'], reason: '넣은 날 새것 먼저 (b 를 나중에 넣음)'); + await t.tap(find.text('가')); + await t.tap(find.text('b.txt')); + expect(got, ['cwd 가', 'view b.txt']); + await show('added', busy: true); + await t.tap(find.text('나')); + expect(got, hasLength(2), reason: '바쁜 동안은 누르기가 꺼짐'); + }); +}