JJ_MKVMaker/lib/ui/vault/vault_views.dart

320 lines
14 KiB
Dart

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<bool> 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<String> 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<String>(
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<String>(
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<String>(
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<String?> askVaultName(BuildContext context, String title, {String text = ''}) async {
final name = await showDialog<String>(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<VaultNameDialog> createState() => _VaultNameDialogState();
}
class _VaultNameDialogState extends State<VaultNameDialog> {
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<String?> pickVaultTarget(BuildContext context, List<String> folders, {String? except, required String from}) {
final all = ['', ...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<String?> askVaultNewPassword(BuildContext context) 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('바꾸기'))),
],
),
),
);
final pw = ok == true ? a.text : null;
Future<void>.delayed(const Duration(seconds: 1), () {
a.dispose();
b.dispose();
});
return pw;
}