Merge branch 'split_vault' into modular

modular
RyuJongJin 2026-10-22 20:12:37 +09:00
commit fbd9144211
8 changed files with 1750 additions and 1182 deletions

View File

@ -0,0 +1,361 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/ai_local.dart' show AiStore;
import '../../app/app_controller.dart';
import '../../app/copy_center.dart' show diskSpace;
import '../../app/model_backup.dart';
import '../../app/version_snapshot.dart';
import '../../core/app_update.dart';
import '../../l10n/tr.dart';
import '../../services/updater.dart' show InstallPermissionNeeded, Updater;
import '../theme.dart';
/// 약속 (업데이트 나누기): 설치 · 되돌리기가 함께 쓰는 단계.
/// - [downloadRelease]: 진행 창을 띄우고 받는다 → 받은 경로, 실패하면 알림 뒤 null (던지지 않음)
/// - [saveVersionSnapshot]: 지금 버전의 설정을 보관 (실패는 작업 기록에만, 던지지 않음)
/// - [rollbackModelRoots]: 되돌리기 때 옮겨 둘 AI 모델 폴더 · [gbText]: 크기 글
/// - [withProgressDialog]: 끌 수 없는 진행 창에서 일 하나 → 실패하면 그 글, 성공은 null
/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null.
Future<String?> downloadRelease(BuildContext context, Updater up, ReleaseInfo r) async {
final progress = ValueNotifier<double>(0);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
scrollable: true,
title: Text(trf('v{0} 받는 중', [r.version])),
content: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'),
const SizedBox(height: 12),
LinearProgressIndicator(value: v >= 1 ? null : v),
]),
),
),
);
try {
final dir = await up.download(r, (x) => progress.value = x);
if (context.mounted) Navigator.of(context).pop();
return dir;
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('업데이트 실패: {0}', [e]))));
}
return null;
}
}
/// 다른 버전을 설치하기 전에 지금 버전의 설정을 보관 (그 버전으로 돌아오면 되살린다)
Future<void> saveVersionSnapshot(AppController c, String current) async {
final snap = VersionSnapshot.instance;
if (snap == null) return;
try {
await c.updateSettings((x) => x.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게
await snap.save(current);
c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)]));
} catch (e) {
c.note(trf('설정을 보관하지 못했습니다: {0}', [e]));
}
}
/// P0: 되돌리기 때 옮겨 둘 받은 AI 모델 폴더 (그림 · 해상도: 앱 데이터/ai, 자막: 모델 폴더)
Future<Map<String, String>> rollbackModelRoots(AppController c) async => {
if (AiStore.instance != null) 'ai': AiStore.instance!.root,
'models': await c.services.models.directory(),
};
/// 크기를 GB · MB 로
String gbText(int bytes) => bytes >= 1 << 30
? '${(bytes / (1 << 30)).toStringAsFixed(1)}GB'
: '${(bytes / (1 << 20)).toStringAsFixed(0)}MB';
/// 진행 창을 띄우고 [work] 를 한다 (끌 수 없음). 실패하면 그 글
Future<String?> withProgressDialog(
BuildContext context, String title, Future<Object?> Function(void Function(int done, int total) onProgress) work) async {
final progress = ValueNotifier<double?>(null);
final nav = Navigator.of(context);
// ignore: unawaited_futures
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(title),
content: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
LinearProgressIndicator(value: v),
const SizedBox(height: 6),
Text(v == null ? tr('준비하는 중') : '${(v * 100).floor()}%'),
]),
),
),
);
String? error;
try {
await work((done, total) => progress.value = total == 0 ? null : done / total);
} catch (e) {
error = '$e';
}
nav.pop();
return error;
}
/// 약속 (업데이트 나누기): 고른 버전 설치 (새 버전 · 예전 버전 · 같은 버전).
/// - 입력: 화면 [context] · [c] (c.services.updater 가 있어야 함) · 버전 [r] · 지금 버전 [current]
/// - 출력: 없음. 받기 · 검증 → 확인 창 → 지금 설정 보관 → Android 는 설치 화면, PC 는 종료 후 설치 · 다시 시작.
/// Android 에서 낮은 버전이면 [androidRollback] 으로
/// - 오류: 작업 중 · 설치할 수 없는 위치 · 받기 실패 · 설치 화면 열기 실패는 알림 (PC 의 설치 예약 오류는 그대로 던짐)
/// [r] 버전 설치 (새 버전 · 예전 버전 · 같은 버전 모두). 설치 전에 지금 버전의 설정을 보관한다.
Future<void> installRelease(BuildContext context, AppController c, ReleaseInfo r, String current) async {
final up = c.services.updater!;
void snack(String t) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
}
if (c.busy) {
snack(tr('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.'));
return;
}
// 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록
if (r.zipUrl == null || !await up.canInstall()) {
snack(tr('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.'));
await up.openPage(r);
return;
}
final down = compareVersions(r.version, current) < 0;
// Android 는 낮은 버전을 위에 설치할 수 없다 (앱을 지우고 설치해야 함) → 따로 안내
if (down && up.installsInPlace) {
if (context.mounted) await androidRollback(context, c, up, r, current);
return;
}
if (!context.mounted) return;
final dir = await downloadRelease(context, up, r);
if (dir == null || !context.mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설치 준비 완료')),
content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [
r.version,
up.installsInPlace
? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)')
: down
? trf('프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, '
'받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.', [r.version, current])
: tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지금 설치'))),
],
),
);
if (go != true) return;
await saveVersionSnapshot(c, current);
// Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움)
if (up.installsInPlace) {
while (true) {
try {
await up.scheduleInstall(dir);
return;
} on InstallPermissionNeeded {
// 설치 허용 설정 화면이 열렸다: 켜고 돌아와 [설치 계속] → 받은 파일로 다시 (처음부터 다시 받지 않게)
if (!context.mounted) return;
final again = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
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 (again != true) return;
} catch (e) {
snack(trf('설치 화면을 열 수 없습니다: {0}', [e]));
return;
}
}
}
// 다운로드 중이면 기존 종료 확인 절차
final ok = await (c.confirmQuit?.call() ?? Future.value(true));
if (!ok) return;
await up.scheduleInstall(dir);
await c.services.shell.quit();
}
/// 약속 (업데이트 나누기): Android 에서 예전 버전 [r] 로 되돌리기 (앱을 지운 뒤 설치).
/// - 입력: 화면 [context] · [c] · [up] · 고른 버전 [r] · 지금 버전 [current]
/// - 출력: 없음. 남는 것 / 사라지는 것을 보여 주고 두 번 확인한 뒤, APK 와 설정 보관본 (원하면 AI 모델) 을
/// Download/JJ_MKVMaker 에 두고 앱 지우기를 부른다
/// - 오류: 저장 · 모델 복사에 실패하면 알림만 하고 앱은 지우지 않는다 (던지지 않음)
/// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다.
/// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다.
Future<void> androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async {
final snap = VersionSnapshot.instance;
final shared = snap?.sharedDir;
if (shared == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.'))));
return;
}
// P0: 남는 것 / 사라지는 것을 사실대로. 받은 AI 모델은 공용 폴더에 옮겨 둘 수 있다
final roots = await rollbackModelRoots(c);
final modelSize = ModelBackup.sizeOf(roots);
// 공용 폴더는 처음엔 없을 수 있다 - 있는 가장 가까운 상위 폴더로 남은 공간을 잰다
var probe = shared;
while (!Directory(probe).existsSync() && p.dirname(probe) != probe) {
probe = p.dirname(probe);
}
final free = (await diskSpace(probe))?.$1;
final setting = c.settings.rollbackKeepModels;
var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2));
var keepTouched = false;
// 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터)
final targetRestoresModels = compareVersions(r.version, '2026.10.09_001') > 0;
final targetRestoresSettings = compareVersions(r.version, '2026.10.08_001') >= 0;
if (!context.mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) {
final size = gbText(modelSize);
final kept = [
tr('설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관'),
tr('공용 폴더에 받은 파일 (동영상 · 그림 등)'),
if (keep) trf('받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관', [size]),
];
final lost = [
if (modelSize > 0 && !keep) trf('받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})', [size]),
tr('비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)'),
tr('마스터 · 최상 비밀번호'),
tr('앱 안 브라우저의 로그인 · 쿠키'),
tr('작업 기록 · 받다 만 파일'),
];
return AlertDialog(
scrollable: true,
title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf(
'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 "남는 것" 을 '
'Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.',
[r.version])),
const SizedBox(height: 10),
Text(tr('남는 것'), style: const TextStyle(fontWeight: FontWeight.w600)),
for (final x in kept) Text('· $x'),
const SizedBox(height: 8),
Text(tr('사라지는 것'), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)),
for (final x in lost) Text('· $x', style: TextStyle(color: JjColors.warning)),
if (modelSize > 0) ...[
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: keep,
// 고른 것은 [저장하고 계속] 을 누를 때만 설정에 남긴다 ([취소] 하면 그대로)
onChanged: (v) => setInner(() {
keep = v ?? false;
keepTouched = true;
}),
title: Text(trf('받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)', [size])),
subtitle: Text([
if (free != null) trf('남은 공간 {0}', [gbText(free)]),
if (!targetRestoresModels)
trf('v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.', [r.version]),
].join(' · ')),
),
],
if (!targetRestoresSettings)
Text(trf('v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.', [r.version]),
style: TextStyle(color: JjColors.warning)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))),
],
);
},
),
);
if (ok != true || !context.mounted) return;
// 체크를 바꿨으면 이제 설정에 (자동이던 것은 건드리지 않으면 자동 그대로)
if (keepTouched) await c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off');
if (!context.mounted) return;
// 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다)
final sure = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('정말 되돌릴까요?')),
content: Text([
tr('앱을 지우면 다음은 되살릴 수 없습니다:'),
if (modelSize > 0 && !keep) trf('· 받은 AI 모델 (약 {0}, 다시 받아야 함)', [gbText(modelSize)]),
tr('· 비밀번호 · API 키 · 마스터 · 최상 비밀번호'),
tr('· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록'),
].join('\n')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.alert),
onPressed: () => Navigator.pop(ctx, true),
child: Text(tr('되돌리기 계속')),
),
],
),
);
if (sure != true || !context.mounted) return;
final apk = await downloadRelease(context, up, r);
if (apk == null || !context.mounted) return;
final outDir = p.dirname(shared);
final saved = p.join(outDir, r.zipName ?? 'JJ_MKVMaker_v${r.version}.apk');
try {
await Directory(outDir).create(recursive: true);
await File(apk).copy(saved);
await saveVersionSnapshot(c, current);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e]))));
}
return;
}
// P0: 받은 AI 모델을 공용 폴더로 (실패하면 앱을 지우지 않는다)
if (keep && modelSize > 0) {
final backup = ModelBackup.shared;
if (backup == null || !context.mounted) return;
final err = await withProgressDialog(context, tr('AI 모델을 복사해 두는 중'), (onProgress) => backup.save(roots, onProgress: onProgress));
if (err != null) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(trf('AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}', [err]))));
}
return;
}
c.note(trf('되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}', [gbText(modelSize), backup.dir]));
}
if (!context.mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('저장했습니다')),
content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('앱 지우기'))),
],
),
);
if (go == true) await up.uninstallSelf();
}

