file_manager_page.dart 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:math';
  4. import 'package:extended_text/extended_text.dart';
  5. import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
  6. import 'package:percent_indicator/percent_indicator.dart';
  7. import 'package:desktop_drop/desktop_drop.dart';
  8. import 'package:flutter/gestures.dart';
  9. import 'package:flutter/material.dart';
  10. import 'package:flutter/services.dart';
  11. import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
  12. import 'package:flutter_hbb/desktop/widgets/list_search_action_listener.dart';
  13. import 'package:flutter_hbb/desktop/widgets/menu_button.dart';
  14. import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
  15. import 'package:flutter_hbb/models/file_model.dart';
  16. import 'package:flutter_svg/flutter_svg.dart';
  17. import 'package:get/get.dart';
  18. import 'package:wakelock_plus/wakelock_plus.dart';
  19. import 'package:flutter_hbb/web/dummy.dart'
  20. if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
  21. import '../../consts.dart';
  22. import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
  23. import '../../common.dart';
  24. import '../../models/model.dart';
  25. import '../../models/platform_model.dart';
  26. import '../widgets/popup_menu.dart';
  27. /// status of location bar
  28. enum LocationStatus {
  29. /// normal bread crumb bar
  30. bread,
  31. /// show path text field
  32. pathLocation,
  33. /// show file search bar text field
  34. fileSearchBar
  35. }
  36. /// The status of currently focused scope of the mouse
  37. enum MouseFocusScope {
  38. /// Mouse is in local field.
  39. local,
  40. /// Mouse is in remote field.
  41. remote,
  42. /// Mouse is not in local field, remote neither.
  43. none
  44. }
  45. class FileManagerPage extends StatefulWidget {
  46. const FileManagerPage(
  47. {Key? key,
  48. required this.id,
  49. required this.password,
  50. required this.isSharedPassword,
  51. this.tabController,
  52. this.connToken,
  53. this.forceRelay})
  54. : super(key: key);
  55. final String id;
  56. final String? password;
  57. final bool? isSharedPassword;
  58. final bool? forceRelay;
  59. final String? connToken;
  60. final DesktopTabController? tabController;
  61. @override
  62. State<StatefulWidget> createState() => _FileManagerPageState();
  63. }
  64. class _FileManagerPageState extends State<FileManagerPage>
  65. with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
  66. final _mouseFocusScope = Rx<MouseFocusScope>(MouseFocusScope.none);
  67. final _dropMaskVisible = false.obs; // TODO impl drop mask
  68. final _overlayKeyState = OverlayKeyState();
  69. late FFI _ffi;
  70. FileModel get model => _ffi.fileModel;
  71. JobController get jobController => model.jobController;
  72. @override
  73. void initState() {
  74. super.initState();
  75. _ffi = FFI(null);
  76. _ffi.start(widget.id,
  77. isFileTransfer: true,
  78. password: widget.password,
  79. isSharedPassword: widget.isSharedPassword,
  80. connToken: widget.connToken,
  81. forceRelay: widget.forceRelay);
  82. WidgetsBinding.instance.addPostFrameCallback((_) {
  83. _ffi.dialogManager
  84. .showLoading(translate('Connecting...'), onCancel: closeConnection);
  85. });
  86. Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
  87. if (!isLinux) {
  88. WakelockPlus.enable();
  89. }
  90. if (isWeb) {
  91. _ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
  92. }
  93. debugPrint("File manager page init success with id ${widget.id}");
  94. _ffi.dialogManager.setOverlayState(_overlayKeyState);
  95. // Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
  96. WidgetsBinding.instance.addPostFrameCallback((_) {
  97. widget.tabController?.onSelected?.call(widget.id);
  98. });
  99. WidgetsBinding.instance.addObserver(this);
  100. }
  101. @override
  102. void dispose() {
  103. model.close().whenComplete(() {
  104. _ffi.close();
  105. _ffi.dialogManager.dismissAll();
  106. if (!isLinux) {
  107. WakelockPlus.disable();
  108. }
  109. Get.delete<FFI>(tag: 'ft_${widget.id}');
  110. });
  111. WidgetsBinding.instance.removeObserver(this);
  112. super.dispose();
  113. }
  114. @override
  115. bool get wantKeepAlive => true;
  116. @override
  117. void didChangeAppLifecycleState(AppLifecycleState state) {
  118. super.didChangeAppLifecycleState(state);
  119. if (state == AppLifecycleState.resumed) {
  120. jobController.jobTable.refresh();
  121. }
  122. }
  123. @override
  124. Widget build(BuildContext context) {
  125. super.build(context);
  126. return Overlay(key: _overlayKeyState.key, initialEntries: [
  127. OverlayEntry(builder: (_) {
  128. return Scaffold(
  129. backgroundColor: Theme.of(context).scaffoldBackgroundColor,
  130. body: Row(
  131. children: [
  132. if (!isWeb)
  133. Flexible(
  134. flex: 3,
  135. child: dropArea(FileManagerView(
  136. model.localController, _ffi, _mouseFocusScope))),
  137. Flexible(
  138. flex: 3,
  139. child: dropArea(FileManagerView(
  140. model.remoteController, _ffi, _mouseFocusScope))),
  141. Flexible(flex: 2, child: statusList())
  142. ],
  143. ),
  144. );
  145. })
  146. ]);
  147. }
  148. Widget dropArea(FileManagerView fileView) {
  149. return DropTarget(
  150. onDragDone: (detail) =>
  151. handleDragDone(detail, fileView.controller.isLocal),
  152. onDragEntered: (enter) {
  153. _dropMaskVisible.value = true;
  154. },
  155. onDragExited: (exit) {
  156. _dropMaskVisible.value = false;
  157. },
  158. child: fileView);
  159. }
  160. Widget generateCard(Widget child) {
  161. return Container(
  162. decoration: BoxDecoration(
  163. color: Theme.of(context).cardColor,
  164. borderRadius: BorderRadius.all(
  165. Radius.circular(15.0),
  166. ),
  167. ),
  168. child: child,
  169. );
  170. }
  171. /// transfer status list
  172. /// watch transfer status
  173. Widget statusList() {
  174. Widget getIcon(JobProgress job) {
  175. final color = Theme.of(context).tabBarTheme.labelColor;
  176. switch (job.type) {
  177. case JobType.deleteDir:
  178. case JobType.deleteFile:
  179. return Icon(Icons.delete_outline, color: color);
  180. default:
  181. return Transform.rotate(
  182. angle: isWeb
  183. ? job.isRemoteToLocal
  184. ? pi / 2
  185. : pi / 2 * 3
  186. : job.isRemoteToLocal
  187. ? pi
  188. : 0,
  189. child: Icon(Icons.arrow_forward_ios, color: color),
  190. );
  191. }
  192. }
  193. statusListView(List<JobProgress> jobs) => ListView.builder(
  194. controller: ScrollController(),
  195. itemBuilder: (BuildContext context, int index) {
  196. final item = jobs[index];
  197. final status = item.getStatus();
  198. return Padding(
  199. padding: const EdgeInsets.only(bottom: 5),
  200. child: generateCard(
  201. Column(
  202. mainAxisSize: MainAxisSize.min,
  203. children: [
  204. Row(
  205. crossAxisAlignment: CrossAxisAlignment.center,
  206. children: [
  207. getIcon(item)
  208. .marginSymmetric(horizontal: 10, vertical: 12),
  209. Expanded(
  210. child: Column(
  211. mainAxisSize: MainAxisSize.min,
  212. crossAxisAlignment: CrossAxisAlignment.start,
  213. children: [
  214. Tooltip(
  215. waitDuration: Duration(milliseconds: 500),
  216. message: item.jobName,
  217. child: ExtendedText(
  218. item.jobName,
  219. maxLines: 1,
  220. overflow: TextOverflow.ellipsis,
  221. overflowWidget: TextOverflowWidget(
  222. child: Text("..."),
  223. position: TextOverflowPosition.start),
  224. ),
  225. ),
  226. Tooltip(
  227. waitDuration: Duration(milliseconds: 500),
  228. message: status,
  229. child: Text(status,
  230. style: TextStyle(
  231. fontSize: 12,
  232. color: MyTheme.darkGray,
  233. )).marginOnly(top: 6),
  234. ),
  235. Offstage(
  236. offstage: item.type != JobType.transfer ||
  237. item.state != JobState.inProgress,
  238. child: LinearPercentIndicator(
  239. animateFromLastPercent: true,
  240. center: Text(
  241. '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
  242. ),
  243. barRadius: Radius.circular(15),
  244. percent: item.finishedSize / item.totalSize,
  245. progressColor: MyTheme.accent,
  246. backgroundColor: Theme.of(context).hoverColor,
  247. lineHeight: kDesktopFileTransferRowHeight,
  248. ).paddingSymmetric(vertical: 8),
  249. ),
  250. ],
  251. ),
  252. ),
  253. Row(
  254. mainAxisAlignment: MainAxisAlignment.end,
  255. children: [
  256. Offstage(
  257. offstage: item.state != JobState.paused,
  258. child: MenuButton(
  259. tooltip: translate("Resume"),
  260. onPressed: () {
  261. jobController.resumeJob(item.id);
  262. },
  263. child: SvgPicture.asset(
  264. "assets/refresh.svg",
  265. colorFilter: svgColor(Colors.white),
  266. ),
  267. color: MyTheme.accent,
  268. hoverColor: MyTheme.accent80,
  269. ),
  270. ),
  271. MenuButton(
  272. tooltip: translate("Delete"),
  273. child: SvgPicture.asset(
  274. "assets/close.svg",
  275. colorFilter: svgColor(Colors.white),
  276. ),
  277. onPressed: () {
  278. jobController.jobTable.removeAt(index);
  279. jobController.cancelJob(item.id);
  280. },
  281. color: MyTheme.accent,
  282. hoverColor: MyTheme.accent80,
  283. ),
  284. ],
  285. ).marginAll(12),
  286. ],
  287. ),
  288. ],
  289. ),
  290. ),
  291. );
  292. },
  293. itemCount: jobController.jobTable.length,
  294. );
  295. return PreferredSize(
  296. preferredSize: const Size(200, double.infinity),
  297. child: Container(
  298. margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0),
  299. padding: const EdgeInsets.all(8.0),
  300. child: Obx(
  301. () => jobController.jobTable.isEmpty
  302. ? generateCard(
  303. Center(
  304. child: Column(
  305. mainAxisAlignment: MainAxisAlignment.center,
  306. children: [
  307. SvgPicture.asset(
  308. "assets/transfer.svg",
  309. colorFilter: svgColor(
  310. Theme.of(context).tabBarTheme.labelColor),
  311. height: 40,
  312. ).paddingOnly(bottom: 10),
  313. Text(
  314. translate("No transfers in progress"),
  315. textAlign: TextAlign.center,
  316. textScaler: TextScaler.linear(1.20),
  317. style: TextStyle(
  318. color:
  319. Theme.of(context).tabBarTheme.labelColor),
  320. ),
  321. ],
  322. ),
  323. ),
  324. )
  325. : statusListView(jobController.jobTable),
  326. )),
  327. );
  328. }
  329. void handleDragDone(DropDoneDetails details, bool isLocal) {
  330. if (isLocal) {
  331. // ignore local
  332. return;
  333. }
  334. final items = SelectedItems(isLocal: false);
  335. for (var file in details.files) {
  336. final f = File(file.path);
  337. items.add(Entry()
  338. ..path = file.path
  339. ..name = file.name
  340. ..size = FileSystemEntity.isDirectorySync(f.path) ? 0 : f.lengthSync());
  341. }
  342. final otherSideData = model.localController.directoryData();
  343. model.remoteController.sendFiles(items, otherSideData);
  344. }
  345. }
  346. class FileManagerView extends StatefulWidget {
  347. final FileController controller;
  348. final FFI _ffi;
  349. final Rx<MouseFocusScope> _mouseFocusScope;
  350. FileManagerView(this.controller, this._ffi, this._mouseFocusScope);
  351. @override
  352. State<StatefulWidget> createState() => _FileManagerViewState();
  353. }
  354. class _FileManagerViewState extends State<FileManagerView> {
  355. final _locationStatus = LocationStatus.bread.obs;
  356. final _locationNode = FocusNode();
  357. final _locationBarKey = GlobalKey();
  358. final _searchText = "".obs;
  359. final _breadCrumbScroller = ScrollController();
  360. final _keyboardNode = FocusNode();
  361. final _listSearchBuffer = TimeoutStringBuffer();
  362. final _nameColWidth = 0.0.obs;
  363. final _modifiedColWidth = 0.0.obs;
  364. final _sizeColWidth = 0.0.obs;
  365. final _fileListScrollController = ScrollController();
  366. final _globalHeaderKey = GlobalKey();
  367. /// [_lastClickTime], [_lastClickEntry] help to handle double click
  368. var _lastClickTime =
  369. DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000;
  370. Entry? _lastClickEntry;
  371. double? _windowWidthPrev;
  372. double _fileTransferMinimumWidth = 0.0;
  373. FileController get controller => widget.controller;
  374. bool get isLocal => widget.controller.isLocal;
  375. FFI get _ffi => widget._ffi;
  376. SelectedItems get selectedItems => controller.selectedItems;
  377. @override
  378. void initState() {
  379. super.initState();
  380. // register location listener
  381. _locationNode.addListener(onLocationFocusChanged);
  382. controller.directory.listen((e) => breadCrumbScrollToEnd());
  383. }
  384. @override
  385. void dispose() {
  386. _locationNode.removeListener(onLocationFocusChanged);
  387. _locationNode.dispose();
  388. _keyboardNode.dispose();
  389. _breadCrumbScroller.dispose();
  390. _fileListScrollController.dispose();
  391. super.dispose();
  392. }
  393. @override
  394. Widget build(BuildContext context) {
  395. _handleColumnPorportions();
  396. return Container(
  397. margin: const EdgeInsets.all(16.0),
  398. padding: const EdgeInsets.all(8.0),
  399. child: Column(
  400. crossAxisAlignment: CrossAxisAlignment.start,
  401. children: [
  402. headTools(),
  403. Expanded(
  404. child: Row(
  405. crossAxisAlignment: CrossAxisAlignment.start,
  406. children: [
  407. Expanded(
  408. child: MouseRegion(
  409. onEnter: (evt) {
  410. widget._mouseFocusScope.value = isLocal
  411. ? MouseFocusScope.local
  412. : MouseFocusScope.remote;
  413. _keyboardNode.requestFocus();
  414. },
  415. onExit: (evt) =>
  416. widget._mouseFocusScope.value = MouseFocusScope.none,
  417. child: _buildFileList(context, _fileListScrollController),
  418. ))
  419. ],
  420. ),
  421. ),
  422. ],
  423. ),
  424. );
  425. }
  426. void _handleColumnPorportions() {
  427. final windowWidthNow = MediaQuery.of(context).size.width;
  428. if (_windowWidthPrev == null) {
  429. _windowWidthPrev = windowWidthNow;
  430. final defaultColumnWidth = windowWidthNow * 0.115;
  431. _fileTransferMinimumWidth = defaultColumnWidth / 3;
  432. _nameColWidth.value = defaultColumnWidth;
  433. _modifiedColWidth.value = defaultColumnWidth;
  434. _sizeColWidth.value = defaultColumnWidth;
  435. }
  436. if (_windowWidthPrev != windowWidthNow) {
  437. final difference = windowWidthNow / _windowWidthPrev!;
  438. _windowWidthPrev = windowWidthNow;
  439. _fileTransferMinimumWidth *= difference;
  440. _nameColWidth.value *= difference;
  441. _modifiedColWidth.value *= difference;
  442. _sizeColWidth.value *= difference;
  443. }
  444. }
  445. void onLocationFocusChanged() {
  446. debugPrint("focus changed on local");
  447. if (_locationNode.hasFocus) {
  448. // ignore
  449. } else {
  450. // lost focus, change to bread
  451. if (_locationStatus.value != LocationStatus.fileSearchBar) {
  452. _locationStatus.value = LocationStatus.bread;
  453. }
  454. }
  455. }
  456. Widget headTools() {
  457. var uploadButtonTapPosition = RelativeRect.fill;
  458. RxBool isUploadFolder =
  459. (bind.mainGetLocalOption(key: 'upload-folder-button') == 'Y').obs;
  460. return Container(
  461. child: Column(
  462. children: [
  463. // symbols
  464. PreferredSize(
  465. child: Row(
  466. crossAxisAlignment: CrossAxisAlignment.center,
  467. children: [
  468. Container(
  469. width: 50,
  470. height: 50,
  471. decoration: BoxDecoration(
  472. borderRadius: BorderRadius.all(Radius.circular(8)),
  473. color: MyTheme.accent,
  474. ),
  475. padding: EdgeInsets.all(8.0),
  476. child: FutureBuilder<String>(
  477. future: bind.sessionGetPlatform(
  478. sessionId: _ffi.sessionId,
  479. isRemote: !isLocal),
  480. builder: (context, snapshot) {
  481. if (snapshot.hasData &&
  482. snapshot.data!.isNotEmpty) {
  483. return getPlatformImage('${snapshot.data}');
  484. } else {
  485. return CircularProgressIndicator(
  486. color: Theme.of(context)
  487. .tabBarTheme
  488. .labelColor,
  489. );
  490. }
  491. })),
  492. Text(isLocal
  493. ? translate("Local Computer")
  494. : translate("Remote Computer"))
  495. .marginOnly(left: 8.0)
  496. ],
  497. ),
  498. preferredSize: Size(double.infinity, 70))
  499. .paddingOnly(bottom: 15),
  500. // buttons
  501. Row(
  502. children: [
  503. Row(
  504. children: [
  505. MenuButton(
  506. tooltip: translate('Back'),
  507. padding: EdgeInsets.only(
  508. right: 3,
  509. ),
  510. child: RotatedBox(
  511. quarterTurns: 2,
  512. child: SvgPicture.asset(
  513. "assets/arrow.svg",
  514. colorFilter:
  515. svgColor(Theme.of(context).tabBarTheme.labelColor),
  516. ),
  517. ),
  518. color: Theme.of(context).cardColor,
  519. hoverColor: Theme.of(context).hoverColor,
  520. onPressed: () {
  521. selectedItems.clear();
  522. controller.goBack();
  523. },
  524. ),
  525. MenuButton(
  526. tooltip: translate('Parent directory'),
  527. child: RotatedBox(
  528. quarterTurns: 3,
  529. child: SvgPicture.asset(
  530. "assets/arrow.svg",
  531. colorFilter:
  532. svgColor(Theme.of(context).tabBarTheme.labelColor),
  533. ),
  534. ),
  535. color: Theme.of(context).cardColor,
  536. hoverColor: Theme.of(context).hoverColor,
  537. onPressed: () {
  538. selectedItems.clear();
  539. controller.goToParentDirectory();
  540. },
  541. ),
  542. ],
  543. ),
  544. Expanded(
  545. child: Padding(
  546. padding: const EdgeInsets.symmetric(horizontal: 3.0),
  547. child: Container(
  548. decoration: BoxDecoration(
  549. color: Theme.of(context).cardColor,
  550. borderRadius: BorderRadius.all(
  551. Radius.circular(8.0),
  552. ),
  553. ),
  554. child: Padding(
  555. padding: EdgeInsets.symmetric(vertical: 2.5),
  556. child: GestureDetector(
  557. onTap: () {
  558. _locationStatus.value =
  559. _locationStatus.value == LocationStatus.bread
  560. ? LocationStatus.pathLocation
  561. : LocationStatus.bread;
  562. Future.delayed(Duration.zero, () {
  563. if (_locationStatus.value ==
  564. LocationStatus.pathLocation) {
  565. _locationNode.requestFocus();
  566. }
  567. });
  568. },
  569. child: Obx(
  570. () => Container(
  571. child: Row(
  572. children: [
  573. Expanded(
  574. child: _locationStatus.value ==
  575. LocationStatus.bread
  576. ? buildBread()
  577. : buildPathLocation()),
  578. ],
  579. ),
  580. ),
  581. ),
  582. ),
  583. ),
  584. ),
  585. ),
  586. ),
  587. Obx(() {
  588. switch (_locationStatus.value) {
  589. case LocationStatus.bread:
  590. return MenuButton(
  591. tooltip: translate('Search'),
  592. onPressed: () {
  593. _locationStatus.value = LocationStatus.fileSearchBar;
  594. Future.delayed(
  595. Duration.zero, () => _locationNode.requestFocus());
  596. },
  597. child: SvgPicture.asset(
  598. "assets/search.svg",
  599. colorFilter:
  600. svgColor(Theme.of(context).tabBarTheme.labelColor),
  601. ),
  602. color: Theme.of(context).cardColor,
  603. hoverColor: Theme.of(context).hoverColor,
  604. );
  605. case LocationStatus.pathLocation:
  606. return MenuButton(
  607. onPressed: null,
  608. child: SvgPicture.asset(
  609. "assets/close.svg",
  610. colorFilter:
  611. svgColor(Theme.of(context).tabBarTheme.labelColor),
  612. ),
  613. color: Theme.of(context).disabledColor,
  614. hoverColor: Theme.of(context).hoverColor,
  615. );
  616. case LocationStatus.fileSearchBar:
  617. return MenuButton(
  618. tooltip: translate('Clear'),
  619. onPressed: () {
  620. onSearchText("", isLocal);
  621. _locationStatus.value = LocationStatus.bread;
  622. },
  623. child: SvgPicture.asset(
  624. "assets/close.svg",
  625. colorFilter:
  626. svgColor(Theme.of(context).tabBarTheme.labelColor),
  627. ),
  628. color: Theme.of(context).cardColor,
  629. hoverColor: Theme.of(context).hoverColor,
  630. );
  631. }
  632. }),
  633. MenuButton(
  634. tooltip: translate('Refresh File'),
  635. padding: EdgeInsets.only(
  636. left: 3,
  637. ),
  638. onPressed: () {
  639. controller.refresh();
  640. },
  641. child: SvgPicture.asset(
  642. "assets/refresh.svg",
  643. colorFilter:
  644. svgColor(Theme.of(context).tabBarTheme.labelColor),
  645. ),
  646. color: Theme.of(context).cardColor,
  647. hoverColor: Theme.of(context).hoverColor,
  648. ),
  649. ],
  650. ),
  651. Row(
  652. textDirection: isLocal ? TextDirection.ltr : TextDirection.rtl,
  653. children: [
  654. Expanded(
  655. child: Row(
  656. mainAxisAlignment:
  657. isLocal ? MainAxisAlignment.start : MainAxisAlignment.end,
  658. children: [
  659. MenuButton(
  660. tooltip: translate('Home'),
  661. padding: EdgeInsets.only(
  662. right: 3,
  663. ),
  664. onPressed: () {
  665. controller.goToHomeDirectory();
  666. },
  667. child: SvgPicture.asset(
  668. "assets/home.svg",
  669. colorFilter:
  670. svgColor(Theme.of(context).tabBarTheme.labelColor),
  671. ),
  672. color: Theme.of(context).cardColor,
  673. hoverColor: Theme.of(context).hoverColor,
  674. ),
  675. MenuButton(
  676. tooltip: translate('Create Folder'),
  677. onPressed: () {
  678. final name = TextEditingController();
  679. String? errorText;
  680. _ffi.dialogManager.show((setState, close, context) {
  681. name.addListener(() {
  682. if (errorText != null) {
  683. setState(() {
  684. errorText = null;
  685. });
  686. }
  687. });
  688. submit() {
  689. if (name.value.text.isNotEmpty) {
  690. if (!PathUtil.validName(name.value.text,
  691. controller.options.value.isWindows)) {
  692. setState(() {
  693. errorText = translate("Invalid folder name");
  694. });
  695. return;
  696. }
  697. controller.createDir(PathUtil.join(
  698. controller.directory.value.path,
  699. name.value.text,
  700. controller.options.value.isWindows,
  701. ));
  702. close();
  703. }
  704. }
  705. cancel() => close(false);
  706. return CustomAlertDialog(
  707. title: Row(
  708. mainAxisAlignment: MainAxisAlignment.center,
  709. children: [
  710. SvgPicture.asset("assets/folder_new.svg",
  711. colorFilter: svgColor(MyTheme.accent)),
  712. Text(
  713. translate("Create Folder"),
  714. ).paddingOnly(
  715. left: 10,
  716. ),
  717. ],
  718. ),
  719. content: Column(
  720. mainAxisSize: MainAxisSize.min,
  721. children: [
  722. TextFormField(
  723. decoration: InputDecoration(
  724. labelText: translate(
  725. "Please enter the folder name",
  726. ),
  727. errorText: errorText,
  728. ),
  729. controller: name,
  730. autofocus: true,
  731. ).workaroundFreezeLinuxMint(),
  732. ],
  733. ),
  734. actions: [
  735. dialogButton(
  736. "Cancel",
  737. icon: Icon(Icons.close_rounded),
  738. onPressed: cancel,
  739. isOutline: true,
  740. ),
  741. dialogButton(
  742. "Ok",
  743. icon: Icon(Icons.done_rounded),
  744. onPressed: submit,
  745. ),
  746. ],
  747. onSubmit: submit,
  748. onCancel: cancel,
  749. );
  750. });
  751. },
  752. child: SvgPicture.asset(
  753. "assets/folder_new.svg",
  754. colorFilter:
  755. svgColor(Theme.of(context).tabBarTheme.labelColor),
  756. ),
  757. color: Theme.of(context).cardColor,
  758. hoverColor: Theme.of(context).hoverColor,
  759. ),
  760. Obx(() => MenuButton(
  761. tooltip: translate('Delete'),
  762. onPressed: SelectedItems.valid(selectedItems.items)
  763. ? () async {
  764. await (controller
  765. .removeAction(selectedItems));
  766. selectedItems.clear();
  767. }
  768. : null,
  769. child: SvgPicture.asset(
  770. "assets/trash.svg",
  771. colorFilter: svgColor(
  772. Theme.of(context).tabBarTheme.labelColor),
  773. ),
  774. color: Theme.of(context).cardColor,
  775. hoverColor: Theme.of(context).hoverColor,
  776. )),
  777. menu(isLocal: isLocal),
  778. ],
  779. ),
  780. ),
  781. if (isWeb)
  782. Obx(() => ElevatedButton.icon(
  783. style: ButtonStyle(
  784. padding: MaterialStateProperty.all<EdgeInsetsGeometry>(
  785. isLocal
  786. ? EdgeInsets.only(left: 10)
  787. : EdgeInsets.only(right: 10)),
  788. backgroundColor: MaterialStateProperty.all(
  789. selectedItems.items.isEmpty
  790. ? MyTheme.accent80
  791. : MyTheme.accent,
  792. ),
  793. ),
  794. onPressed: () =>
  795. {webselectFiles(is_folder: isUploadFolder.value)},
  796. label: InkWell(
  797. hoverColor: Colors.transparent,
  798. splashColor: Colors.transparent,
  799. highlightColor: Colors.transparent,
  800. focusColor: Colors.transparent,
  801. onTapDown: (e) {
  802. final x = e.globalPosition.dx;
  803. final y = e.globalPosition.dy;
  804. uploadButtonTapPosition =
  805. RelativeRect.fromLTRB(x, y, x, y);
  806. },
  807. onTap: () async {
  808. final value = await showMenu<bool>(
  809. context: context,
  810. position: uploadButtonTapPosition,
  811. items: [
  812. PopupMenuItem<bool>(
  813. value: false,
  814. child: Text(translate('Upload files')),
  815. ),
  816. PopupMenuItem<bool>(
  817. value: true,
  818. child: Text(translate('Upload folder')),
  819. ),
  820. ]);
  821. if (value != null) {
  822. isUploadFolder.value = value;
  823. bind.mainSetLocalOption(
  824. key: 'upload-folder-button',
  825. value: value ? 'Y' : '');
  826. webselectFiles(is_folder: value);
  827. }
  828. },
  829. child: Icon(Icons.arrow_drop_down),
  830. ),
  831. icon: Text(
  832. translate(isUploadFolder.isTrue
  833. ? 'Upload folder'
  834. : 'Upload files'),
  835. textAlign: TextAlign.right,
  836. style: TextStyle(
  837. color: Colors.white,
  838. ),
  839. ).marginOnly(left: 8),
  840. )).marginOnly(left: 16),
  841. Obx(() => ElevatedButton.icon(
  842. style: ButtonStyle(
  843. padding: MaterialStateProperty.all<EdgeInsetsGeometry>(
  844. isLocal
  845. ? EdgeInsets.only(left: 10)
  846. : EdgeInsets.only(right: 10)),
  847. backgroundColor: MaterialStateProperty.all(
  848. selectedItems.items.isEmpty
  849. ? MyTheme.accent80
  850. : MyTheme.accent,
  851. ),
  852. ),
  853. onPressed: SelectedItems.valid(selectedItems.items)
  854. ? () {
  855. final otherSideData =
  856. controller.getOtherSideDirectoryData();
  857. controller.sendFiles(selectedItems, otherSideData);
  858. selectedItems.clear();
  859. }
  860. : null,
  861. icon: isLocal
  862. ? Text(
  863. translate('Send'),
  864. textAlign: TextAlign.right,
  865. style: TextStyle(
  866. color: selectedItems.items.isEmpty
  867. ? Theme.of(context).brightness ==
  868. Brightness.light
  869. ? MyTheme.grayBg
  870. : MyTheme.darkGray
  871. : Colors.white,
  872. ),
  873. )
  874. : isWeb
  875. ? Offstage()
  876. : RotatedBox(
  877. quarterTurns: 2,
  878. child: SvgPicture.asset(
  879. "assets/arrow.svg",
  880. colorFilter: svgColor(
  881. selectedItems.items.isEmpty
  882. ? Theme.of(context).brightness ==
  883. Brightness.light
  884. ? MyTheme.grayBg
  885. : MyTheme.darkGray
  886. : Colors.white),
  887. alignment: Alignment.bottomRight,
  888. ),
  889. ),
  890. label: isLocal
  891. ? SvgPicture.asset(
  892. "assets/arrow.svg",
  893. colorFilter: svgColor(selectedItems.items.isEmpty
  894. ? Theme.of(context).brightness ==
  895. Brightness.light
  896. ? MyTheme.grayBg
  897. : MyTheme.darkGray
  898. : Colors.white),
  899. )
  900. : Text(
  901. translate(isWeb ? 'Download' : 'Receive'),
  902. style: TextStyle(
  903. color: selectedItems.items.isEmpty
  904. ? Theme.of(context).brightness ==
  905. Brightness.light
  906. ? MyTheme.grayBg
  907. : MyTheme.darkGray
  908. : Colors.white,
  909. ),
  910. ),
  911. )),
  912. ],
  913. ).marginOnly(top: 8.0)
  914. ],
  915. ),
  916. );
  917. }
  918. Widget menu({bool isLocal = false}) {
  919. var menuPos = RelativeRect.fill;
  920. final List<MenuEntryBase<String>> items = [
  921. MenuEntrySwitch<String>(
  922. switchType: SwitchType.scheckbox,
  923. text: translate("Show Hidden Files"),
  924. getter: () async {
  925. return controller.options.value.showHidden;
  926. },
  927. setter: (bool v) async {
  928. controller.toggleShowHidden();
  929. },
  930. padding: kDesktopMenuPadding,
  931. dismissOnClicked: true,
  932. ),
  933. MenuEntryButton(
  934. childBuilder: (style) => Text(translate("Select All"), style: style),
  935. proc: () => setState(() =>
  936. selectedItems.selectAll(controller.directory.value.entries)),
  937. padding: kDesktopMenuPadding,
  938. dismissOnClicked: true),
  939. MenuEntryButton(
  940. childBuilder: (style) =>
  941. Text(translate("Unselect All"), style: style),
  942. proc: () => selectedItems.clear(),
  943. padding: kDesktopMenuPadding,
  944. dismissOnClicked: true)
  945. ];
  946. return Listener(
  947. onPointerDown: (e) {
  948. final x = e.position.dx;
  949. final y = e.position.dy;
  950. menuPos = RelativeRect.fromLTRB(x, y, x, y);
  951. },
  952. child: MenuButton(
  953. tooltip: translate('More'),
  954. onPressed: () => mod_menu.showMenu(
  955. context: context,
  956. position: menuPos,
  957. items: items
  958. .map(
  959. (e) => e.build(
  960. context,
  961. MenuConfig(
  962. commonColor: CustomPopupMenuTheme.commonColor,
  963. height: CustomPopupMenuTheme.height,
  964. dividerHeight: CustomPopupMenuTheme.dividerHeight),
  965. ),
  966. )
  967. .expand((i) => i)
  968. .toList(),
  969. elevation: 8,
  970. ),
  971. child: SvgPicture.asset(
  972. "assets/dots.svg",
  973. colorFilter: svgColor(Theme.of(context).tabBarTheme.labelColor),
  974. ),
  975. color: Theme.of(context).cardColor,
  976. hoverColor: Theme.of(context).hoverColor,
  977. ),
  978. );
  979. }
  980. Widget _buildFileList(
  981. BuildContext context, ScrollController scrollController) {
  982. final fd = controller.directory.value;
  983. final entries = fd.entries;
  984. Rx<Entry?> rightClickEntry = Rx(null);
  985. return ListSearchActionListener(
  986. node: _keyboardNode,
  987. buffer: _listSearchBuffer,
  988. onNext: (buffer) {
  989. debugPrint("searching next for $buffer");
  990. assert(buffer.length == 1);
  991. assert(selectedItems.items.length <= 1);
  992. var skipCount = 0;
  993. if (selectedItems.items.isNotEmpty) {
  994. final index = entries.indexOf(selectedItems.items.first);
  995. if (index < 0) {
  996. return;
  997. }
  998. skipCount = index + 1;
  999. }
  1000. var searchResult = entries
  1001. .skip(skipCount)
  1002. .where((element) => element.name.toLowerCase().startsWith(buffer));
  1003. if (searchResult.isEmpty) {
  1004. // cannot find next, lets restart search from head
  1005. debugPrint("restart search from head");
  1006. searchResult = entries.where(
  1007. (element) => element.name.toLowerCase().startsWith(buffer));
  1008. }
  1009. if (searchResult.isEmpty) {
  1010. selectedItems.clear();
  1011. return;
  1012. }
  1013. _jumpToEntry(isLocal, searchResult.first, scrollController,
  1014. kDesktopFileTransferRowHeight);
  1015. },
  1016. onSearch: (buffer) {
  1017. debugPrint("searching for $buffer");
  1018. final selectedEntries = selectedItems;
  1019. final searchResult = entries
  1020. .where((element) => element.name.toLowerCase().startsWith(buffer));
  1021. selectedEntries.clear();
  1022. if (searchResult.isEmpty) {
  1023. selectedItems.clear();
  1024. return;
  1025. }
  1026. _jumpToEntry(isLocal, searchResult.first, scrollController,
  1027. kDesktopFileTransferRowHeight);
  1028. },
  1029. child: Obx(() {
  1030. final entries = controller.directory.value.entries;
  1031. final filteredEntries = _searchText.isNotEmpty
  1032. ? entries.where((element) {
  1033. return element.name.contains(_searchText.value);
  1034. }).toList(growable: false)
  1035. : entries;
  1036. final rows = filteredEntries.map((entry) {
  1037. final sizeStr =
  1038. entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
  1039. final lastModifiedStr = entry.isDrive
  1040. ? " "
  1041. : "${entry.lastModified().toString().replaceAll(".000", "")} ";
  1042. var secondaryPosition = RelativeRect.fromLTRB(0, 0, 0, 0);
  1043. onTap() {
  1044. final items = selectedItems;
  1045. // handle double click
  1046. if (_checkDoubleClick(entry)) {
  1047. controller.openDirectory(entry.path);
  1048. items.clear();
  1049. return;
  1050. }
  1051. _onSelectedChanged(items, filteredEntries, entry, isLocal);
  1052. }
  1053. onSecondaryTap() {
  1054. final items = [
  1055. if (!entry.isDrive &&
  1056. versionCmp(_ffi.ffiModel.pi.version, "1.3.0") >= 0)
  1057. mod_menu.PopupMenuItem(
  1058. child: Text(translate("Rename")),
  1059. height: CustomPopupMenuTheme.height,
  1060. onTap: () {
  1061. controller.renameAction(entry, isLocal);
  1062. },
  1063. )
  1064. ];
  1065. if (items.isNotEmpty) {
  1066. rightClickEntry.value = entry;
  1067. final future = mod_menu.showMenu(
  1068. context: context,
  1069. position: secondaryPosition,
  1070. items: items,
  1071. );
  1072. future.then((value) {
  1073. rightClickEntry.value = null;
  1074. });
  1075. future.onError((error, stackTrace) {
  1076. rightClickEntry.value = null;
  1077. });
  1078. }
  1079. }
  1080. onSecondaryTapDown(details) {
  1081. secondaryPosition = RelativeRect.fromLTRB(
  1082. details.globalPosition.dx,
  1083. details.globalPosition.dy,
  1084. details.globalPosition.dx,
  1085. details.globalPosition.dy);
  1086. }
  1087. return Padding(
  1088. padding: EdgeInsets.symmetric(vertical: 1),
  1089. child: Obx(() => Container(
  1090. decoration: BoxDecoration(
  1091. color: selectedItems.items.contains(entry)
  1092. ? MyTheme.button
  1093. : Theme.of(context).cardColor,
  1094. borderRadius: BorderRadius.all(
  1095. Radius.circular(5.0),
  1096. ),
  1097. border: rightClickEntry.value == entry
  1098. ? Border.all(
  1099. color: MyTheme.button,
  1100. width: 1.0,
  1101. )
  1102. : null,
  1103. ),
  1104. key: ValueKey(entry.name),
  1105. height: kDesktopFileTransferRowHeight,
  1106. child: Column(
  1107. mainAxisAlignment: MainAxisAlignment.spaceAround,
  1108. children: [
  1109. Expanded(
  1110. child: InkWell(
  1111. child: Row(
  1112. children: [
  1113. GestureDetector(
  1114. child: Obx(
  1115. () => Container(
  1116. width: _nameColWidth.value,
  1117. child: Tooltip(
  1118. waitDuration: Duration(milliseconds: 500),
  1119. message: entry.name,
  1120. child: Row(children: [
  1121. entry.isDrive
  1122. ? Image(
  1123. image: iconHardDrive,
  1124. fit: BoxFit.scaleDown,
  1125. color: Theme.of(context)
  1126. .iconTheme
  1127. .color
  1128. ?.withOpacity(0.7))
  1129. .paddingAll(4)
  1130. : SvgPicture.asset(
  1131. entry.isFile
  1132. ? "assets/file.svg"
  1133. : "assets/folder.svg",
  1134. colorFilter: svgColor(
  1135. Theme.of(context)
  1136. .tabBarTheme
  1137. .labelColor),
  1138. ),
  1139. Expanded(
  1140. child: Text(entry.name.nonBreaking,
  1141. style: TextStyle(
  1142. color: selectedItems.items
  1143. .contains(entry)
  1144. ? Colors.white
  1145. : null),
  1146. overflow:
  1147. TextOverflow.ellipsis))
  1148. ]),
  1149. )),
  1150. ),
  1151. onTap: onTap,
  1152. onSecondaryTap: onSecondaryTap,
  1153. onSecondaryTapDown: onSecondaryTapDown,
  1154. ),
  1155. SizedBox(
  1156. width: 2.0,
  1157. ),
  1158. GestureDetector(
  1159. child: Obx(
  1160. () => SizedBox(
  1161. width: _modifiedColWidth.value,
  1162. child: Tooltip(
  1163. waitDuration: Duration(milliseconds: 500),
  1164. message: lastModifiedStr,
  1165. child: Text(
  1166. lastModifiedStr,
  1167. overflow: TextOverflow.ellipsis,
  1168. style: TextStyle(
  1169. fontSize: 12,
  1170. color: selectedItems.items
  1171. .contains(entry)
  1172. ? Colors.white70
  1173. : MyTheme.darkGray,
  1174. ),
  1175. )),
  1176. ),
  1177. ),
  1178. onTap: onTap,
  1179. onSecondaryTap: onSecondaryTap,
  1180. onSecondaryTapDown: onSecondaryTapDown,
  1181. ),
  1182. // Divider from header.
  1183. SizedBox(
  1184. width: 2.0,
  1185. ),
  1186. Expanded(
  1187. // width: 100,
  1188. child: GestureDetector(
  1189. child: Tooltip(
  1190. waitDuration: Duration(milliseconds: 500),
  1191. message: sizeStr,
  1192. child: Text(
  1193. sizeStr,
  1194. overflow: TextOverflow.ellipsis,
  1195. style: TextStyle(
  1196. fontSize: 10,
  1197. color:
  1198. selectedItems.items.contains(entry)
  1199. ? Colors.white70
  1200. : MyTheme.darkGray),
  1201. ),
  1202. ),
  1203. onTap: onTap,
  1204. onSecondaryTap: onSecondaryTap,
  1205. onSecondaryTapDown: onSecondaryTapDown,
  1206. ),
  1207. ),
  1208. ],
  1209. ),
  1210. ),
  1211. ),
  1212. ],
  1213. ))),
  1214. );
  1215. }).toList(growable: false);
  1216. return Column(
  1217. children: [
  1218. // Header
  1219. Row(
  1220. children: [
  1221. Expanded(child: _buildFileBrowserHeader(context)),
  1222. ],
  1223. ),
  1224. // Body
  1225. Expanded(
  1226. child: ListView.builder(
  1227. controller: scrollController,
  1228. itemExtent: kDesktopFileTransferRowHeight,
  1229. itemBuilder: (context, index) {
  1230. return rows[index];
  1231. },
  1232. itemCount: rows.length,
  1233. ),
  1234. ),
  1235. ],
  1236. );
  1237. }),
  1238. );
  1239. }
  1240. onSearchText(String searchText, bool isLocal) {
  1241. selectedItems.clear();
  1242. _searchText.value = searchText;
  1243. }
  1244. void _jumpToEntry(bool isLocal, Entry entry,
  1245. ScrollController scrollController, double rowHeight) {
  1246. final entries = controller.directory.value.entries;
  1247. final index = entries.indexOf(entry);
  1248. if (index == -1) {
  1249. debugPrint("entry is not valid: ${entry.path}");
  1250. }
  1251. final selectedEntries = selectedItems;
  1252. final searchResult = entries.where((element) => element == entry);
  1253. selectedEntries.clear();
  1254. if (searchResult.isEmpty) {
  1255. return;
  1256. }
  1257. final offset = min(
  1258. max(scrollController.position.minScrollExtent,
  1259. entries.indexOf(searchResult.first) * rowHeight),
  1260. scrollController.position.maxScrollExtent);
  1261. scrollController.jumpTo(offset);
  1262. selectedEntries.add(searchResult.first);
  1263. debugPrint("focused on ${searchResult.first.name}");
  1264. }
  1265. void _onSelectedChanged(SelectedItems selectedItems, List<Entry> entries,
  1266. Entry entry, bool isLocal) {
  1267. final isCtrlDown = RawKeyboard.instance.keysPressed
  1268. .contains(LogicalKeyboardKey.controlLeft) ||
  1269. RawKeyboard.instance.keysPressed
  1270. .contains(LogicalKeyboardKey.controlRight);
  1271. final isShiftDown = RawKeyboard.instance.keysPressed
  1272. .contains(LogicalKeyboardKey.shiftLeft) ||
  1273. RawKeyboard.instance.keysPressed
  1274. .contains(LogicalKeyboardKey.shiftRight);
  1275. if (isCtrlDown) {
  1276. if (selectedItems.items.contains(entry)) {
  1277. selectedItems.remove(entry);
  1278. } else {
  1279. selectedItems.add(entry);
  1280. }
  1281. } else if (isShiftDown) {
  1282. final List<int> indexGroup = [];
  1283. for (var selected in selectedItems.items) {
  1284. indexGroup.add(entries.indexOf(selected));
  1285. }
  1286. indexGroup.add(entries.indexOf(entry));
  1287. indexGroup.removeWhere((e) => e == -1);
  1288. final maxIndex = indexGroup.reduce(max);
  1289. final minIndex = indexGroup.reduce(min);
  1290. selectedItems.clear();
  1291. entries
  1292. .getRange(minIndex, maxIndex + 1)
  1293. .forEach((e) => selectedItems.add(e));
  1294. } else {
  1295. selectedItems.clear();
  1296. selectedItems.add(entry);
  1297. }
  1298. setState(() {});
  1299. }
  1300. bool _checkDoubleClick(Entry entry) {
  1301. final current = DateTime.now().millisecondsSinceEpoch;
  1302. final elapsed = current - _lastClickTime;
  1303. _lastClickTime = current;
  1304. if (_lastClickEntry == entry) {
  1305. if (elapsed < bind.getDoubleClickTime()) {
  1306. return true;
  1307. }
  1308. } else {
  1309. _lastClickEntry = entry;
  1310. }
  1311. return false;
  1312. }
  1313. void _onDrag(double dx, RxDouble column1, RxDouble column2) {
  1314. if (column1.value + dx <= _fileTransferMinimumWidth ||
  1315. column2.value - dx <= _fileTransferMinimumWidth) {
  1316. return;
  1317. }
  1318. column1.value += dx;
  1319. column2.value -= dx;
  1320. column1.value = max(_fileTransferMinimumWidth, column1.value);
  1321. column2.value = max(_fileTransferMinimumWidth, column2.value);
  1322. }
  1323. Widget _buildFileBrowserHeader(BuildContext context) {
  1324. final padding = EdgeInsets.all(1.0);
  1325. return SizedBox(
  1326. key: _globalHeaderKey,
  1327. height: kDesktopFileTransferHeaderHeight,
  1328. child: Row(
  1329. children: [
  1330. Obx(
  1331. () => headerItemFunc(
  1332. _nameColWidth.value, SortBy.name, translate("Name")),
  1333. ),
  1334. DraggableDivider(
  1335. axis: Axis.vertical,
  1336. onPointerMove: (dx) =>
  1337. _onDrag(dx, _nameColWidth, _modifiedColWidth),
  1338. padding: padding,
  1339. ),
  1340. Obx(
  1341. () => headerItemFunc(_modifiedColWidth.value, SortBy.modified,
  1342. translate("Modified")),
  1343. ),
  1344. DraggableDivider(
  1345. axis: Axis.vertical,
  1346. onPointerMove: (dx) =>
  1347. _onDrag(dx, _modifiedColWidth, _sizeColWidth),
  1348. padding: padding),
  1349. Expanded(
  1350. child: headerItemFunc(
  1351. _sizeColWidth.value, SortBy.size, translate("Size")))
  1352. ],
  1353. ),
  1354. );
  1355. }
  1356. Widget headerItemFunc(double? width, SortBy sortBy, String name) {
  1357. final headerTextStyle =
  1358. Theme.of(context).dataTableTheme.headingTextStyle ?? TextStyle();
  1359. return ObxValue<Rx<bool?>>(
  1360. (ascending) => InkWell(
  1361. onTap: () {
  1362. if (ascending.value == null) {
  1363. ascending.value = true;
  1364. } else {
  1365. ascending.value = !ascending.value!;
  1366. }
  1367. controller.changeSortStyle(sortBy,
  1368. isLocal: isLocal, ascending: ascending.value!);
  1369. },
  1370. child: SizedBox(
  1371. width: width,
  1372. height: kDesktopFileTransferHeaderHeight,
  1373. child: Row(
  1374. children: [
  1375. Expanded(
  1376. child: Text(
  1377. name,
  1378. style: headerTextStyle,
  1379. overflow: TextOverflow.ellipsis,
  1380. ).marginOnly(left: 4),
  1381. ),
  1382. ascending.value != null
  1383. ? Icon(
  1384. ascending.value!
  1385. ? Icons.keyboard_arrow_up_rounded
  1386. : Icons.keyboard_arrow_down_rounded,
  1387. )
  1388. : SizedBox()
  1389. ],
  1390. ),
  1391. ),
  1392. ), () {
  1393. if (controller.sortBy.value == sortBy) {
  1394. return controller.sortAscending.obs;
  1395. } else {
  1396. return Rx<bool?>(null);
  1397. }
  1398. }());
  1399. }
  1400. Widget buildBread() {
  1401. final items = getPathBreadCrumbItems(isLocal, (list) {
  1402. var path = "";
  1403. for (var item in list) {
  1404. path = PathUtil.join(path, item, controller.options.value.isWindows);
  1405. }
  1406. controller.openDirectory(path);
  1407. });
  1408. return items.isEmpty
  1409. ? Offstage()
  1410. : Row(
  1411. key: _locationBarKey,
  1412. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  1413. children: [
  1414. Expanded(
  1415. child: Listener(
  1416. // handle mouse wheel
  1417. onPointerSignal: (e) {
  1418. if (e is PointerScrollEvent) {
  1419. final sc = _breadCrumbScroller;
  1420. final scale = isWindows ? 2 : 4;
  1421. sc.jumpTo(sc.offset + e.scrollDelta.dy / scale);
  1422. }
  1423. },
  1424. child: BreadCrumb(
  1425. items: items,
  1426. divider: const Icon(Icons.keyboard_arrow_right_rounded),
  1427. overflow: ScrollableOverflow(
  1428. controller: _breadCrumbScroller,
  1429. ),
  1430. ),
  1431. ),
  1432. ),
  1433. ActionIcon(
  1434. message: "",
  1435. icon: Icons.keyboard_arrow_down_rounded,
  1436. onTap: () async {
  1437. final renderBox = _locationBarKey.currentContext
  1438. ?.findRenderObject() as RenderBox;
  1439. _locationBarKey.currentContext?.size;
  1440. final size = renderBox.size;
  1441. final offset = renderBox.localToGlobal(Offset.zero);
  1442. final x = offset.dx;
  1443. final y = offset.dy + size.height + 1;
  1444. final isPeerWindows = controller.options.value.isWindows;
  1445. final List<MenuEntryBase> menuItems = [
  1446. MenuEntryButton(
  1447. childBuilder: (TextStyle? style) => isPeerWindows
  1448. ? buildWindowsThisPC(context, style)
  1449. : Text(
  1450. '/',
  1451. style: style,
  1452. ),
  1453. proc: () {
  1454. controller.openDirectory('/');
  1455. },
  1456. dismissOnClicked: true),
  1457. MenuEntryDivider()
  1458. ];
  1459. if (isPeerWindows) {
  1460. var loadingTag = "";
  1461. if (!isLocal) {
  1462. loadingTag = _ffi.dialogManager.showLoading("Waiting");
  1463. }
  1464. try {
  1465. final showHidden = controller.options.value.showHidden;
  1466. final fd = await controller.fileFetcher
  1467. .fetchDirectory("/", isLocal, showHidden);
  1468. for (var entry in fd.entries) {
  1469. menuItems.add(MenuEntryButton(
  1470. childBuilder: (TextStyle? style) =>
  1471. Row(children: [
  1472. Image(
  1473. image: iconHardDrive,
  1474. fit: BoxFit.scaleDown,
  1475. color: Theme.of(context)
  1476. .iconTheme
  1477. .color
  1478. ?.withOpacity(0.7)),
  1479. SizedBox(width: 10),
  1480. Text(
  1481. entry.name,
  1482. style: style,
  1483. )
  1484. ]),
  1485. proc: () {
  1486. controller.openDirectory('${entry.name}\\');
  1487. },
  1488. dismissOnClicked: true));
  1489. }
  1490. menuItems.add(MenuEntryDivider());
  1491. } catch (e) {
  1492. debugPrint("buildBread fetchDirectory err=$e");
  1493. } finally {
  1494. if (!isLocal) {
  1495. _ffi.dialogManager.dismissByTag(loadingTag);
  1496. }
  1497. }
  1498. }
  1499. mod_menu.showMenu(
  1500. context: context,
  1501. position: RelativeRect.fromLTRB(x, y, x, y),
  1502. elevation: 4,
  1503. items: menuItems
  1504. .map((e) => e.build(
  1505. context,
  1506. MenuConfig(
  1507. commonColor:
  1508. CustomPopupMenuTheme.commonColor,
  1509. height: CustomPopupMenuTheme.height,
  1510. dividerHeight:
  1511. CustomPopupMenuTheme.dividerHeight,
  1512. boxWidth: size.width)))
  1513. .expand((i) => i)
  1514. .toList());
  1515. },
  1516. iconSize: 20,
  1517. )
  1518. ]);
  1519. }
  1520. List<BreadCrumbItem> getPathBreadCrumbItems(
  1521. bool isLocal, void Function(List<String>) onPressed) {
  1522. final path = controller.directory.value.path;
  1523. final breadCrumbList = List<BreadCrumbItem>.empty(growable: true);
  1524. final isWindows = controller.options.value.isWindows;
  1525. if (isWindows && path == '/') {
  1526. breadCrumbList.add(BreadCrumbItem(
  1527. content: TextButton(
  1528. child: buildWindowsThisPC(context),
  1529. style: ButtonStyle(
  1530. minimumSize: MaterialStateProperty.all(Size(0, 0))),
  1531. onPressed: () => onPressed(['/']))
  1532. .marginSymmetric(horizontal: 4)));
  1533. } else {
  1534. final list = PathUtil.split(path, isWindows);
  1535. breadCrumbList.addAll(
  1536. list.asMap().entries.map(
  1537. (e) => BreadCrumbItem(
  1538. content: TextButton(
  1539. child: Text(e.value),
  1540. style: ButtonStyle(
  1541. minimumSize: MaterialStateProperty.all(
  1542. Size(0, 0),
  1543. ),
  1544. ),
  1545. onPressed: () => onPressed(
  1546. list.sublist(0, e.key + 1),
  1547. ),
  1548. ).marginSymmetric(horizontal: 4),
  1549. ),
  1550. ),
  1551. );
  1552. }
  1553. return breadCrumbList;
  1554. }
  1555. breadCrumbScrollToEnd() {
  1556. Future.delayed(Duration(milliseconds: 200), () {
  1557. if (_breadCrumbScroller.hasClients) {
  1558. _breadCrumbScroller.animateTo(
  1559. _breadCrumbScroller.position.maxScrollExtent,
  1560. duration: Duration(milliseconds: 200),
  1561. curve: Curves.fastLinearToSlowEaseIn);
  1562. }
  1563. });
  1564. }
  1565. Widget buildPathLocation() {
  1566. final text = _locationStatus.value == LocationStatus.pathLocation
  1567. ? controller.directory.value.path
  1568. : _searchText.value;
  1569. final textController = TextEditingController(text: text)
  1570. ..selection = TextSelection.collapsed(offset: text.length);
  1571. return Row(
  1572. children: [
  1573. SvgPicture.asset(
  1574. _locationStatus.value == LocationStatus.pathLocation
  1575. ? "assets/folder.svg"
  1576. : "assets/search.svg",
  1577. colorFilter: svgColor(Theme.of(context).tabBarTheme.labelColor),
  1578. ),
  1579. Expanded(
  1580. child: TextField(
  1581. focusNode: _locationNode,
  1582. decoration: InputDecoration(
  1583. border: InputBorder.none,
  1584. isDense: true,
  1585. prefix: Padding(
  1586. padding: EdgeInsets.only(left: 4.0),
  1587. ),
  1588. ),
  1589. controller: textController,
  1590. onSubmitted: (path) {
  1591. controller.openDirectory(path);
  1592. },
  1593. onChanged: _locationStatus.value == LocationStatus.fileSearchBar
  1594. ? (searchText) => onSearchText(searchText, isLocal)
  1595. : null,
  1596. ).workaroundFreezeLinuxMint(),
  1597. )
  1598. ],
  1599. );
  1600. }
  1601. // openDirectory(String path, {bool isLocal = false}) {
  1602. // model.openDirectory(path, isLocal: isLocal);
  1603. // }
  1604. }
  1605. Widget buildWindowsThisPC(BuildContext context, [TextStyle? textStyle]) {
  1606. final color = Theme.of(context).iconTheme.color?.withOpacity(0.7);
  1607. return Row(children: [
  1608. Icon(Icons.computer, size: 20, color: color),
  1609. SizedBox(width: 10),
  1610. Text(translate('This PC'), style: textStyle)
  1611. ]);
  1612. }