JJ_MKVMaker/lib/ui/explorer/pane_header.dart

288 lines
14 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart' show PointerDeviceKind, PointerScrollEvent;
import 'package:flutter/material.dart';
import '../../core/file_ops.dart' show samePath;
import '../../core/shizuku.dart' show isRootPath, isShizuku;
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../dlna_page.dart' show openDlna;
import '../key_hint.dart';
import '../theme.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'server_flows.dart';
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
const double paneHeaderWideWidth = 480;
/// 약속 (E5d-3): 창 머리 한 줄 - [뒤로 · 앞으로 · 즐겨찾기 · 빠른 필터] · 저장 장치 탭 · [서버 추가 · 경로 입력].
/// 창 상태는 [ExplorerController], 서버 · 경로 흐름은 [ExplorerServers], 화면에 남은 일 (즐겨찾기 · 필터 · 루트 · Shizuku ·
/// 다시 연결) 은 콜백. 누르면 [pane] 이 지금 창이 된다. [android]: SMB 서버 추가 메뉴 · 경로 입력의 SMB 안내
class ExplorerPaneHeader extends StatelessWidget {
const ExplorerPaneHeader({
super.key,
required this.ctl,
required this.servers,
required this.pane,
required this.android,
required this.onBookmarks,
required this.onOpenFilter,
required this.onCloseFilter,
required this.onOpenRoot,
required this.onOpenShizuku,
required this.onReconnect,
});
final ExplorerController ctl;
final ExplorerServers servers;
final ExplorerPane pane;
final bool android;
final VoidCallback onBookmarks, onOpenFilter, onCloseFilter;
final Future<void> Function() onOpenRoot, onOpenShizuku;
final Future<void> Function(String path) onReconnect;
/// 창 위쪽: 저장 장치 고르기
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
@override
Widget build(BuildContext context) => SizedBox(
height: 40,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: LayoutBuilder(
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
? Row(children: [
..._start(),
Expanded(child: _driveTabs(context)),
..._end(context),
])
: Row(children: [
Expanded(child: _driveTabs(context, lead: _start())),
_menu(context),
]),
),
),
);
/// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력
Widget _menu(BuildContext context) => PopupMenuButton<String>(
tooltip: tr('경로 입력 · 네트워크 서버'),
icon: const Icon(Icons.more_vert, size: 18),
padding: EdgeInsets.zero,
onSelected: (v) => switch (v) {
'path' => servers.enterPath(context, pane, android: android),
'sftp' => servers.editSftp(context, pane, null),
'smb' => servers.editSmb(context, pane, null),
'ftp' => servers.editFtp(context, pane, null),
'dlna' => openDlna(context, ctl.c),
_ => servers.editDav(context, pane, null),
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'path',
child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))),
PopupMenuItem(
value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))),
PopupMenuItem(
value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))),
PopupMenuItem(
value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))),
// 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유)
if (android)
PopupMenuItem(
value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))),
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
if (!ctl.rsync)
PopupMenuItem(
value: 'dlna', child: ListTile(dense: true, leading: const Icon(Icons.cast_outlined), title: Text(tr('미디어 서버 (DLNA)')))),
],
);
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
List<Widget> _start() => [
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
IconButton(
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, size: 16),
onPressed: !pane.nav.canBack ? null : () => ctl.travel(pane, forward: false),
),
IconButton(
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_forward, size: 16),
onPressed: !pane.nav.canForward ? null : () => ctl.travel(pane, forward: true),
),
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
IconButton(
tooltip: tr('즐겨찾기'),
visualDensity: VisualDensity.compact,
icon: Icon(
ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline,
size: 16,
color: JjColors.amber),
onPressed: () => onBookmarks(),
),
// 76: 빠른 필터 (켜져 있으면 강조)
IconButton(
tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'),
visualDensity: VisualDensity.compact,
isSelected: pane.filterOpen,
icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined,
size: 16, color: pane.filterOpen ? JjColors.accent : null),
onPressed: () => pane.filterOpen ? onCloseFilter() : onOpenFilter(),
),
];
/// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로
Widget _driveTabs(BuildContext context, {List<Widget> lead = const []}) {
final all = [
...ctl.volumes,
// 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로
for (final x in ctl.panes)
if (Platform.isWindows && x.root.startsWith(r'\\') && !ctl.volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
];
final tabs = [
...all.where((v) => ctl.drives.ok[v.$1] != false || samePath(pane.root, v.$1)),
...all.where((v) => ctl.drives.ok[v.$1] == false && !samePath(pane.root, v.$1)),
];
return Listener(
// 마우스 휠 (위 · 아래) 을 옆으로
onPointerSignal: (e) {
if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return;
final pos = pane.tabScroll.position;
final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy;
pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent));
},
child: ScrollConfiguration(
// 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음)
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus},
scrollbars: false,
),
child: ListView(
controller: pane.tabScroll,
scrollDirection: Axis.horizontal,
children: [
...lead,
for (final (path, label) in tabs)
Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton.icon(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10),
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
),
icon: Icon(
isRootPath(path)
? Icons.security
: isShizuku(path)
? Icons.admin_panel_settings_outlined
: isSftp(path)
? Icons.vpn_key_outlined
: isFtp(path)
? Icons.folder_shared_outlined
: isSmb(path)
? Icons.dns_outlined
: isDav(path)
? Icons.cloud_outlined
: ctl.drives.network.contains(path)
? Icons.lan_outlined
: Platform.isWindows
? Icons.storage
: Icons.sd_storage_outlined,
size: 16),
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
label: ctl.drives.ok[path] == false
? Text('$label · ${ctl.drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
style: TextStyle(color: ctl.drives.removable.contains(path) ? JjColors.textDim : JjColors.warning))
: Text(label),
onPressed: () {
ctl
..active = ctl.panes.indexOf(pane)
..update();
if (isRootPath(path)) {
unawaited(onOpenRoot());
} else if (isShizuku(path)) {
unawaited(onOpenShizuku());
} else if (ctl.drives.ok[path] == false) {
unawaited(onReconnect(path));
} else {
ctl.goTo(pane, path);
}
},
onLongPress: isSftp(path)
? () => servers.editSftp(context, pane, path)
: isFtp(path)
? () => servers.editFtp(context, pane, path)
: isSmb(path)
? () => servers.editSmb(context, pane, path)
: isDav(path)
? () => servers.editDav(context, pane, path)
: null,
),
),
],
),
),
);
}
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
List<Widget> _end(BuildContext context) => [
if (ctl.c.settings.webdavServers.isEmpty)
TextButton.icon(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
icon: const Icon(Icons.cloud_outlined, size: 16),
label: const Text('WebDAV'),
onPressed: () => servers.editDav(context, pane, null),
)
else
IconButton(
tooltip: tr('WebDAV 서버 추가'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add, size: 16),
onPressed: () => servers.editDav(context, pane, null),
),
// 37: 경로를 직접 넣어 가기 (Windows 는 \\NAS\공유 도)
// 88: SFTP 서버 추가
IconButton(
tooltip: tr('SFTP 서버 추가'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.vpn_key_outlined, size: 16),
onPressed: () => servers.editSftp(context, pane, null),
),
// l: FTP · FTPS 서버 추가
IconButton(
tooltip: tr('FTP 서버 추가'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.folder_shared_outlined, size: 16),
onPressed: () => servers.editFtp(context, pane, null),
),
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
if (!ctl.rsync)
IconButton(
tooltip: tr('미디어 서버 (DLNA)'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.cast_outlined, size: 16),
onPressed: () => openDlna(context, ctl.c),
),
IconButton(
tooltip: tr('경로 입력'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.edit_location_alt_outlined, size: 16),
onPressed: () => servers.enterPath(context, pane, android: android),
),
];
}