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 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 actions, Color? color}) => _noticeBox(icon, title, body, actions, color ?? JjColors.alert); Widget _noticeBox(IconData icon, String title, String body, List 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 _partials(ExplorerPane pane, String dir) { final old = DateTime.now().subtract(const Duration(minutes: 2)); return [ for (final e in pane.cache[dir] ?? const []) 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( 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 = []; 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('서버 설정 고치기'))), ], ); } }