View File

@ -1,20 +1,20 @@
import 'dart:math' as math;
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../app/ai_local.dart' show AiStore;
import '../app/app_controller.dart';
import '../app/copy_center.dart' show diskSpace;
import '../app/model_backup.dart';
import '../app/net_guard.dart';
import '../app/version_snapshot.dart';
import '../core/app_update.dart';
import '../services/updater.dart' show InstallPermissionNeeded, Updater;
import '../services/updater.dart' show Updater;
import 'theme.dart';
import 'update/update_install.dart';
import '../l10n/tr.dart';
// 약속 (업데이트 나누기): 이 파일은 새 버전 확인 ([checkForUpdate] · [UpdateCheckButton]) 과 버전 고르기를 맡는다.
// 버전 고르기 ([chooseVersion] · [VersionPickerDialog]) 도 여기, 설치 · 되돌리기 · 공용 단계는 update/update_install.dart (부르는 곳이 그대로 쓰게 아래에서 내보냄)
export 'update/update_install.dart' show gbText, installRelease, rollbackModelRoots, withProgressDialog;
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만)
///
/// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
@ -79,7 +79,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
const SizedBox(height: 12),
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Expanded(child: _Notes(r.notes)),
Expanded(child: UpdateNotes(r.notes)),
]),
),
actions: [
@ -105,30 +105,6 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
}
}
/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다.
/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다.
Future<void> chooseVersion(BuildContext context, AppController c) async {
final up = c.services.updater;
if (up == null) return;
final String current;
try {
current = await up.currentVersion();
} catch (_) {
return;
}
if (!context.mounted) return;
final picked = await showDialog<ReleaseInfo>(
context: context,
builder: (_) => _VersionPicker(up: up, current: current),
);
if (picked == null || !context.mounted) return;
await installRelease(context, c, picked, current);
}
/// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞)
ReleaseInfo initialVersionPick(List<ReleaseInfo> all, String current) =>
all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first));
/// 164: [최신 버전 확인] 과 그 결과 (버튼 옆에 - 최신입니다 · 새 버전이 있습니다 · 확인할 수 없습니다)
class UpdateCheckButton extends StatefulWidget {
final AppController c;
@ -164,17 +140,46 @@ class _UpdateCheckButtonState extends State<UpdateCheckButton> {
]);
}
/// 버전 고르기 창
class _VersionPicker extends StatefulWidget {
final Updater up;
/// 약속 (업데이트 나누기): 버전 고르기.
/// - [chooseVersion]: 올려 둔 모든 버전을 보여 주고 고른 것을 [installRelease] 로 (닫으면 아무것도 하지 않음)
/// - [initialVersionPick]: 처음 고를 버전 (순수 함수)
/// - [VersionPickerDialog]: 고르기 창 → 고른 [ReleaseInfo], 닫으면 null. 목록을 읽지 못하면 창 안에 그 글
/// - [UpdateNotes] · [UpdateBadge]: 바뀐 내용 칸 · 작은 표 (지금 · 최신 · 시험판)
/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다.
/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다.
Future<void> chooseVersion(BuildContext context, AppController c) async {
final up = c.services.updater;
if (up == null) return;
final String current;
const _VersionPicker({required this.up, required this.current});
@override
State<_VersionPicker> createState() => _VersionPickerState();
try {
current = await up.currentVersion();
} catch (_) {
return;
}
if (!context.mounted) return;
final picked = await showDialog<ReleaseInfo>(
context: context,
builder: (_) => VersionPickerDialog(up: up, current: current),
);
if (picked == null || !context.mounted) return;
await installRelease(context, c, picked, current);
}
class _VersionPickerState extends State<_VersionPicker> {
/// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞)
ReleaseInfo initialVersionPick(List<ReleaseInfo> all, String current) =>
all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first));
/// 버전 고르기 창
class VersionPickerDialog extends StatefulWidget {
final Updater up;
final String current;
const VersionPickerDialog({super.key, required this.up, required this.current});
@override
State<VersionPickerDialog> createState() => _VersionPickerDialogState();
}
class _VersionPickerDialogState extends State<VersionPickerDialog> {
late final Future<List<ReleaseInfo>> _list = widget.up.releases();
ReleaseInfo? _sel;
@ -229,9 +234,9 @@ class _VersionPickerState extends State<_VersionPicker> {
selectedTileColor: JjColors.accent.withValues(alpha: 0.15),
title: Row(children: [
Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)),
if (r.version == widget.current) _Badge(tr('지금'), JjColors.accent),
if (r.version == newest) _Badge(tr('최신'), JjColors.positive),
if (r.prerelease) _Badge(tr('시험판'), JjColors.warning),
if (r.version == widget.current) UpdateBadge(tr('지금'), JjColors.accent),
if (r.version == newest) UpdateBadge(tr('최신'), JjColors.positive),
if (r.prerelease) UpdateBadge(tr('시험판'), JjColors.warning),
]),
subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published),
style: const TextStyle(fontSize: 11)),
@ -260,7 +265,7 @@ class _VersionPickerState extends State<_VersionPicker> {
const SizedBox(height: 8),
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Expanded(child: _Notes(_sel!.notes)),
Expanded(child: UpdateNotes(_sel!.notes)),
]);
// 168: 좁은 화면 (폰) 에서는 목록 위 · 고른 버전의 바뀐 내용 아래 (나란히 두면 바뀐 내용 칸이 너무 좁았다)
return LayoutBuilder(
@ -290,10 +295,10 @@ class _VersionPickerState extends State<_VersionPicker> {
);
}
class _Badge extends StatelessWidget {
class UpdateBadge extends StatelessWidget {
final String text;
final Color color;
const _Badge(this.text, this.color);
const UpdateBadge(this.text, this.color, {super.key});
@override
Widget build(BuildContext context) => Container(
@ -304,9 +309,9 @@ class _Badge extends StatelessWidget {
);
}
class _Notes extends StatelessWidget {
class UpdateNotes extends StatelessWidget {
final String notes;
const _Notes(this.notes);
const UpdateNotes(this.notes, {super.key});
@override
Widget build(BuildContext context) => Container(
@ -318,334 +323,3 @@ class _Notes extends StatelessWidget {
),
);
}
/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null.
Future<String?> _download(BuildContext context, Updater up, ReleaseInfo r) async {
final progress = ValueNotifier<double>(0);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
scrollable: true,
title: Text(trf('v{0} 받는 중', [r.version])),
content: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'),
const SizedBox(height: 12),
LinearProgressIndicator(value: v >= 1 ? null : v),
]),
),
),
);
try {
final dir = await up.download(r, (x) => progress.value = x);
if (context.mounted) Navigator.of(context).pop();
return dir;
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('업데이트 실패: {0}', [e]))));
}
return null;
}
}
/// [r] 버전 설치 (새 버전 · 예전 버전 · 같은 버전 모두). 설치 전에 지금 버전의 설정을 보관한다.
Future<void> installRelease(BuildContext context, AppController c, ReleaseInfo r, String current) async {
final up = c.services.updater!;
void snack(String t) {
if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
}
if (c.busy) {
snack(tr('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.'));
return;
}
// 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록
if (r.zipUrl == null || !await up.canInstall()) {
snack(tr('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.'));
await up.openPage(r);
return;
}
final down = compareVersions(r.version, current) < 0;
// Android 는 낮은 버전을 위에 설치할 수 없다 (앱을 지우고 설치해야 함) → 따로 안내
if (down && up.installsInPlace) {
if (context.mounted) await _androidRollback(context, c, up, r, current);
return;
}
if (!context.mounted) return;
final dir = await _download(context, up, r);
if (dir == null || !context.mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('설치 준비 완료')),
content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [
r.version,
up.installsInPlace
? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)')
: down
? trf('프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, '
'받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.', [r.version, current])
: tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지금 설치'))),
],
),
);
if (go != true) return;
await _saveSnapshot(c, current);
// Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움)
if (up.installsInPlace) {
while (true) {
try {
await up.scheduleInstall(dir);
return;
} on InstallPermissionNeeded {
// 설치 허용 설정 화면이 열렸다: 켜고 돌아와 [설치 계속] → 받은 파일로 다시 (처음부터 다시 받지 않게)
if (!context.mounted) return;
final again = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
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 (again != true) return;
} catch (e) {
snack(trf('설치 화면을 열 수 없습니다: {0}', [e]));
return;
}
}
}
// 다운로드 중이면 기존 종료 확인 절차
final ok = await (c.confirmQuit?.call() ?? Future.value(true));
if (!ok) return;
await up.scheduleInstall(dir);
await c.services.shell.quit();
}
/// 다른 버전을 설치하기 전에 지금 버전의 설정을 보관 (그 버전으로 돌아오면 되살린다)
Future<void> _saveSnapshot(AppController c, String current) async {
final snap = VersionSnapshot.instance;
if (snap == null) return;
try {
await c.updateSettings((x) => x.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게
await snap.save(current);
c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)]));
} catch (e) {
c.note(trf('설정을 보관하지 못했습니다: {0}', [e]));
}
}
/// P0: 되돌리기 때 옮겨 둘 받은 AI 모델 폴더 (그림 · 해상도: 앱 데이터/ai, 자막: 모델 폴더)
Future<Map<String, String>> rollbackModelRoots(AppController c) async => {
if (AiStore.instance != null) 'ai': AiStore.instance!.root,
'models': await c.services.models.directory(),
};
/// 크기를 GB · MB 로
String gbText(int bytes) => bytes >= 1 << 30
? '${(bytes / (1 << 30)).toStringAsFixed(1)}GB'
: '${(bytes / (1 << 20)).toStringAsFixed(0)}MB';
/// 진행 창을 띄우고 [work] 를 한다 (끌 수 없음). 실패하면 그 글
Future<String?> withProgressDialog(
BuildContext context, String title, Future<Object?> Function(void Function(int done, int total) onProgress) work) async {
final progress = ValueNotifier<double?>(null);
final nav = Navigator.of(context);
// ignore: unawaited_futures
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(title),
content: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [
LinearProgressIndicator(value: v),
const SizedBox(height: 6),
Text(v == null ? tr('준비하는 중') : '${(v * 100).floor()}%'),
]),
),
),
);
String? error;
try {
await work((done, total) => progress.value = total == 0 ? null : done / total);
} catch (e) {
error = '$e';
}
nav.pop();
return error;
}
/// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다.
/// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다.
Future<void> _androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async {
final snap = VersionSnapshot.instance;
final shared = snap?.sharedDir;
if (shared == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.'))));
return;
}
// P0: 남는 것 / 사라지는 것을 사실대로. 받은 AI 모델은 공용 폴더에 옮겨 둘 수 있다
final roots = await rollbackModelRoots(c);
final modelSize = ModelBackup.sizeOf(roots);
// 공용 폴더는 처음엔 없을 수 있다 - 있는 가장 가까운 상위 폴더로 남은 공간을 잰다
var probe = shared;
while (!Directory(probe).existsSync() && p.dirname(probe) != probe) {
probe = p.dirname(probe);
}
final free = (await diskSpace(probe))?.$1;
final setting = c.settings.rollbackKeepModels;
var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2));
var keepTouched = false;
// 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터)
final targetRestoresModels = compareVersions(r.version, '2026.10.09_001') > 0;
final targetRestoresSettings = compareVersions(r.version, '2026.10.08_001') >= 0;
if (!context.mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) {
final size = gbText(modelSize);
final kept = [
tr('설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관'),
tr('공용 폴더에 받은 파일 (동영상 · 그림 등)'),
if (keep) trf('받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관', [size]),
];
final lost = [
if (modelSize > 0 && !keep) trf('받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})', [size]),
tr('비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)'),
tr('마스터 · 최상 비밀번호'),
tr('앱 안 브라우저의 로그인 · 쿠키'),
tr('작업 기록 · 받다 만 파일'),
];
return AlertDialog(
scrollable: true,
title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf(
'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 "남는 것" 을 '
'Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.',
[r.version])),
const SizedBox(height: 10),
Text(tr('남는 것'), style: const TextStyle(fontWeight: FontWeight.w600)),
for (final x in kept) Text('· $x'),
const SizedBox(height: 8),
Text(tr('사라지는 것'), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)),
for (final x in lost) Text('· $x', style: TextStyle(color: JjColors.warning)),
if (modelSize > 0) ...[
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: keep,
// 고른 것은 [저장하고 계속] 을 누를 때만 설정에 남긴다 ([취소] 하면 그대로)
onChanged: (v) => setInner(() {
keep = v ?? false;
keepTouched = true;
}),
title: Text(trf('받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)', [size])),
subtitle: Text([
if (free != null) trf('남은 공간 {0}', [gbText(free)]),
if (!targetRestoresModels)
trf('v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.', [r.version]),
].join(' · ')),
),
],
if (!targetRestoresSettings)
Text(trf('v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.', [r.version]),
style: TextStyle(color: JjColors.warning)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))),
],
);
},
),
);
if (ok != true || !context.mounted) return;
// 체크를 바꿨으면 이제 설정에 (자동이던 것은 건드리지 않으면 자동 그대로)
if (keepTouched) await c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off');
if (!context.mounted) return;
// 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다)
final sure = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('정말 되돌릴까요?')),
content: Text([
tr('앱을 지우면 다음은 되살릴 수 없습니다:'),
if (modelSize > 0 && !keep) trf('· 받은 AI 모델 (약 {0}, 다시 받아야 함)', [gbText(modelSize)]),
tr('· 비밀번호 · API 키 · 마스터 · 최상 비밀번호'),
tr('· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록'),
].join('\n')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.alert),
onPressed: () => Navigator.pop(ctx, true),
child: Text(tr('되돌리기 계속')),
),
],
),
);
if (sure != true || !context.mounted) return;
final apk = await _download(context, up, r);
if (apk == null || !context.mounted) return;
final outDir = p.dirname(shared);
final saved = p.join(outDir, r.zipName ?? 'JJ_MKVMaker_v${r.version}.apk');
try {
await Directory(outDir).create(recursive: true);
await File(apk).copy(saved);
await _saveSnapshot(c, current);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e]))));
}
return;
}
// P0: 받은 AI 모델을 공용 폴더로 (실패하면 앱을 지우지 않는다)
if (keep && modelSize > 0) {
final backup = ModelBackup.shared;
if (backup == null || !context.mounted) return;
final err = await withProgressDialog(context, tr('AI 모델을 복사해 두는 중'), (onProgress) => backup.save(roots, onProgress: onProgress));
if (err != null) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(trf('AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}', [err]))));
}
return;
}
c.note(trf('되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}', [gbText(modelSize), backup.dir]));
}
if (!context.mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('저장했습니다')),
content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('앱 지우기'))),
],
),
);
if (go == true) await up.uninstallSelf();
}

