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'; import '../confirm_dialog.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(); // 공용 확인 창: 첫 초점 [취소] (Enter 로 바로 바뀌지 않음) · 두 칸이 같아야 [바꾸기] 켜짐 final ok = await confirmDialog( context, title: () => tr('보관함 비밀번호 바꾸기'), body: (ctx, set) => 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(() {})), ]), confirmLabel: () => tr('바꾸기'), cancelFirst: true, canConfirm: () => a.text.isNotEmpty && a.text == b.text, ); final pw = ok ? a.text : null; Future.delayed(const Duration(seconds: 1), () { a.dispose(); b.dispose(); }); return pw; }