288 lines
14 KiB
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.remote.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),
|
|
),
|
|
];
|
|
}
|