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 Function() onOpenRoot, onOpenShizuku; final Future 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( 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 _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.general.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 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 _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), ), ]; }