JJ_MKVMaker/lib/ui/explorer/pane_notices.dart

276 lines
13 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import '../../core/file_ops.dart';
import '../../core/ftp.dart' show FtpRegistry;
import '../../core/secret_gate.dart';
import '../../core/sftp.dart' show SftpRegistry;
import '../../core/shizuku.dart';
import '../../core/smb.dart' show SmbRegistry;
import '../../core/vfs.dart';
import '../../core/webdav.dart' show DavRegistry;
import '../../l10n/tr.dart';
import '../../platform/android/android_storage.dart';
import '../file_error.dart';
import '../ftp_settings.dart';
import '../sftp_settings.dart';
import '../theme.dart';
import 'access_flow.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'server_flows.dart';
/// 약속 (E5d-6): 창 목록 위의 안내 상자들 ([of] - 없는 것은 null). 시스템 폴더 · 루트를 보는 동안 (읽기 전용) · 권한이 없을 때
/// (허용 화면 · 다시 확인) · 읽지 못할 때 (까닭 · [다시 시도] · [서버 설정 고치기] · 처음 보는 서버 키 · 인증서 지문) ·
/// 만들다 만 파일 (지우기). 안내의 단추가 하는 일은 [ExplorerAccess] · [ExplorerServers] · [ExplorerController]
class ExplorerNotices {
ExplorerNotices(this.ctl, this.access, this.servers);
final ExplorerController ctl;
final ExplorerAccess access;
final ExplorerServers servers;
List<Widget?> of(BuildContext context, ExplorerPane pane, String dir) =>
[_shizuku(pane), _access(pane), _error(context, pane, dir), _partial(context, pane, dir)];
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
Widget? _shizuku(ExplorerPane pane) {
if (!isShizuku(pane.current)) return null;
final root = isRootPath(pane.current);
return _notice(
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
body: root
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
actions: const [],
color: JjColors.warning,
);
}
Widget? _access(ExplorerPane pane) {
if (!Platform.isAndroid || access.allFiles != false || isDav(pane.root)) return null;
return _notice(
icon: Icons.lock_outline,
title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'),
body: access.dualApp
? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.')
: tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'),
actions: [
FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))),
TextButton(onPressed: () => access.checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))),
],
);
}
/// 목록 위 안내 상자
Widget _notice(
{required IconData icon,
required String title,
required String body,
required List<Widget> actions,
Color? color}) =>
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
Widget _noticeBox(IconData icon, String title, String body, List<Widget> actions, Color color) =>
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(6, 6, 6, 0),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 4),
Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Wrap(spacing: 8, children: actions),
]),
);
// ───────── 만들다 만 파일 (71) ─────────
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
List<FileEntry> _partials(ExplorerPane pane, String dir) {
final old = DateTime.now().subtract(const Duration(minutes: 2));
return [
for (final e in pane.cache[dir] ?? const <FileEntry>[])
if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e,
];
}
Widget? _partial(BuildContext context, ExplorerPane pane, String dir) {
final list = _partials(pane, dir);
if (list.isEmpty) return null;
return _notice(
icon: Icons.broken_image_outlined,
color: JjColors.warning,
title: trf('만들다 만 파일 {0}개', [list.length]),
body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. '
'같은 파일을 다시 복사하면 새로 만듭니다.'),
actions: [
TextButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)),
if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
final failed = <String>[];
for (final e in list) {
try {
await vDelete(e.path);
} catch (_) {
failed.add(e.name);
}
}
// 같은 폴더를 보는 다른 창도 새로
for (final q in ctl.panes) {
if (q == pane || q.cache.containsKey(dir)) await ctl.load(q, dir, force: true);
}
if (failed.isNotEmpty) ctl.host.snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
},
child: Text(tr('지우기')),
),
],
);
}
// ───────── 읽을 수 없을 때 (12) ─────────
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
Widget? _error(BuildContext context, ExplorerPane pane, String dir) {
final err = pane.errors[dir] ?? pane.errors[pane.root];
if (err == null) return null;
final where = pane.errors[dir] != null ? dir : pane.root;
final dav = isDav(where);
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
if (ftp != null && cert != null) {
return _notice(
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
body: cert.message,
actions: [
if (!cert.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
await trustFtpCert(ctl.c, ftp.id, cert);
pane.certErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
if (sftp != null && hostKey != null) {
return _notice(
icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}',
body: hostKey.message,
actions: [
if (!hostKey.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustHostKey(context, sftp.label, hostKey)) return;
await trustSftpHostKey(ctl.c, sftp.id, hostKey);
pane.hostKeyErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
final (title, body) = explainFileError(err,
dav: dav,
noPassword: (server != null && server.password.isEmpty) ||
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
final locked = isLockedError(err);
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
if (err == androidRestrictedError) {
return _notice(
icon: Icons.lock_outline,
title: '${ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () {
pane.errors.remove(where);
ctl.goTo(pane, vDirname(where));
},
child: Text(tr('위 폴더로')),
),
],
);
}
return _notice(
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
title: '${ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () async {
// 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다
if (locked && !await SecretGate.pass(force: true)) return;
pane.errors.remove(where);
pane.changed();
await ctl.load(pane, where, force: true);
for (final d in pane.expanded.toList()) {
if (isSameOrInside(d, where) && d != where) await ctl.load(pane, d, force: true);
}
},
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
),
// 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다
if (dav && !locked && !isShizuku(where))
TextButton(
onPressed: () => sftp != null
? servers.editSftp(context, pane, pane.root)
: smb != null
? servers.editSmb(context, pane, pane.root)
: ftp != null
? servers.editFtp(context, pane, pane.root)
: servers.editDav(context, pane, pane.root),
child: Text(tr('서버 설정 고치기'))),
],
);
}
}