1087 lines
45 KiB
Dart
1087 lines
45 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:path/path.dart' as p;
|
||
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 'theme.dart';
|
||
|
||
/// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21))
|
||
Future<void> openVault(BuildContext context, AppController c,
|
||
{List<String> add = const [], Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) =>
|
||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals)));
|
||
|
||
/// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈
|
||
Future<Directory> Function() vaultDirOf = () async {
|
||
if (Platform.isAndroid) {
|
||
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot') ?? '/storage/emulated/0';
|
||
return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault'));
|
||
}
|
||
return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault'));
|
||
};
|
||
|
||
/// 꺼낼 폴더 고르기 (시험에서 바꿈)
|
||
Future<String?> Function() vaultPickFolder = () => FilePicker.getDirectoryPath();
|
||
|
||
/// 시험: PBKDF2 반복 수 (새 보관함 · 비밀번호 바꾸기)
|
||
int vaultIterationsForTest = VaultCrypto.defaultIterations;
|
||
|
||
class VaultPage extends StatefulWidget {
|
||
final AppController c;
|
||
|
||
/// 열면서 넣을 파일 (탐색기 [보관함에 넣기])
|
||
final List<String> add;
|
||
|
||
/// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통)
|
||
final Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals;
|
||
const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals});
|
||
|
||
@override
|
||
State<VaultPage> createState() => _VaultPageState();
|
||
}
|
||
|
||
enum _Stage { checking, create, locked, opening, open }
|
||
|
||
class _VaultPageState extends State<VaultPage> 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<String> _pendingAdd = [...widget.add];
|
||
String _sort = 'added';
|
||
|
||
AppController get c => widget.c;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
unawaited(_check());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_lock(closeViews: false);
|
||
_pw.dispose();
|
||
_pw2.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 앱이 뒤로 간 뒤 정한 시간이 지나 돌아오면 다시 잠금
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
|
||
_hiddenAt ??= DateTime.now();
|
||
} else if (state == AppLifecycleState.resumed) {
|
||
final at = _hiddenAt;
|
||
_hiddenAt = null;
|
||
if (at != null && _vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) {
|
||
setState(_lock);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 그림은 메모리에서 바로 (파일로 풀지 않음)
|
||
static const _imageMax = 256 * 1024 * 1024;
|
||
|
||
/// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고)
|
||
ModalRoute<dynamic>? _route;
|
||
|
||
/// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21)
|
||
void _lock({bool closeViews = true}) {
|
||
_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<Directory> _viewDir() async {
|
||
final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
|
||
await d.create(recursive: true);
|
||
return d;
|
||
}
|
||
|
||
Future<void> _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<void>.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _check() async {
|
||
try {
|
||
final d = await vaultDirOf();
|
||
final has = await Vault.exists(d);
|
||
if (mounted) {
|
||
setState(() {
|
||
_dir = d;
|
||
_stage = has ? _Stage.locked : _Stage.create;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() => _error = trf('보관함 폴더를 열 수 없습니다: {0}', [e]));
|
||
}
|
||
}
|
||
|
||
void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t)));
|
||
|
||
Future<void> _create() async {
|
||
if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return;
|
||
setState(() {
|
||
_busy = true;
|
||
_stage = _Stage.opening;
|
||
});
|
||
try {
|
||
final v = await Vault.create(_dir!, _pw.text, iterations: vaultIterationsForTest);
|
||
_pw.clear();
|
||
_pw2.clear();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_vault = v;
|
||
_stage = _Stage.open;
|
||
});
|
||
await _addPending();
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_stage = _Stage.create;
|
||
_error = trf('보관함을 만들지 못했습니다: {0}', [e]);
|
||
});
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busy = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _unlock() async {
|
||
final wait = _waitUntil;
|
||
if (wait != null && DateTime.now().isBefore(wait)) return;
|
||
setState(() {
|
||
_stage = _Stage.opening;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final v = await Vault.unlock(_dir!, _pw.text);
|
||
_pw.clear();
|
||
_fails = 0;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_vault = v;
|
||
_stage = _Stage.open;
|
||
});
|
||
await _addPending();
|
||
} on VaultAuthException {
|
||
_fails++;
|
||
if (_fails >= 5) {
|
||
_waitUntil = DateTime.now().add(const Duration(seconds: 30));
|
||
_fails = 0;
|
||
Timer(const Duration(seconds: 30), () => mounted ? setState(() => _waitUntil = null) : null);
|
||
}
|
||
if (mounted) {
|
||
setState(() {
|
||
_stage = _Stage.locked;
|
||
_error = _waitUntil != null ? tr('비밀번호가 여러 번 틀려 30초 뒤에 다시 넣을 수 있습니다') : tr('비밀번호가 틀렸습니다');
|
||
});
|
||
}
|
||
} on VaultFormatException {
|
||
if (mounted) {
|
||
setState(() {
|
||
_stage = _Stage.locked;
|
||
_error = tr('이 판이 모르는 보관함 형식입니다 - 앱을 새 판으로 올려 주세요 (보관함은 그대로 둡니다)');
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_stage = _Stage.locked;
|
||
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// [마스터로도 열기]: 마스터 비밀번호 확인 뒤 안전 저장소의 열쇠로
|
||
Future<void> _unlockWithMaster() async {
|
||
final lock = MasterLock.instance;
|
||
if (lock == null || !await SecretGate.pass(force: true)) return;
|
||
final key = (await lock.secrets.readAll())[MasterLock.vaultCopyKey];
|
||
if (key == null) {
|
||
_snack(tr('마스터로 여는 열쇠가 없습니다 (마스터를 초기화했으면 없어집니다) - 보관함 비밀번호로 여세요'));
|
||
return;
|
||
}
|
||
setState(() => _stage = _Stage.opening);
|
||
try {
|
||
final v = await Vault.unlockWithKey(_dir!, base64.decode(key));
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_vault = v;
|
||
_stage = _Stage.open;
|
||
});
|
||
await _addPending();
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_stage = _Stage.locked;
|
||
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도.
|
||
/// 맨 위 폴더 이름이 지금 폴더에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게)
|
||
Future<_AddPlan> _plan(List<String> targets) async {
|
||
final plan = _AddPlan();
|
||
final v = _vault!;
|
||
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;
|
||
}
|
||
|
||
/// 넣는 동안 위에 보이는 줄 ("3 / 10 · 사진.jpg")
|
||
String? _progress;
|
||
|
||
/// 탐색기에서 고른 것 넣기 - [원본 지우기 (이동)] 체크 (처음 끔)
|
||
Future<void> _addPending() async {
|
||
final paths = _pendingAdd;
|
||
if (paths.isEmpty || _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<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}', [_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<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('넣기'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (go != true || !mounted) return;
|
||
setState(() => _busy = true);
|
||
final bad = <String>{...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);
|
||
}
|
||
|
||
/// 앱 전용 임시 폴더 지우기 (백신 · 색인이 잠깐 잡고 있으면 몇 번 더)
|
||
static Future<void> _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<void>.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _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<void>(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<void> _broken(VaultEntry e, Object err) async {
|
||
if (!mounted) return;
|
||
var detail = false;
|
||
final drop = await showDialog<bool>(
|
||
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<bool>(
|
||
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<void> _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<void> _remove(VaultEntry e) async {
|
||
final ok = await showDialog<bool>(
|
||
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<void> _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<String?> _askName(String title, {String text = ''}) async {
|
||
final name = await showDialog<String>(context: context, builder: (_) => _NameDialog(title: title, text: text));
|
||
return name == null || name.isEmpty ? null : name;
|
||
}
|
||
|
||
/// 2단계: 보관함 안 새 폴더 (지금 폴더 안에)
|
||
Future<void> _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<String?> _pickTarget({String? except, required String from}) {
|
||
final all = ['', ..._vault!.folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList();
|
||
return showDialog<String>(
|
||
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<void> _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<void> _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<void> _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<void> _removeFolder(String f) async {
|
||
final n = _vault!.filesUnder(f).length;
|
||
final ok = await showDialog<bool>(
|
||
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<void> _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<void> _changePassword() async {
|
||
final a = TextEditingController(), b = TextEditingController();
|
||
final ok = await showDialog<bool>(
|
||
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<void>.delayed(const Duration(seconds: 1), () {
|
||
a.dispose();
|
||
b.dispose();
|
||
});
|
||
}
|
||
|
||
/// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬)
|
||
Future<void> _toggleMasterCopy(bool on) async {
|
||
final lock = MasterLock.instance;
|
||
if (lock == null) return;
|
||
if (on) {
|
||
if (!lock.hasMaster) {
|
||
_snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요'));
|
||
return;
|
||
}
|
||
final ok = await showDialog<bool>(
|
||
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(() {});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
_route ??= ModalRoute.of(context);
|
||
// 2단계: 보관함 안 폴더에서 ← 는 위 폴더로
|
||
return PopScope(
|
||
canPop: _stage != _Stage.open || _cwd.isEmpty,
|
||
onPopInvokedWithResult: (did, _) {
|
||
if (!did && _cwd.isNotEmpty) setState(() => _cwd = _parentOf(_cwd));
|
||
},
|
||
child: _scaffold(),
|
||
);
|
||
}
|
||
|
||
Widget _scaffold() => Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(tr('암호 보관함')),
|
||
actions: [
|
||
if (_stage == _Stage.open) ...[
|
||
IconButton(tooltip: tr('잠그기'), icon: const Icon(Icons.lock_outline), onPressed: () => setState(_lock)),
|
||
PopupMenuButton<String>(
|
||
tooltip: tr('더 보기'),
|
||
onSelected: (w) => switch (w) {
|
||
'mkdir' => _mkdir(),
|
||
'pw' => _changePassword(),
|
||
'master' => _toggleMasterCopy(!c.settings.vaultMasterCopy),
|
||
_ => null,
|
||
},
|
||
itemBuilder: (_) => [
|
||
PopupMenuItem(value: 'mkdir', child: Text(tr('새 폴더'))),
|
||
PopupMenuItem(value: 'pw', child: Text(tr('보관함 비밀번호 바꾸기'))),
|
||
CheckedPopupMenuItem(value: 'master', checked: c.settings.vaultMasterCopy, child: Text(tr('마스터로도 열기'))),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
body: Stack(children: [
|
||
switch (_stage) {
|
||
_Stage.checking => const Center(child: CircularProgressIndicator()),
|
||
_Stage.create => _createForm(),
|
||
_Stage.locked => _unlockForm(),
|
||
_Stage.opening => Center(
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
const CircularProgressIndicator(),
|
||
const SizedBox(height: 12),
|
||
Text(tr('보관함을 여는 중… (비밀번호 확인)')),
|
||
]),
|
||
),
|
||
_Stage.open => _list(),
|
||
},
|
||
if (_busy && _stage == _Stage.open)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||
const LinearProgressIndicator(),
|
||
if (_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)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
|
||
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 _list() {
|
||
final v = _vault!;
|
||
// 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로
|
||
while (_cwd.isNotEmpty && !v.folders.contains(_cwd)) {
|
||
_cwd = _parentOf(_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: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||
child: Row(children: [
|
||
if (_cwd.isNotEmpty)
|
||
IconButton(
|
||
tooltip: tr('위 폴더로'), icon: const Icon(Icons.arrow_upward), onPressed: () => setState(() => _cwd = _parentOf(_cwd))),
|
||
if (_cwd.isEmpty) const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _cwd.isEmpty
|
||
? Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim))
|
||
: Text(_cwdLabel(), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
),
|
||
DropdownButton<String>(
|
||
value: _sort,
|
||
items: [
|
||
DropdownMenuItem(value: 'added', child: Text(tr('넣은 날'))),
|
||
DropdownMenuItem(value: 'name', child: Text(tr('이름'))),
|
||
],
|
||
onChanged: (v) => setState(() => _sort = v!),
|
||
),
|
||
]),
|
||
),
|
||
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<String>(
|
||
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<String>(
|
||
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('보관함에서 지우기'))),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
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 = <String>[];
|
||
final failed = <String, Object>{};
|
||
}
|
||
|
||
/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음)
|
||
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<ReaderImage> load(int i, {required int maxWidth}) async =>
|
||
ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes)));
|
||
}
|