View File

@ -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<void> 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<void>(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<void> vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e, Object err) async {
if (!context.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 || !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<void> vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, Future<String?> 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<void> 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<void> 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<ReaderImage> load(int i, {required int maxWidth}) async =>
ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes)));
}
/// 약속 (보관함 나누기, 2단계): 보관함 안 폴더 일 - 새 폴더 · 옮기기 (파일 · 폴더) · 이름 변경 · 지우기 · 폴더째 꺼내기.
/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 지금 폴더 [VaultUiState.cwd] · 진행 막대 · 알림)
/// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림)
/// - 오류: 같은 이름 · 쓸 수 없는 이름 · 꺼내지 못함은 알림으로 (던지지 않음)
/// 지금 폴더 안 새 폴더
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> vaultExportFolder(BuildContext context, VaultUiState s, String f, Future<String?> 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<void> 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<void> 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();
}

View File

@ -0,0 +1,296 @@
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<Directory> dir() async {
final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
await d.create(recursive: true);
return d;
}
Future<void> clear() async {
final d = _session;
_session = null;
if (d == null) return;
await removeTempDirRetry(d);
}
}
/// 약속: 앱 전용 임시 폴더 [d] 를 지운다. Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 -
/// 0.2초 간격으로 10번까지 (남으면 다음에 켤 때 지움). 오류는 던지지 않는다
Future<void> removeTempDirRetry(Directory d) async {
for (var i = 0; i < 10; i++) {
try {
if (await d.exists()) await d.delete(recursive: true);
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
}
}
/// 약속 (보관함 나누기): 보관함에 넣기 - 펼치기 [planVaultAdd] → 확인 창 [askVaultAdd] → 넣기 [putVaultAdd].
/// [vaultAddFlow] 는 셋을 차례로 부르고, 다 넣은 것만 원본을 지운다. 오류는 알림 ([VaultUiState.snack]) 으로만 (던지지 않음)
/// 2단계: 넣을 것 (폴더를 펼친 것). [failed] 읽지 못한 고른 것 → 오류 (그 원본은 지우지 않음)
class VaultAddPlan {
final files = <({String src, String folder, String top})>[];
final folders = <String>[];
final failed = <String, Object>{};
}
/// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도.
/// 맨 위 폴더 이름이 [cwd] 에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게)
/// - 입력: 열린 보관함 [v] · 넣을 곳 [cwd] · 고른 것 [targets]
/// - 출력: [VaultAddPlan]. 읽지 못한 것은 [VaultAddPlan.failed] 에 (그 폴더는 반만 넣지 않게 파일도 뺌). 던지지 않음
Future<VaultAddPlan> planVaultAdd(Vault v, String cwd, List<String> targets) async {
final plan = VaultAddPlan();
final taken = <String>{};
for (final t in targets) {
try {
if (!await vIsDir(t)) {
plan.files.add((src: t, folder: cwd, top: t));
continue;
}
var name = v.freeName(cwd, vBasename(t));
for (var i = 2; taken.contains(name); i++) {
name = v.freeName(cwd, '${vBasename(t)} ($i)');
}
taken.add(name);
final root = cwd.isEmpty ? name : '$cwd/$name';
if (Vault.cleanFolder(root) == null) throw FileSystemException(tr('쓸 수 없는 이름입니다'), t);
plan.folders.add(root);
Future<void> walk(String dir, String folder) async {
for (final x in await vList(dir, strict: true)) {
final n = vBasename(x.path);
if (!Vault.validName(n)) throw FileSystemException(tr('쓸 수 없는 이름입니다'), x.path);
if (x.isDir) {
plan.folders.add('$folder/$n');
await walk(x.path, '$folder/$n');
} else {
plan.files.add((src: x.path, folder: folder, top: t));
}
}
}
await walk(t, root);
} catch (e) {
plan.failed[t] = e;
}
}
// 읽지 못한 폴더는 반만 넣지 않는다
plan.files.removeWhere((x) => plan.failed.containsKey(x.top));
return plan;
}
/// 넣기 확인 창 - [원본 지우기 (이동)] 체크 (처음 끔, [canMove] 가 아니면 꺼진 채).
/// 출력: [넣기] 면 (move, permanent), [취소] · 바깥이면 null
Future<({bool move, bool permanent})?> askVaultAdd(BuildContext context, List<String> paths, VaultAddPlan plan,
{required String cwd, required bool canMove}) async {
final remote = paths.any(isDav);
var move = false;
// 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게)
var permanent = false;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(trf('보관함에 넣기 · {0}개', [paths.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final x in paths.take(8)) Text('· ${vBasename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis),
if (paths.length > 8) Text(trf('… 외 {0}개', [paths.length - 8])),
const SizedBox(height: 6),
Text(trf('파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)', [plan.files.length, plan.folders.length]),
style: const TextStyle(fontSize: 12.5)),
Text(trf('넣을 곳: {0}', [vaultFolderLabel(cwd)]), style: const TextStyle(fontSize: 12.5)),
if (remote)
Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final f in plan.failed.entries.take(3))
Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]),
style: TextStyle(fontSize: 12, color: JjColors.warning), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: move,
onChanged: !canMove ? null : (v) => set(() => move = v ?? false),
title: Text(tr('원본 지우기 (이동)')),
subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')),
),
if (move) ...[
Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
RadioGroup<bool>(
groupValue: permanent,
onChanged: (v) => set(() => permanent = v ?? false),
child: Column(mainAxisSize: MainAxisSize.min, children: [
RadioListTile<bool>(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
value: true,
title: Text(tr('영구 삭제')),
subtitle: Text(tr('휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다'))),
]),
),
],
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
onPressed: plan.files.isEmpty && plan.folders.isEmpty ? null : () => Navigator.pop(ctx, true), child: Text(tr('넣기'))),
],
),
),
);
return go == true ? (move: move, permanent: permanent) : null;
}
/// 펼친 [plan] 을 [s] 의 보관함에 넣는다 (넣는 동안 잠그면 ([VaultUiState.vault] 가 null) 멈춤).
/// - 원격은 앱 전용 폴더에 받아 넣고 바로 지운다. 넣은 원본의 썸네일 캐시 ([thumbTemp] 아래) 도 지운다
/// - 출력: 넣은 수 [done] · 다 넣지 못한 고른 것 [bad] (plan.failed · 잠가서 멈춘 뒤 남은 것 포함). 오류는 [VaultUiState.snack] 으로 (던지지 않음)
Future<({int done, Set<String> bad})> putVaultAdd(VaultUiState s, List<String> paths, VaultAddPlan plan,
{required Future<String> Function() thumbTemp}) async {
s.setBusy(true);
final bad = <String>{...plan.failed.keys};
var done = 0;
Directory? tmp;
try {
for (final f in plan.folders) {
await s.vault!.mkdir(f);
}
for (var i = 0; i < plan.files.length; i++) {
final x = plan.files[i];
if (s.vault == null) {
// 넣는 동안 잠갔으면 멈춤 - 넣지 못한 남은 것은 bad 로 (그 원본은 [원본 지우기 (이동)] 로 지우지 않게)
for (final rest in plan.files.skip(i)) {
bad.add(rest.top);
}
break;
}
s.setProgress('${i + 1} / ${plan.files.length} · ${vBasename(x.src)}');
String? fetched;
try {
var src = x.src;
// 관리자 지시 (10/21, 28 지적): 넣은 원본의 썸네일 캐시 (이름이 경로 · 크기 · 바뀐 때) 를 지우려고 미리 봄
final st = await vStat(x.src);
if (isDav(src)) {
// 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다
tmp ??= await Directory(p.join((await 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<void> vaultAddFlow(BuildContext context, VaultUiState s, List<String> paths,
{required Future<String> Function() thumbTemp,
Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) async {
if (paths.isEmpty || s.vault == null) return;
s.setBusyProgress(true, tr('넣을 것을 세는 중…'));
final plan = await planVaultAdd(s.vault!, s.cwd, paths);
if (!context.mounted) return;
s.setBusyProgress(false, null);
final pick = await askVaultAdd(context, paths, plan, cwd: s.cwd, canMove: deleteOriginals != null);
if (pick == null || !context.mounted) return;
final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp);
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);
}

View File

@ -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<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;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/version_snapshot.dart';
import 'package:jj_mkvmaker/core/app_update.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/update/update_install.dart';
import 'package:jj_mkvmaker/ui/update_dialog.dart';
/// 업데이트 나누기 (update_dialog 651줄 → lib/ui/update/) 의 모듈 약속 시험.
/// 확인 · 고르기 · 설치 · Android 되돌리기 흐름 전체는 update_ui_test 가 본다
void main() {
ReleaseInfo rel(String v, {bool zip = true}) => ReleaseInfo(
version: v, tag: 'v$v', name: v, notes: '', pageUrl: 'https://example.com/$v', zipUrl: zip ? 'https://example.com/$v.zip' : null);
test('update_install (단계): gbText (1GB 이상은 소수 한 자리 GB, 아래는 MB)', () {
expect(gbText(1 << 30), '1.0GB');
expect(gbText(3 * (1 << 29)), '1.5GB');
expect(gbText(5 << 20), '5MB');
expect(gbText(0), '0MB');
});
test('update_dialog (버전 고르기): 처음 고를 버전 = 이 기기용 파일이 있는 가장 새것, 없으면 지금, 그것도 없으면 맨 앞', () {
expect(initialVersionPick([rel('3', zip: false), rel('2'), rel('1')], '1').version, '2');
expect(initialVersionPick([rel('3', zip: false), rel('2', zip: false)], '2').version, '2');
expect(initialVersionPick([rel('3', zip: false), rel('2', zip: false)], '9').version, '3');
});
testWidgets('update_install (단계): withProgressDialog - 끝나면 창을 닫고 null, 실패하면 그 글 · 진행은 % 로', (t) async {
late BuildContext ctx;
await t.pumpWidget(MaterialApp(home: Builder(builder: (c) {
ctx = c;
return const Scaffold();
})));
void Function(int, int)? report;
var release = false;
final ok = withProgressDialog(ctx, '복사 중', (onProgress) async {
report = onProgress;
while (!release) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
return null;
});
await t.pump();
expect(find.text('복사 중'), findsOneWidget);
expect(find.text('준비하는 중'), findsOneWidget);
report!(1, 4);
await t.pump();
expect(find.text('25%'), findsOneWidget);
release = true;
await t.pump(const Duration(milliseconds: 20));
await t.pumpAndSettle();
expect(await ok, isNull);
expect(find.text('복사 중'), findsNothing);
final bad = withProgressDialog(ctx, '되살리는 중', (_) async => throw StateError('자리 없음'));
await t.pumpAndSettle();
expect(await bad, contains('자리 없음'));
expect(find.text('되살리는 중'), findsNothing);
});
test('update_install (단계): 설정 보관 저장소가 없으면 (VersionSnapshot 없음) 아무것도 하지 않음 · 던지지 않음', () async {
final was = VersionSnapshot.instance;
VersionSnapshot.instance = null;
addTearDown(() => VersionSnapshot.instance = was);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final before = c.settings.lastRunVersion;
await saveVersionSnapshot(c, '9.9.9');
expect(c.settings.lastRunVersion, before);
});
testWidgets('update_dialog (버전 고르기): 바뀐 내용이 비면 "(설명 없음)" · 표는 글자 그대로', (t) async {
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: Column(children: [
const SizedBox(height: 100, child: UpdateNotes('')),
UpdateBadge('최신', Colors.green),
]))));
expect(find.text('(설명 없음)'), findsOneWidget);
expect(find.text('최신'), findsOneWidget);
});
}

View File

@ -0,0 +1,219 @@
import 'dart:async';
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 = <String>[];
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 = <String>[];
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<ListTile>(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<void>.delayed(const Duration(milliseconds: 20));
}
await v.mkdir('나');
await v.mkdir('가');
return v;
});
final got = <String>[];
Future<void> 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<String> titles() => [
for (final w in t.widgetList<ListTile>(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: '바쁜 동안은 누르기가 꺼짐');
});
testWidgets('vault_session (넣기): [원본 지우기 (이동)] 로 넣다가 중간에 잠그면 넣은 것만 원본을 지우고, 넣지 못한 원본은 바이트까지 그대로', (t) async {
final src = Directory(p.join(tmp.path, 'src'));
final names = ['1.bin', '2.bin', '3.bin', '4.bin'];
final bytes = {for (final n in names) n: List<int>.generate(4096, (i) => (i * 7 + n.codeUnitAt(0)) % 256)};
final vault = await t.runAsync(() async {
src.createSync();
for (final n in names) {
File(p.join(src.path, n)).writeAsBytesSync(bytes[n]!);
}
Directory(p.join(tmp.path, 'thumbs')).createSync();
return Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000);
});
final paths = [for (final n in names) p.join(src.path, n)];
late VaultUiState s;
// 화면의 잠그기와 같게: 열쇠를 버린다 (vault = null). "3 / 4" 를 보이는 때 = 1 · 2 를 넣은 뒤
s = VaultUiState(
changed: () {
if (s.progress?.startsWith('3 / ') ?? false) s.vault = null;
},
snack: (_) {})
..vault = vault;
final deleted = <String>[];
late BuildContext ctx;
await t.pumpWidget(MaterialApp(home: Builder(builder: (c) {
ctx = c;
return const Scaffold();
})));
var finished = false;
unawaited(vaultAddFlow(ctx, s, paths, thumbTemp: () async => p.join(tmp.path, 'thumbs'), deleteOriginals: (ok, {required permanent}) async {
deleted.addAll(ok);
for (final x in ok) {
File(x).deleteSync(); // 실제로 지운다 (탐색기의 지우기 대신)
}
}).whenComplete(() => finished = true));
Future<void> until(bool Function() ok) async {
for (var i = 0; i < 300 && !ok(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await t.pump(const Duration(milliseconds: 20));
}
}
await until(() => find.text('원본 지우기 (이동)').evaluate().isNotEmpty);
await t.tap(find.text('원본 지우기 (이동)'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(() => finished);
expect(finished, isTrue);
expect(s.vault, isNull, reason: '중간에 잠금');
expect([for (final e in vault!.entries) e.name], unorderedEquals(['1.bin', '2.bin']), reason: '잠그기 전 둘만 들어감');
expect(deleted, unorderedEquals([paths[0], paths[1]]), reason: '넣은 것만 원본 지우기로');
for (final n in ['3.bin', '4.bin']) {
final f = File(p.join(src.path, n));
expect(f.existsSync(), isTrue, reason: '넣지 못한 원본 $n 은 남음');
expect(f.readAsBytesSync(), bytes[n], reason: '$n 바이트 그대로');
}
});
}