diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index 8bb57145f..44def46c2 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -15,7 +15,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; -import 'package:provider/provider.dart'; import 'package:wakelock/wakelock.dart'; import '../../consts.dart'; @@ -61,52 +60,15 @@ class FileManagerPage extends StatefulWidget { class _FileManagerPageState extends State with AutomaticKeepAliveClientMixin { - final _localSelectedItems = SelectedItems(); - final _remoteSelectedItems = SelectedItems(); - - final _locationStatusLocal = LocationStatus.bread.obs; - final _locationStatusRemote = LocationStatus.bread.obs; - final _locationNodeLocal = FocusNode(debugLabel: "locationNodeLocal"); - final _locationNodeRemote = FocusNode(debugLabel: "locationNodeRemote"); - final _locationBarKeyLocal = GlobalKey(debugLabel: "locationBarKeyLocal"); - final _locationBarKeyRemote = GlobalKey(debugLabel: "locationBarKeyRemote"); - final _searchTextLocal = "".obs; - final _searchTextRemote = "".obs; - final _breadCrumbScrollerLocal = ScrollController(); - final _breadCrumbScrollerRemote = ScrollController(); final _mouseFocusScope = Rx(MouseFocusScope.none); - final _keyboardNodeLocal = FocusNode(debugLabel: "keyboardNodeLocal"); - final _keyboardNodeRemote = FocusNode(debugLabel: "keyboardNodeRemote"); - final _listSearchBufferLocal = TimeoutStringBuffer(); - final _listSearchBufferRemote = TimeoutStringBuffer(); - final _nameColWidthLocal = kDesktopFileTransferNameColWidth.obs; - final _modifiedColWidthLocal = kDesktopFileTransferModifiedColWidth.obs; - final _nameColWidthRemote = kDesktopFileTransferNameColWidth.obs; - final _modifiedColWidthRemote = kDesktopFileTransferModifiedColWidth.obs; - - /// [_lastClickTime], [_lastClickEntry] help to handle double click - int _lastClickTime = - DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000; - Entry? _lastClickEntry; final _dropMaskVisible = false.obs; // TODO impl drop mask final _overlayKeyState = OverlayKeyState(); - ScrollController getBreadCrumbScrollController(bool isLocal) { - return isLocal ? _breadCrumbScrollerLocal : _breadCrumbScrollerRemote; - } - - GlobalKey getLocationBarKey(bool isLocal) { - return isLocal ? _locationBarKeyLocal : _locationBarKeyRemote; - } - late FFI _ffi; FileModel get model => _ffi.fileModel; - - SelectedItems getSelectedItems(bool isLocal) { - return isLocal ? _localSelectedItems : _remoteSelectedItems; - } + JobController get jobController => model.jobController; @override void initState() { @@ -122,448 +84,61 @@ class _FileManagerPageState extends State Wakelock.enable(); } debugPrint("File manager page init success with id ${widget.id}"); - model.onDirChanged = breadCrumbScrollToEnd; - // register location listener - _locationNodeLocal.addListener(onLocalLocationFocusChanged); - _locationNodeRemote.addListener(onRemoteLocationFocusChanged); _ffi.dialogManager.setOverlayState(_overlayKeyState); } @override void dispose() { - model.onClose().whenComplete(() { + model.close().whenComplete(() { _ffi.close(); _ffi.dialogManager.dismissAll(); if (!Platform.isLinux) { Wakelock.disable(); } Get.delete(tag: 'ft_${widget.id}'); - _locationNodeLocal.removeListener(onLocalLocationFocusChanged); - _locationNodeRemote.removeListener(onRemoteLocationFocusChanged); - _locationNodeLocal.dispose(); - _locationNodeRemote.dispose(); }); super.dispose(); } + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { super.build(context); return Overlay(key: _overlayKeyState.key, initialEntries: [ OverlayEntry(builder: (_) { - return ChangeNotifierProvider.value( - value: _ffi.fileModel, - child: Consumer(builder: (context, model, child) { - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - body: Row( - children: [ - Flexible(flex: 3, child: body(isLocal: true)), - Flexible(flex: 3, child: body(isLocal: false)), - Flexible(flex: 2, child: statusList()) - ], - ), - ); - })); + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Row( + children: [ + Flexible( + flex: 3, + child: dropArea(FileManagerView( + model.localController, _ffi, _mouseFocusScope))), + Flexible( + flex: 3, + child: dropArea(FileManagerView( + model.remoteController, _ffi, _mouseFocusScope))), + Flexible(flex: 2, child: statusList()) + ], + ), + ); }) ]); } - Widget menu({bool isLocal = false}) { - var menuPos = RelativeRect.fill; - - final List> items = [ - MenuEntrySwitch( - switchType: SwitchType.scheckbox, - text: translate("Show Hidden Files"), - getter: () async { - return model.getCurrentShowHidden(isLocal); - }, - setter: (bool v) async { - model.toggleShowHidden(local: isLocal); - }, - padding: kDesktopMenuPadding, - dismissOnClicked: true, - ), - MenuEntryButton( - childBuilder: (style) => Text(translate("Select All"), style: style), - proc: () => setState(() => getSelectedItems(isLocal) - .selectAll(model.getCurrentDir(isLocal).entries)), - padding: kDesktopMenuPadding, - dismissOnClicked: true), - MenuEntryButton( - childBuilder: (style) => - Text(translate("Unselect All"), style: style), - proc: () => setState(() => getSelectedItems(isLocal).clear()), - padding: kDesktopMenuPadding, - dismissOnClicked: true) - ]; - - return Listener( - onPointerDown: (e) { - final x = e.position.dx; - final y = e.position.dy; - menuPos = RelativeRect.fromLTRB(x, y, x, y); - }, - child: MenuButton( - onPressed: () => mod_menu.showMenu( - context: context, - position: menuPos, - items: items - .map( - (e) => e.build( - context, - MenuConfig( - commonColor: CustomPopupMenuTheme.commonColor, - height: CustomPopupMenuTheme.height, - dividerHeight: CustomPopupMenuTheme.dividerHeight), - ), - ) - .expand((i) => i) - .toList(), - elevation: 8, - ), - child: SvgPicture.asset( - "assets/dots.svg", - color: Theme.of(context).tabBarTheme.labelColor, - ), - color: Theme.of(context).cardColor, - hoverColor: Theme.of(context).hoverColor, - ), - ); - } - - Widget body({bool isLocal = false}) { - final scrollController = ScrollController(); - return Container( - margin: const EdgeInsets.all(16.0), - padding: const EdgeInsets.all(8.0), - child: DropTarget( - onDragDone: (detail) => handleDragDone(detail, isLocal), + Widget dropArea(FileManagerView fileView) { + return DropTarget( + onDragDone: (detail) => + handleDragDone(detail, fileView.controller.isLocal), onDragEntered: (enter) { _dropMaskVisible.value = true; }, onDragExited: (exit) { _dropMaskVisible.value = false; }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - headTools(isLocal), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _buildFileList(context, isLocal, scrollController), - ) - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildFileList( - BuildContext context, bool isLocal, ScrollController scrollController) { - final fd = model.getCurrentDir(isLocal); - final entries = fd.entries; - final selectedEntries = getSelectedItems(isLocal); - - return MouseRegion( - onEnter: (evt) { - _mouseFocusScope.value = - isLocal ? MouseFocusScope.local : MouseFocusScope.remote; - if (isLocal) { - _keyboardNodeLocal.requestFocus(); - } else { - _keyboardNodeRemote.requestFocus(); - } - }, - onExit: (evt) { - _mouseFocusScope.value = MouseFocusScope.none; - }, - child: ListSearchActionListener( - node: isLocal ? _keyboardNodeLocal : _keyboardNodeRemote, - buffer: isLocal ? _listSearchBufferLocal : _listSearchBufferRemote, - onNext: (buffer) { - debugPrint("searching next for $buffer"); - assert(buffer.length == 1); - assert(selectedEntries.length <= 1); - var skipCount = 0; - if (selectedEntries.items.isNotEmpty) { - final index = entries.indexOf(selectedEntries.items.first); - if (index < 0) { - return; - } - skipCount = index + 1; - } - var searchResult = entries.skip(skipCount).where( - (element) => element.name.toLowerCase().startsWith(buffer)); - if (searchResult.isEmpty) { - // cannot find next, lets restart search from head - debugPrint("restart search from head"); - searchResult = entries.where( - (element) => element.name.toLowerCase().startsWith(buffer)); - } - if (searchResult.isEmpty) { - setState(() { - getSelectedItems(isLocal).clear(); - }); - return; - } - _jumpToEntry(isLocal, searchResult.first, scrollController, - kDesktopFileTransferRowHeight); - }, - onSearch: (buffer) { - debugPrint("searching for $buffer"); - final selectedEntries = getSelectedItems(isLocal); - final searchResult = entries.where( - (element) => element.name.toLowerCase().startsWith(buffer)); - selectedEntries.clear(); - if (searchResult.isEmpty) { - setState(() { - getSelectedItems(isLocal).clear(); - }); - return; - } - _jumpToEntry(isLocal, searchResult.first, scrollController, - kDesktopFileTransferRowHeight); - }, - child: ObxValue( - (searchText) { - final filteredEntries = searchText.isNotEmpty - ? entries.where((element) { - return element.name.contains(searchText.value); - }).toList(growable: false) - : entries; - final rows = filteredEntries.map((entry) { - final sizeStr = - entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; - final lastModifiedStr = entry.isDrive - ? " " - : "${entry.lastModified().toString().replaceAll(".000", "")} "; - final isSelected = selectedEntries.contains(entry); - return Padding( - padding: EdgeInsets.symmetric(vertical: 1), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).hoverColor - : Theme.of(context).cardColor, - borderRadius: BorderRadius.all( - Radius.circular(5.0), - ), - ), - key: ValueKey(entry.name), - height: kDesktopFileTransferRowHeight, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded( - child: InkWell( - child: Row( - children: [ - GestureDetector( - child: Obx( - () => Container( - width: isLocal - ? _nameColWidthLocal.value - : _nameColWidthRemote.value, - child: Tooltip( - waitDuration: - Duration(milliseconds: 500), - message: entry.name, - child: Row(children: [ - entry.isDrive - ? Image( - image: iconHardDrive, - fit: BoxFit.scaleDown, - color: Theme.of(context) - .iconTheme - .color - ?.withOpacity(0.7)) - .paddingAll(4) - : SvgPicture.asset( - entry.isFile - ? "assets/file.svg" - : "assets/folder.svg", - color: Theme.of(context) - .tabBarTheme - .labelColor, - ), - Expanded( - child: Text( - entry.name.nonBreaking, - overflow: - TextOverflow.ellipsis)) - ]), - )), - ), - onTap: () { - final items = getSelectedItems(isLocal); - // handle double click - if (_checkDoubleClick(entry)) { - openDirectory(entry.path, - isLocal: isLocal); - items.clear(); - return; - } - _onSelectedChanged( - items, filteredEntries, entry, isLocal); - }, - ), - SizedBox( - width: 2.0, - ), - GestureDetector( - child: Obx( - () => SizedBox( - width: isLocal - ? _modifiedColWidthLocal.value - : _modifiedColWidthRemote.value, - child: Tooltip( - waitDuration: - Duration(milliseconds: 500), - message: lastModifiedStr, - child: Text( - lastModifiedStr, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: MyTheme.darkGray, - ), - )), - ), - ), - ), - // Divider from header. - SizedBox( - width: 2.0, - ), - Expanded( - // width: 100, - child: GestureDetector( - child: Tooltip( - waitDuration: Duration(milliseconds: 500), - message: sizeStr, - child: Text( - sizeStr, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - color: MyTheme.darkGray), - ), - ), - ), - ), - ], - ), - ), - ), - ], - )), - ); - }).toList(growable: false); - - return Column( - children: [ - // Header - Row( - children: [ - Expanded(child: _buildFileBrowserHeader(context, isLocal)), - ], - ), - // Body - Expanded( - child: ListView.builder( - controller: scrollController, - itemExtent: kDesktopFileTransferRowHeight, - itemBuilder: (context, index) { - return rows[index]; - }, - itemCount: rows.length, - ), - ), - ], - ); - }, - isLocal ? _searchTextLocal : _searchTextRemote, - ), - ), - ); - } - - void _jumpToEntry(bool isLocal, Entry entry, - ScrollController scrollController, double rowHeight) { - final entries = model.getCurrentDir(isLocal).entries; - final index = entries.indexOf(entry); - if (index == -1) { - debugPrint("entry is not valid: ${entry.path}"); - } - final selectedEntries = getSelectedItems(isLocal); - final searchResult = entries.where((element) => element == entry); - selectedEntries.clear(); - if (searchResult.isEmpty) { - return; - } - final offset = min( - max(scrollController.position.minScrollExtent, - entries.indexOf(searchResult.first) * rowHeight), - scrollController.position.maxScrollExtent); - scrollController.jumpTo(offset); - setState(() { - selectedEntries.add(isLocal, searchResult.first); - debugPrint("focused on ${searchResult.first.name}"); - }); - } - - void _onSelectedChanged(SelectedItems selectedItems, List entries, - Entry entry, bool isLocal) { - final isCtrlDown = RawKeyboard.instance.keysPressed - .contains(LogicalKeyboardKey.controlLeft); - final isShiftDown = - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft); - if (isCtrlDown) { - if (selectedItems.contains(entry)) { - selectedItems.remove(entry); - } else { - selectedItems.add(isLocal, entry); - } - } else if (isShiftDown) { - final List indexGroup = []; - for (var selected in selectedItems.items) { - indexGroup.add(entries.indexOf(selected)); - } - indexGroup.add(entries.indexOf(entry)); - indexGroup.removeWhere((e) => e == -1); - final maxIndex = indexGroup.reduce(max); - final minIndex = indexGroup.reduce(min); - selectedItems.clear(); - entries - .getRange(minIndex, maxIndex + 1) - .forEach((e) => selectedItems.add(isLocal, e)); - } else { - selectedItems.clear(); - selectedItems.add(isLocal, entry); - } - setState(() {}); - } - - bool _checkDoubleClick(Entry entry) { - final current = DateTime.now().millisecondsSinceEpoch; - final elapsed = current - _lastClickTime; - _lastClickTime = current; - if (_lastClickEntry == entry) { - if (elapsed < bind.getDoubleClickTime()) { - return true; - } - } else { - _lastClickEntry = entry; - } - return false; + child: fileView); } Widget generateCard(Widget child) { @@ -581,179 +156,281 @@ class _FileManagerPageState extends State /// transfer status list /// watch transfer status Widget statusList() { + statusListView(List jobs) => ListView.builder( + controller: ScrollController(), + itemBuilder: (BuildContext context, int index) { + final item = jobs[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: generateCard( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Transform.rotate( + angle: item.isRemoteToLocal ? pi : 0, + child: SvgPicture.asset( + "assets/arrow.svg", + color: Theme.of(context).tabBarTheme.labelColor, + ), + ).paddingOnly(left: 15), + const SizedBox( + width: 16.0, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + waitDuration: Duration(milliseconds: 500), + message: item.jobName, + child: Text( + item.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).paddingSymmetric(vertical: 10), + ), + Text( + '${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}', + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + Offstage( + offstage: item.state != JobState.inProgress, + child: Text( + '${translate("Speed")} ${readableFileSize(item.speed)}/s', + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + ), + Offstage( + offstage: item.state == JobState.inProgress, + child: Text( + translate( + item.display(), + ), + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + ), + Offstage( + offstage: item.state != JobState.inProgress, + child: LinearPercentIndicator( + padding: EdgeInsets.only(right: 15), + animateFromLastPercent: true, + center: Text( + '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', + ), + barRadius: Radius.circular(15), + percent: item.finishedSize / item.totalSize, + progressColor: MyTheme.accent, + backgroundColor: Theme.of(context).hoverColor, + lineHeight: kDesktopFileTransferRowHeight, + ).paddingSymmetric(vertical: 15), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Offstage( + offstage: item.state != JobState.paused, + child: MenuButton( + onPressed: () { + jobController.resumeJob(item.id); + }, + child: SvgPicture.asset( + "assets/refresh.svg", + color: Colors.white, + ), + color: MyTheme.accent, + hoverColor: MyTheme.accent80, + ), + ), + MenuButton( + padding: EdgeInsets.only(right: 15), + child: SvgPicture.asset( + "assets/close.svg", + color: Colors.white, + ), + onPressed: () { + jobController.jobTable.removeAt(index); + jobController.cancelJob(item.id); + }, + color: MyTheme.accent, + hoverColor: MyTheme.accent80, + ), + ], + ), + ], + ), + ], + ).paddingSymmetric(vertical: 10), + ), + ); + }, + itemCount: jobController.jobTable.length, + ); + return PreferredSize( preferredSize: const Size(200, double.infinity), child: Container( - margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0), - padding: const EdgeInsets.all(8.0), - child: model.jobTable.isEmpty - ? generateCard( - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - "assets/transfer.svg", - color: Theme.of(context).tabBarTheme.labelColor, - height: 40, - ).paddingOnly(bottom: 10), - Text( - translate("No transfers in progress"), - textAlign: TextAlign.center, - textScaleFactor: 1.20, - style: TextStyle( - color: Theme.of(context).tabBarTheme.labelColor), + margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0), + padding: const EdgeInsets.all(8.0), + child: Obx( + () => jobController.jobTable.isEmpty + ? generateCard( + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + "assets/transfer.svg", + color: Theme.of(context).tabBarTheme.labelColor, + height: 40, + ).paddingOnly(bottom: 10), + Text( + translate("No transfers in progress"), + textAlign: TextAlign.center, + textScaleFactor: 1.20, + style: TextStyle( + color: + Theme.of(context).tabBarTheme.labelColor), + ), + ], ), - ], - ), - ), - ) - : Obx( - () => ListView.builder( - controller: ScrollController(), - itemBuilder: (BuildContext context, int index) { - final item = model.jobTable[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 5), - child: generateCard( - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Transform.rotate( - angle: item.isRemote ? pi : 0, - child: SvgPicture.asset( - "assets/arrow.svg", - color: Theme.of(context) - .tabBarTheme - .labelColor, - ), - ).paddingOnly(left: 15), - const SizedBox( - width: 16.0, - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Tooltip( - waitDuration: - Duration(milliseconds: 500), - message: item.jobName, - child: Text( - item.fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).paddingSymmetric(vertical: 10), - ), - Text( - '${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}', - style: TextStyle( - fontSize: 12, - color: MyTheme.darkGray, - ), - ), - Offstage( - offstage: - item.state != JobState.inProgress, - child: Text( - '${translate("Speed")} ${readableFileSize(item.speed)}/s', - style: TextStyle( - fontSize: 12, - color: MyTheme.darkGray, - ), - ), - ), - Offstage( - offstage: - item.state == JobState.inProgress, - child: Text( - translate( - item.display(), - ), - style: TextStyle( - fontSize: 12, - color: MyTheme.darkGray, - ), - ), - ), - Offstage( - offstage: - item.state != JobState.inProgress, - child: LinearPercentIndicator( - padding: EdgeInsets.only(right: 15), - animateFromLastPercent: true, - center: Text( - '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', - ), - barRadius: Radius.circular(15), - percent: item.finishedSize / - item.totalSize, - progressColor: MyTheme.accent, - backgroundColor: - Theme.of(context).hoverColor, - lineHeight: - kDesktopFileTransferRowHeight, - ).paddingSymmetric(vertical: 15), - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Offstage( - offstage: item.state != JobState.paused, - child: MenuButton( - onPressed: () { - model.resumeJob(item.id); - }, - child: SvgPicture.asset( - "assets/refresh.svg", - color: Colors.white, - ), - color: MyTheme.accent, - hoverColor: MyTheme.accent80, - ), - ), - MenuButton( - padding: EdgeInsets.only(right: 15), - child: SvgPicture.asset( - "assets/close.svg", - color: Colors.white, - ), - onPressed: () { - model.jobTable.removeAt(index); - model.cancelJob(item.id); - }, - color: MyTheme.accent, - hoverColor: MyTheme.accent80, - ), - ], - ), - ], - ), - ], - ).paddingSymmetric(vertical: 10), - ), - ); + ), + ) + : statusListView(jobController.jobTable), + )), + ); + } + + void handleDragDone(DropDoneDetails details, bool isLocal) { + if (isLocal) { + // ignore local + return; + } + final items = SelectedItems(isLocal: false); + for (var file in details.files) { + final f = File(file.path); + items.add(Entry() + ..path = file.path + ..name = file.name + ..size = FileSystemEntity.isDirectorySync(f.path) ? 0 : f.lengthSync()); + } + final otherSideData = model.localController.directoryData(); + model.remoteController.sendFiles(items, otherSideData); + } +} + +class FileManagerView extends StatefulWidget { + final FileController controller; + final FFI _ffi; + final Rx _mouseFocusScope; + + FileManagerView(this.controller, this._ffi, this._mouseFocusScope); + + @override + State createState() => _FileManagerViewState(); +} + +class _FileManagerViewState extends State { + final _locationStatus = LocationStatus.bread.obs; + final _locationNode = FocusNode(); + final _locationBarKey = GlobalKey(); + final _searchText = "".obs; + final _breadCrumbScroller = ScrollController(); + final _keyboardNode = FocusNode(); + final _listSearchBuffer = TimeoutStringBuffer(); + final _nameColWidth = kDesktopFileTransferNameColWidth.obs; + final _modifiedColWidth = kDesktopFileTransferModifiedColWidth.obs; + final _fileListScrollController = ScrollController(); + + /// [_lastClickTime], [_lastClickEntry] help to handle double click + var _lastClickTime = + DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000; + Entry? _lastClickEntry; + + FileController get controller => widget.controller; + bool get isLocal => widget.controller.isLocal; + FFI get _ffi => widget._ffi; + SelectedItems get selectedItems => controller.selectedItems; + + @override + void initState() { + super.initState(); + // register location listener + _locationNode.addListener(onLocationFocusChanged); + controller.directory.listen((e) => breadCrumbScrollToEnd()); + } + + @override + void dispose() { + _locationNode.removeListener(onLocationFocusChanged); + _locationNode.dispose(); + _keyboardNode.dispose(); + _breadCrumbScroller.dispose(); + _fileListScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + headTools(), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: MouseRegion( + onEnter: (evt) { + widget._mouseFocusScope.value = isLocal + ? MouseFocusScope.local + : MouseFocusScope.remote; + _keyboardNode.requestFocus(); }, - itemCount: model.jobTable.length, - ), - ), + onExit: (evt) => + widget._mouseFocusScope.value = MouseFocusScope.none, + child: _buildFileList(context, _fileListScrollController), + )) + ], + ), + ), + ], ), ); } - Widget headTools(bool isLocal) { - final locationStatus = - isLocal ? _locationStatusLocal : _locationStatusRemote; - final locationFocus = isLocal ? _locationNodeLocal : _locationNodeRemote; - final selectedItems = getSelectedItems(isLocal); + void onLocationFocusChanged() { + debugPrint("focus changed on local"); + if (_locationNode.hasFocus) { + // ignore + } else { + // lost focus, change to bread + if (_locationStatus.value != LocationStatus.fileSearchBar) { + _locationStatus.value = LocationStatus.bread; + } + } + } + + Widget headTools() { return Container( child: Column( children: [ @@ -813,7 +490,7 @@ class _FileManagerPageState extends State hoverColor: Theme.of(context).hoverColor, onPressed: () { selectedItems.clear(); - model.goBack(isLocal: isLocal); + controller.goBack(); }, ), MenuButton( @@ -828,7 +505,7 @@ class _FileManagerPageState extends State hoverColor: Theme.of(context).hoverColor, onPressed: () { selectedItems.clear(); - model.goToParentDirectory(isLocal: isLocal); + controller.goToParentDirectory(); }, ), ], @@ -847,14 +524,14 @@ class _FileManagerPageState extends State padding: EdgeInsets.symmetric(vertical: 2.5), child: GestureDetector( onTap: () { - locationStatus.value = - locationStatus.value == LocationStatus.bread + _locationStatus.value = + _locationStatus.value == LocationStatus.bread ? LocationStatus.pathLocation : LocationStatus.bread; Future.delayed(Duration.zero, () { - if (locationStatus.value == + if (_locationStatus.value == LocationStatus.pathLocation) { - locationFocus.requestFocus(); + _locationNode.requestFocus(); } }); }, @@ -863,10 +540,10 @@ class _FileManagerPageState extends State child: Row( children: [ Expanded( - child: locationStatus.value == + child: _locationStatus.value == LocationStatus.bread - ? buildBread(isLocal) - : buildPathLocation(isLocal)), + ? buildBread() + : buildPathLocation()), ], ), ), @@ -877,15 +554,13 @@ class _FileManagerPageState extends State ), ), Obx(() { - switch (locationStatus.value) { + switch (_locationStatus.value) { case LocationStatus.bread: return MenuButton( onPressed: () { - locationStatus.value = LocationStatus.fileSearchBar; - final focusNode = - isLocal ? _locationNodeLocal : _locationNodeRemote; + _locationStatus.value = LocationStatus.fileSearchBar; Future.delayed( - Duration.zero, () => focusNode.requestFocus()); + Duration.zero, () => _locationNode.requestFocus()); }, child: SvgPicture.asset( "assets/search.svg", @@ -908,7 +583,7 @@ class _FileManagerPageState extends State return MenuButton( onPressed: () { onSearchText("", isLocal); - locationStatus.value = LocationStatus.bread; + _locationStatus.value = LocationStatus.bread; }, child: SvgPicture.asset( "assets/close.svg", @@ -924,7 +599,7 @@ class _FileManagerPageState extends State left: 3, ), onPressed: () { - model.refresh(isLocal: isLocal); + controller.refresh(); }, child: SvgPicture.asset( "assets/refresh.svg", @@ -948,7 +623,7 @@ class _FileManagerPageState extends State right: 3, ), onPressed: () { - model.goHome(isLocal: isLocal); + controller.goToHomeDirectory(); }, child: SvgPicture.asset( "assets/home.svg", @@ -963,12 +638,11 @@ class _FileManagerPageState extends State _ffi.dialogManager.show((setState, close) { submit() { if (name.value.text.isNotEmpty) { - model.createDir( - PathUtil.join( - model.getCurrentDir(isLocal).path, - name.value.text, - model.getCurrentIsWindows(isLocal)), - isLocal: isLocal); + controller.createDir(PathUtil.join( + controller.directory.value.path, + name.value.text, + controller.options.value.isWindows, + )); close(); } } @@ -1026,86 +700,93 @@ class _FileManagerPageState extends State color: Theme.of(context).cardColor, hoverColor: Theme.of(context).hoverColor, ), - MenuButton( - onPressed: validItems(selectedItems) - ? () async { - await (model.removeAction(selectedItems, - isLocal: isLocal)); - selectedItems.clear(); - } - : null, - child: SvgPicture.asset( - "assets/trash.svg", - color: Theme.of(context).tabBarTheme.labelColor, - ), - color: Theme.of(context).cardColor, - hoverColor: Theme.of(context).hoverColor, - ), + Obx(() => MenuButton( + onPressed: SelectedItems.valid(selectedItems.items) + ? () async { + await (controller + .removeAction(selectedItems)); + selectedItems.clear(); + } + : null, + child: SvgPicture.asset( + "assets/trash.svg", + color: Theme.of(context).tabBarTheme.labelColor, + ), + color: Theme.of(context).cardColor, + hoverColor: Theme.of(context).hoverColor, + )), menu(isLocal: isLocal), ], ), ), - ElevatedButton.icon( - style: ButtonStyle( - padding: MaterialStateProperty.all(isLocal - ? EdgeInsets.only(left: 10) - : EdgeInsets.only(right: 10)), - backgroundColor: MaterialStateProperty.all( - selectedItems.length == 0 - ? MyTheme.accent80 - : MyTheme.accent, - ), - ), - onPressed: validItems(selectedItems) - ? () { - model.sendFiles(selectedItems, isRemote: !isLocal); - selectedItems.clear(); - } - : null, - icon: isLocal - ? Text( - translate('Send'), - textAlign: TextAlign.right, - style: TextStyle( - color: selectedItems.length == 0 - ? Theme.of(context).brightness == Brightness.light - ? MyTheme.grayBg - : MyTheme.darkGray - : Colors.white, - ), - ) - : RotatedBox( - quarterTurns: 2, - child: SvgPicture.asset( - "assets/arrow.svg", - color: selectedItems.length == 0 - ? Theme.of(context).brightness == Brightness.light - ? MyTheme.grayBg - : MyTheme.darkGray - : Colors.white, - alignment: Alignment.bottomRight, - ), + Obx(() => ElevatedButton.icon( + style: ButtonStyle( + padding: MaterialStateProperty.all( + isLocal + ? EdgeInsets.only(left: 10) + : EdgeInsets.only(right: 10)), + backgroundColor: MaterialStateProperty.all( + selectedItems.items.isEmpty + ? MyTheme.accent80 + : MyTheme.accent, ), - label: isLocal - ? SvgPicture.asset( - "assets/arrow.svg", - color: selectedItems.length == 0 - ? Theme.of(context).brightness == Brightness.light - ? MyTheme.grayBg - : MyTheme.darkGray - : Colors.white, - ) - : Text( - translate('Receive'), - style: TextStyle( - color: selectedItems.length == 0 - ? Theme.of(context).brightness == Brightness.light - ? MyTheme.grayBg - : MyTheme.darkGray - : Colors.white, - ), - ), - ), + ), + onPressed: SelectedItems.valid(selectedItems.items) + ? () { + final otherSideData = + controller.getOtherSideDirectoryData(); + controller.sendFiles(selectedItems, otherSideData); + selectedItems.clear(); + } + : null, + icon: isLocal + ? Text( + translate('Send'), + textAlign: TextAlign.right, + style: TextStyle( + color: selectedItems.items.isEmpty + ? Theme.of(context).brightness == + Brightness.light + ? MyTheme.grayBg + : MyTheme.darkGray + : Colors.white, + ), + ) + : RotatedBox( + quarterTurns: 2, + child: SvgPicture.asset( + "assets/arrow.svg", + color: selectedItems.items.isEmpty + ? Theme.of(context).brightness == + Brightness.light + ? MyTheme.grayBg + : MyTheme.darkGray + : Colors.white, + alignment: Alignment.bottomRight, + ), + ), + label: isLocal + ? SvgPicture.asset( + "assets/arrow.svg", + color: selectedItems.items.isEmpty + ? Theme.of(context).brightness == + Brightness.light + ? MyTheme.grayBg + : MyTheme.darkGray + : Colors.white, + ) + : Text( + translate('Receive'), + style: TextStyle( + color: selectedItems.items.isEmpty + ? Theme.of(context).brightness == + Brightness.light + ? MyTheme.grayBg + : MyTheme.darkGray + : Colors.white, + ), + ), + )), ], ).marginOnly(top: 8.0) ], @@ -1113,55 +794,443 @@ class _FileManagerPageState extends State ); } - bool validItems(SelectedItems items) { - if (items.length > 0) { - // exclude DirDrive type - return items.items.any((item) => !item.isDrive); + Widget menu({bool isLocal = false}) { + var menuPos = RelativeRect.fill; + + final List> items = [ + MenuEntrySwitch( + switchType: SwitchType.scheckbox, + text: translate("Show Hidden Files"), + getter: () async { + return controller.options.value.isWindows; + }, + setter: (bool v) async { + controller.toggleShowHidden(); + }, + padding: kDesktopMenuPadding, + dismissOnClicked: true, + ), + MenuEntryButton( + childBuilder: (style) => Text(translate("Select All"), style: style), + proc: () => setState(() => + selectedItems.selectAll(controller.directory.value.entries)), + padding: kDesktopMenuPadding, + dismissOnClicked: true), + MenuEntryButton( + childBuilder: (style) => + Text(translate("Unselect All"), style: style), + proc: () => selectedItems.clear(), + padding: kDesktopMenuPadding, + dismissOnClicked: true) + ]; + + return Listener( + onPointerDown: (e) { + final x = e.position.dx; + final y = e.position.dy; + menuPos = RelativeRect.fromLTRB(x, y, x, y); + }, + child: MenuButton( + onPressed: () => mod_menu.showMenu( + context: context, + position: menuPos, + items: items + .map( + (e) => e.build( + context, + MenuConfig( + commonColor: CustomPopupMenuTheme.commonColor, + height: CustomPopupMenuTheme.height, + dividerHeight: CustomPopupMenuTheme.dividerHeight), + ), + ) + .expand((i) => i) + .toList(), + elevation: 8, + ), + child: SvgPicture.asset( + "assets/dots.svg", + color: Theme.of(context).tabBarTheme.labelColor, + ), + color: Theme.of(context).cardColor, + hoverColor: Theme.of(context).hoverColor, + ), + ); + } + + Widget _buildFileList( + BuildContext context, ScrollController scrollController) { + final fd = controller.directory.value; + final entries = fd.entries; + + return ListSearchActionListener( + node: _keyboardNode, + buffer: _listSearchBuffer, + onNext: (buffer) { + debugPrint("searching next for $buffer"); + assert(buffer.length == 1); + assert(selectedItems.items.length <= 1); + var skipCount = 0; + if (selectedItems.items.isNotEmpty) { + final index = entries.indexOf(selectedItems.items.first); + if (index < 0) { + return; + } + skipCount = index + 1; + } + var searchResult = entries + .skip(skipCount) + .where((element) => element.name.toLowerCase().startsWith(buffer)); + if (searchResult.isEmpty) { + // cannot find next, lets restart search from head + debugPrint("restart search from head"); + searchResult = entries.where( + (element) => element.name.toLowerCase().startsWith(buffer)); + } + if (searchResult.isEmpty) { + selectedItems.clear(); + return; + } + _jumpToEntry(isLocal, searchResult.first, scrollController, + kDesktopFileTransferRowHeight); + }, + onSearch: (buffer) { + debugPrint("searching for $buffer"); + final selectedEntries = selectedItems; + final searchResult = entries + .where((element) => element.name.toLowerCase().startsWith(buffer)); + selectedEntries.clear(); + if (searchResult.isEmpty) { + selectedItems.clear(); + return; + } + _jumpToEntry(isLocal, searchResult.first, scrollController, + kDesktopFileTransferRowHeight); + }, + child: Obx(() { + final entries = controller.directory.value.entries; + final filteredEntries = _searchText.isNotEmpty + ? entries.where((element) { + return element.name.contains(_searchText.value); + }).toList(growable: false) + : entries; + final rows = filteredEntries.map((entry) { + final sizeStr = + entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; + final lastModifiedStr = entry.isDrive + ? " " + : "${entry.lastModified().toString().replaceAll(".000", "")} "; + return Padding( + padding: EdgeInsets.symmetric(vertical: 1), + child: Obx(() => Container( + decoration: BoxDecoration( + color: selectedItems.items.contains(entry) + ? Theme.of(context).hoverColor + : Theme.of(context).cardColor, + borderRadius: BorderRadius.all( + Radius.circular(5.0), + ), + ), + key: ValueKey(entry.name), + height: kDesktopFileTransferRowHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: InkWell( + child: Row( + children: [ + GestureDetector( + child: Obx( + () => Container( + width: _nameColWidth.value, + child: Tooltip( + waitDuration: Duration(milliseconds: 500), + message: entry.name, + child: Row(children: [ + entry.isDrive + ? Image( + image: iconHardDrive, + fit: BoxFit.scaleDown, + color: Theme.of(context) + .iconTheme + .color + ?.withOpacity(0.7)) + .paddingAll(4) + : SvgPicture.asset( + entry.isFile + ? "assets/file.svg" + : "assets/folder.svg", + color: Theme.of(context) + .tabBarTheme + .labelColor, + ), + Expanded( + child: Text(entry.name.nonBreaking, + overflow: + TextOverflow.ellipsis)) + ]), + )), + ), + onTap: () { + final items = selectedItems; + // handle double click + if (_checkDoubleClick(entry)) { + controller.openDirectory(entry.path); + items.clear(); + return; + } + _onSelectedChanged( + items, filteredEntries, entry, isLocal); + }, + ), + SizedBox( + width: 2.0, + ), + GestureDetector( + child: Obx( + () => SizedBox( + width: _modifiedColWidth.value, + child: Tooltip( + waitDuration: Duration(milliseconds: 500), + message: lastModifiedStr, + child: Text( + lastModifiedStr, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + )), + ), + ), + ), + // Divider from header. + SizedBox( + width: 2.0, + ), + Expanded( + // width: 100, + child: GestureDetector( + child: Tooltip( + waitDuration: Duration(milliseconds: 500), + message: sizeStr, + child: Text( + sizeStr, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, color: MyTheme.darkGray), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ))), + ); + }).toList(growable: false); + + return Column( + children: [ + // Header + Row( + children: [ + Expanded(child: _buildFileBrowserHeader(context)), + ], + ), + // Body + Expanded( + child: ListView.builder( + controller: scrollController, + itemExtent: kDesktopFileTransferRowHeight, + itemBuilder: (context, index) { + return rows[index]; + }, + itemCount: rows.length, + ), + ), + ], + ); + }), + ); + } + + onSearchText(String searchText, bool isLocal) { + selectedItems.clear(); + _searchText.value = searchText; + } + + void _jumpToEntry(bool isLocal, Entry entry, + ScrollController scrollController, double rowHeight) { + final entries = controller.directory.value.entries; + final index = entries.indexOf(entry); + if (index == -1) { + debugPrint("entry is not valid: ${entry.path}"); + } + final selectedEntries = selectedItems; + final searchResult = entries.where((element) => element == entry); + selectedEntries.clear(); + if (searchResult.isEmpty) { + return; + } + final offset = min( + max(scrollController.position.minScrollExtent, + entries.indexOf(searchResult.first) * rowHeight), + scrollController.position.maxScrollExtent); + scrollController.jumpTo(offset); + selectedEntries.add(searchResult.first); + debugPrint("focused on ${searchResult.first.name}"); + } + + void _onSelectedChanged(SelectedItems selectedItems, List entries, + Entry entry, bool isLocal) { + final isCtrlDown = RawKeyboard.instance.keysPressed + .contains(LogicalKeyboardKey.controlLeft); + final isShiftDown = + RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft); + if (isCtrlDown) { + if (selectedItems.items.contains(entry)) { + selectedItems.remove(entry); + } else { + selectedItems.add(entry); + } + } else if (isShiftDown) { + final List indexGroup = []; + for (var selected in selectedItems.items) { + indexGroup.add(entries.indexOf(selected)); + } + indexGroup.add(entries.indexOf(entry)); + indexGroup.removeWhere((e) => e == -1); + final maxIndex = indexGroup.reduce(max); + final minIndex = indexGroup.reduce(min); + selectedItems.clear(); + entries + .getRange(minIndex, maxIndex + 1) + .forEach((e) => selectedItems.add(e)); + } else { + selectedItems.clear(); + selectedItems.add(entry); + } + setState(() {}); + } + + bool _checkDoubleClick(Entry entry) { + final current = DateTime.now().millisecondsSinceEpoch; + final elapsed = current - _lastClickTime; + _lastClickTime = current; + if (_lastClickEntry == entry) { + if (elapsed < bind.getDoubleClickTime()) { + return true; + } + } else { + _lastClickEntry = entry; } return false; } - @override - bool get wantKeepAlive => true; - - void onLocalLocationFocusChanged() { - debugPrint("focus changed on local"); - if (_locationNodeLocal.hasFocus) { - // ignore - } else { - // lost focus, change to bread - if (_locationStatusLocal.value != LocationStatus.fileSearchBar) { - _locationStatusLocal.value = LocationStatus.bread; - } - } + Widget _buildFileBrowserHeader(BuildContext context) { + final padding = EdgeInsets.all(1.0); + return SizedBox( + height: kDesktopFileTransferHeaderHeight, + child: Row( + children: [ + Obx( + () => headerItemFunc( + _nameColWidth.value, SortBy.name, translate("Name")), + ), + DraggableDivider( + axis: Axis.vertical, + onPointerMove: (dx) { + _nameColWidth.value += dx; + _nameColWidth.value = min(kDesktopFileTransferMaximumWidth, + max(kDesktopFileTransferMinimumWidth, _nameColWidth.value)); + }, + padding: padding, + ), + Obx( + () => headerItemFunc(_modifiedColWidth.value, SortBy.modified, + translate("Modified")), + ), + DraggableDivider( + axis: Axis.vertical, + onPointerMove: (dx) { + _modifiedColWidth.value += dx; + _modifiedColWidth.value = min( + kDesktopFileTransferMaximumWidth, + max(kDesktopFileTransferMinimumWidth, + _modifiedColWidth.value)); + }, + padding: padding), + Expanded(child: headerItemFunc(null, SortBy.size, translate("Size"))) + ], + ), + ); } - void onRemoteLocationFocusChanged() { - debugPrint("focus changed on remote"); - if (_locationNodeRemote.hasFocus) { - // ignore - } else { - // lost focus, change to bread - if (_locationStatusRemote.value != LocationStatus.fileSearchBar) { - _locationStatusRemote.value = LocationStatus.bread; + Widget headerItemFunc(double? width, SortBy sortBy, String name) { + final headerTextStyle = + Theme.of(context).dataTableTheme.headingTextStyle ?? TextStyle(); + return ObxValue>( + (ascending) => InkWell( + onTap: () { + if (ascending.value == null) { + ascending.value = true; + } else { + ascending.value = !ascending.value!; + } + controller.changeSortStyle(sortBy, + isLocal: isLocal, ascending: ascending.value!); + }, + child: SizedBox( + width: width, + height: kDesktopFileTransferHeaderHeight, + child: Row( + children: [ + Flexible( + flex: 2, + child: Text( + name, + style: headerTextStyle, + overflow: TextOverflow.ellipsis, + ).marginSymmetric(horizontal: 4), + ), + Flexible( + flex: 1, + child: ascending.value != null + ? Icon( + ascending.value! + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + ) + : const Offstage()) + ], + ), + ), + ), () { + if (controller.sortBy.value == sortBy) { + return controller.sortAscending.obs; + } else { + return Rx(null); } - } + }()); } - Widget buildBread(bool isLocal) { + Widget buildBread() { final items = getPathBreadCrumbItems(isLocal, (list) { var path = ""; for (var item in list) { - path = PathUtil.join(path, item, model.getCurrentIsWindows(isLocal)); + path = PathUtil.join(path, item, controller.options.value.isWindows); } - openDirectory(path, isLocal: isLocal); + controller.openDirectory(path); }); - final locationBarKey = getLocationBarKey(isLocal); return items.isEmpty ? Offstage() : Row( - key: locationBarKey, + key: _locationBarKey, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( @@ -1169,7 +1238,7 @@ class _FileManagerPageState extends State // handle mouse wheel onPointerSignal: (e) { if (e is PointerScrollEvent) { - final sc = getBreadCrumbScrollController(isLocal); + final sc = _breadCrumbScroller; final scale = Platform.isWindows ? 2 : 4; sc.jumpTo(sc.offset + e.scrollDelta.dy / scale); } @@ -1178,7 +1247,7 @@ class _FileManagerPageState extends State items: items, divider: const Icon(Icons.keyboard_arrow_right_rounded), overflow: ScrollableOverflow( - controller: getBreadCrumbScrollController(isLocal), + controller: _breadCrumbScroller, ), ), ), @@ -1187,9 +1256,9 @@ class _FileManagerPageState extends State message: "", icon: Icons.keyboard_arrow_down_rounded, onTap: () async { - final renderBox = locationBarKey.currentContext + final renderBox = _locationBarKey.currentContext ?.findRenderObject() as RenderBox; - locationBarKey.currentContext?.size; + _locationBarKey.currentContext?.size; final size = renderBox.size; final offset = renderBox.localToGlobal(Offset.zero); @@ -1197,17 +1266,17 @@ class _FileManagerPageState extends State final x = offset.dx; final y = offset.dy + size.height + 1; - final isPeerWindows = model.getCurrentIsWindows(isLocal); + final isPeerWindows = controller.options.value.isWindows; final List menuItems = [ MenuEntryButton( childBuilder: (TextStyle? style) => isPeerWindows - ? buildWindowsThisPC(style) + ? buildWindowsThisPC(context, style) : Text( '/', style: style, ), proc: () { - openDirectory('/', isLocal: isLocal); + controller.openDirectory('/'); }, dismissOnClicked: true), MenuEntryDivider() @@ -1218,8 +1287,9 @@ class _FileManagerPageState extends State loadingTag = _ffi.dialogManager.showLoading("Waiting"); } try { - final fd = - await model.fetchDirectory("/", isLocal, isLocal); + final showHidden = controller.options.value.showHidden; + final fd = await controller.fileFetcher + .fetchDirectory("/", isLocal, showHidden); for (var entry in fd.entries) { menuItems.add(MenuEntryButton( childBuilder: (TextStyle? style) => @@ -1238,8 +1308,7 @@ class _FileManagerPageState extends State ) ]), proc: () { - openDirectory('${entry.name}\\', - isLocal: isLocal); + controller.openDirectory('${entry.name}\\'); }, dismissOnClicked: true)); } @@ -1274,24 +1343,15 @@ class _FileManagerPageState extends State ]); } - Widget buildWindowsThisPC([TextStyle? textStyle]) { - final color = Theme.of(context).iconTheme.color?.withOpacity(0.7); - return Row(children: [ - Icon(Icons.computer, size: 20, color: color), - SizedBox(width: 10), - Text(translate('This PC'), style: textStyle) - ]); - } - List getPathBreadCrumbItems( bool isLocal, void Function(List) onPressed) { - final path = model.getCurrentDir(isLocal).path; + final path = controller.directory.value.path; final breadCrumbList = List.empty(growable: true); - final isWindows = model.getCurrentIsWindows(isLocal); + final isWindows = controller.options.value.isWindows; if (isWindows && path == '/') { breadCrumbList.add(BreadCrumbItem( content: TextButton( - child: buildWindowsThisPC(), + child: buildWindowsThisPC(context), style: ButtonStyle( minimumSize: MaterialStateProperty.all(Size(0, 0))), onPressed: () => onPressed(['/'])) @@ -1319,39 +1379,34 @@ class _FileManagerPageState extends State return breadCrumbList; } - breadCrumbScrollToEnd(bool isLocal) { + breadCrumbScrollToEnd() { Future.delayed(Duration(milliseconds: 200), () { - final breadCrumbScroller = getBreadCrumbScrollController(isLocal); - if (breadCrumbScroller.hasClients) { - breadCrumbScroller.animateTo( - breadCrumbScroller.position.maxScrollExtent, + if (_breadCrumbScroller.hasClients) { + _breadCrumbScroller.animateTo( + _breadCrumbScroller.position.maxScrollExtent, duration: Duration(milliseconds: 200), curve: Curves.fastLinearToSlowEaseIn); } }); } - Widget buildPathLocation(bool isLocal) { - final searchTextObs = isLocal ? _searchTextLocal : _searchTextRemote; - final locationStatus = - isLocal ? _locationStatusLocal : _locationStatusRemote; - final focusNode = isLocal ? _locationNodeLocal : _locationNodeRemote; - final text = locationStatus.value == LocationStatus.pathLocation - ? model.getCurrentDir(isLocal).path - : searchTextObs.value; + Widget buildPathLocation() { + final text = _locationStatus.value == LocationStatus.pathLocation + ? controller.directory.value.path + : _searchText.value; final textController = TextEditingController(text: text) ..selection = TextSelection.collapsed(offset: text.length); return Row( children: [ SvgPicture.asset( - locationStatus.value == LocationStatus.pathLocation + _locationStatus.value == LocationStatus.pathLocation ? "assets/folder.svg" : "assets/search.svg", color: Theme.of(context).tabBarTheme.labelColor, ), Expanded( child: TextField( - focusNode: focusNode, + focusNode: _locationNode, decoration: InputDecoration( border: InputBorder.none, isDense: true, @@ -1361,9 +1416,9 @@ class _FileManagerPageState extends State ), controller: textController, onSubmitted: (path) { - openDirectory(path, isLocal: isLocal); + controller.openDirectory(path); }, - onChanged: locationStatus.value == LocationStatus.fileSearchBar + onChanged: _locationStatus.value == LocationStatus.fileSearchBar ? (searchText) => onSearchText(searchText, isLocal) : null, ), @@ -1372,139 +1427,16 @@ class _FileManagerPageState extends State ); } - onSearchText(String searchText, bool isLocal) { - if (isLocal) { - _localSelectedItems.clear(); - _searchTextLocal.value = searchText; - } else { - _remoteSelectedItems.clear(); - _searchTextRemote.value = searchText; - } - } - - openDirectory(String path, {bool isLocal = false}) { - model.openDirectory(path, isLocal: isLocal); - } - - void handleDragDone(DropDoneDetails details, bool isLocal) { - if (isLocal) { - // ignore local - return; - } - var items = SelectedItems(); - for (var file in details.files) { - final f = File(file.path); - items.add( - true, - Entry() - ..path = file.path - ..name = file.name - ..size = - FileSystemEntity.isDirectorySync(f.path) ? 0 : f.lengthSync()); - } - model.sendFiles(items, isRemote: false); - } - - void refocusKeyboardListener(bool isLocal) { - Future.delayed(Duration.zero, () { - if (isLocal) { - _keyboardNodeLocal.requestFocus(); - } else { - _keyboardNodeRemote.requestFocus(); - } - }); - } - - Widget headerItemFunc( - double? width, SortBy sortBy, String name, bool isLocal) { - final headerTextStyle = - Theme.of(context).dataTableTheme.headingTextStyle ?? TextStyle(); - return ObxValue>( - (ascending) => InkWell( - onTap: () { - if (ascending.value == null) { - ascending.value = true; - } else { - ascending.value = !ascending.value!; - } - model.changeSortStyle(sortBy, - isLocal: isLocal, ascending: ascending.value!); - }, - child: SizedBox( - width: width, - height: kDesktopFileTransferHeaderHeight, - child: Row( - children: [ - Flexible( - flex: 2, - child: Text( - name, - style: headerTextStyle, - overflow: TextOverflow.ellipsis, - ).marginSymmetric(horizontal: 4), - ), - Flexible( - flex: 1, - child: ascending.value != null - ? Icon( - ascending.value! - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - ) - : const Offstage()) - ], - ), - ), - ), () { - if (model.getSortStyle(isLocal) == sortBy) { - return model.getSortAscending(isLocal).obs; - } else { - return Rx(null); - } - }()); - } - - Widget _buildFileBrowserHeader(BuildContext context, bool isLocal) { - final nameColWidth = isLocal ? _nameColWidthLocal : _nameColWidthRemote; - final modifiedColWidth = - isLocal ? _modifiedColWidthLocal : _modifiedColWidthRemote; - final padding = EdgeInsets.all(1.0); - return SizedBox( - height: kDesktopFileTransferHeaderHeight, - child: Row( - children: [ - Obx( - () => headerItemFunc( - nameColWidth.value, SortBy.name, translate("Name"), isLocal), - ), - DraggableDivider( - axis: Axis.vertical, - onPointerMove: (dx) { - nameColWidth.value += dx; - nameColWidth.value = min(kDesktopFileTransferMaximumWidth, - max(kDesktopFileTransferMinimumWidth, nameColWidth.value)); - }, - padding: padding, - ), - Obx( - () => headerItemFunc(modifiedColWidth.value, SortBy.modified, - translate("Modified"), isLocal), - ), - DraggableDivider( - axis: Axis.vertical, - onPointerMove: (dx) { - modifiedColWidth.value += dx; - modifiedColWidth.value = min( - kDesktopFileTransferMaximumWidth, - max(kDesktopFileTransferMinimumWidth, - modifiedColWidth.value)); - }, - padding: padding), - Expanded( - child: - headerItemFunc(null, SortBy.size, translate("Size"), isLocal)) - ], - ), - ); - } + // openDirectory(String path, {bool isLocal = false}) { + // model.openDirectory(path, isLocal: isLocal); + // } +} + +Widget buildWindowsThisPC(BuildContext context, [TextStyle? textStyle]) { + final color = Theme.of(context).iconTheme.color?.withOpacity(0.7); + return Row(children: [ + Icon(Icons.computer, size: 20, color: color), + SizedBox(width: 10), + Text(translate('This PC'), style: textStyle) + ]); } diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 7aa9a0005..c6ba42d31 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; import 'package:flutter_hbb/models/file_model.dart'; -import 'package:provider/provider.dart'; +import 'package:get/get.dart'; import 'package:toggle_switch/toggle_switch.dart'; import 'package:wakelock/wakelock.dart'; @@ -18,10 +18,51 @@ class FileManagerPage extends StatefulWidget { State createState() => _FileManagerPageState(); } +enum SelectMode { local, remote, none } + +extension SelectModeEq on SelectMode { + bool eq(bool? currentIsLocal) { + if (currentIsLocal == null) { + return false; + } + if (currentIsLocal) { + return this == SelectMode.local; + } else { + return this == SelectMode.remote; + } + } +} + +extension SelectModeExt on Rx { + void toggle(bool currentIsLocal) { + switch (value) { + case SelectMode.local: + value = SelectMode.none; + break; + case SelectMode.remote: + value = SelectMode.none; + break; + case SelectMode.none: + if (currentIsLocal) { + value = SelectMode.local; + } else { + value = SelectMode.remote; + } + break; + } + } +} + class _FileManagerPageState extends State { final model = gFFI.fileModel; - final _selectedItems = SelectedItems(); - final _breadCrumbScroller = ScrollController(); + final selectMode = SelectMode.none.obs; + + var showLocal = true; + + FileController get currentFileController => + showLocal ? model.localController : model.remoteController; + FileDirectory get currentDir => currentFileController.directory.value; + DirectoryOptions get currentOptions => currentFileController.options.value; @override void initState() { @@ -32,13 +73,12 @@ class _FileManagerPageState extends State { .showLoading(translate('Connecting...'), onCancel: closeConnection); }); gFFI.ffiModel.updateEventListener(widget.id); - model.onDirChanged = (_) => breadCrumbScrollToEnd(); Wakelock.enable(); } @override void dispose() { - model.onClose().whenComplete(() { + model.close().whenComplete(() { gFFI.close(); gFFI.dialogManager.dismissAll(); Wakelock.disable(); @@ -47,288 +87,455 @@ class _FileManagerPageState extends State { } @override - Widget build(BuildContext context) => ChangeNotifierProvider.value( - value: model, - child: Consumer(builder: (_context, _model, _child) { - return WillPopScope( - onWillPop: () async { - if (model.selectMode) { - model.toggleSelectMode(); - } else { - model.goBack(); + Widget build(BuildContext context) => WillPopScope( + onWillPop: () async { + if (selectMode.value != SelectMode.none) { + selectMode.value = SelectMode.none; + setState(() {}); + } else { + currentFileController.goBack(); + } + return false; + }, + child: Scaffold( + // backgroundColor: MyTheme.grayBg, + appBar: AppBar( + leading: Row(children: [ + IconButton( + icon: Icon(Icons.close), + onPressed: () => clientClose(widget.id, gFFI.dialogManager)), + ]), + centerTitle: true, + title: ToggleSwitch( + initialLabelIndex: showLocal ? 0 : 1, + activeBgColor: [MyTheme.idColor], + inactiveBgColor: Theme.of(context).brightness == Brightness.light + ? MyTheme.grayBg + : null, + inactiveFgColor: Theme.of(context).brightness == Brightness.light + ? Colors.black54 + : null, + totalSwitches: 2, + minWidth: 100, + fontSize: 15, + iconSize: 18, + labels: [translate("Local"), translate("Remote")], + icons: [Icons.phone_android_sharp, Icons.screen_share], + onToggle: (index) { + final current = showLocal ? 0 : 1; + if (index != current) { + setState(() => showLocal = !showLocal); } - return false; }, - child: Scaffold( - // backgroundColor: MyTheme.grayBg, - appBar: AppBar( - leading: Row(children: [ - IconButton( - icon: Icon(Icons.close), - onPressed: () => - clientClose(widget.id, gFFI.dialogManager)), - ]), - centerTitle: true, - title: ToggleSwitch( - initialLabelIndex: model.isLocal ? 0 : 1, - activeBgColor: [MyTheme.idColor], - inactiveBgColor: - Theme.of(context).brightness == Brightness.light - ? MyTheme.grayBg - : null, - inactiveFgColor: - Theme.of(context).brightness == Brightness.light - ? Colors.black54 - : null, - totalSwitches: 2, - minWidth: 100, - fontSize: 15, - iconSize: 18, - labels: [translate("Local"), translate("Remote")], - icons: [Icons.phone_android_sharp, Icons.screen_share], - onToggle: (index) { - final current = model.isLocal ? 0 : 1; - if (index != current) { - model.togglePage(); - } - }, - ), - actions: [ - PopupMenuButton( - icon: Icon(Icons.more_vert), - itemBuilder: (context) { - return [ - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.refresh, - color: Theme.of(context).iconTheme.color), - SizedBox(width: 5), - Text(translate("Refresh File")) - ], - ), - value: "refresh", - ), - PopupMenuItem( - enabled: model.currentDir.path != "/", - child: Row( - children: [ - Icon(Icons.check, - color: Theme.of(context).iconTheme.color), - SizedBox(width: 5), - Text(translate("Multi Select")) - ], - ), - value: "select", - ), - PopupMenuItem( - enabled: model.currentDir.path != "/", - child: Row( - children: [ - Icon(Icons.folder_outlined, - color: Theme.of(context).iconTheme.color), - SizedBox(width: 5), - Text(translate("Create Folder")) - ], - ), - value: "folder", - ), - PopupMenuItem( - enabled: model.currentDir.path != "/", - child: Row( - children: [ - Icon( - model.getCurrentShowHidden() - ? Icons.check_box_outlined - : Icons.check_box_outline_blank, - color: Theme.of(context).iconTheme.color), - SizedBox(width: 5), - Text(translate("Show Hidden Files")) - ], - ), - value: "hidden", - ) - ]; - }, - onSelected: (v) { - if (v == "refresh") { - model.refresh(); - } else if (v == "select") { - _selectedItems.clear(); - model.toggleSelectMode(); - } else if (v == "folder") { - final name = TextEditingController(); - gFFI.dialogManager - .show((setState, close) => CustomAlertDialog( - title: Text(translate("Create Folder")), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - decoration: InputDecoration( - labelText: translate( - "Please enter the folder name"), - ), - controller: name, - ), - ], + ), + actions: [ + PopupMenuButton( + icon: Icon(Icons.more_vert), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Row( + children: [ + Icon(Icons.refresh, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Refresh File")) + ], + ), + value: "refresh", + ), + PopupMenuItem( + enabled: currentDir.path != "/", + child: Row( + children: [ + Icon(Icons.check, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Multi Select")) + ], + ), + value: "select", + ), + PopupMenuItem( + enabled: currentDir.path != "/", + child: Row( + children: [ + Icon(Icons.folder_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Create Folder")) + ], + ), + value: "folder", + ), + PopupMenuItem( + enabled: currentDir.path != "/", + child: Row( + children: [ + Icon( + currentOptions.showHidden + ? Icons.check_box_outlined + : Icons.check_box_outline_blank, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Show Hidden Files")) + ], + ), + value: "hidden", + ) + ]; + }, + onSelected: (v) { + if (v == "refresh") { + currentFileController.refresh(); + } else if (v == "select") { + model.localController.selectedItems.clear(); + model.remoteController.selectedItems.clear(); + selectMode.toggle(showLocal); + setState(() {}); + } else if (v == "folder") { + final name = TextEditingController(); + gFFI.dialogManager + .show((setState, close) => CustomAlertDialog( + title: Text(translate("Create Folder")), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + decoration: InputDecoration( + labelText: translate( + "Please enter the folder name"), ), - actions: [ - dialogButton("Cancel", - onPressed: () => close(false), - isOutline: true), - dialogButton("OK", onPressed: () { - if (name.value.text.isNotEmpty) { - model.createDir(PathUtil.join( - model.currentDir.path, - name.value.text, - model.getCurrentIsWindows())); - close(); - } - }) - ])); - } else if (v == "hidden") { - model.toggleShowHidden(); - } - }), - ], + controller: name, + ), + ], + ), + actions: [ + dialogButton("Cancel", + onPressed: () => close(false), + isOutline: true), + dialogButton("OK", onPressed: () { + if (name.value.text.isNotEmpty) { + currentFileController.createDir( + PathUtil.join( + currentDir.path, + name.value.text, + currentOptions.isWindows)); + close(); + } + }) + ])); + } else if (v == "hidden") { + currentFileController.toggleShowHidden(); + } + }), + ], + ), + body: showLocal + ? FileManagerView( + controller: model.localController, + selectMode: selectMode, + ) + : FileManagerView( + controller: model.remoteController, + selectMode: selectMode, ), - body: body(), - bottomSheet: bottomSheet(), - )); - })); + bottomSheet: bottomSheet(), + )); - bool showCheckBox() { - if (!model.selectMode) { - return false; - } - return !_selectedItems.isOtherPage(model.isLocal); + Widget? bottomSheet() { + return Obx(() { + final selectedItems = getActiveSelectedItems(); + final jobTable = model.jobController.jobTable; + + final localLabel = selectedItems?.isLocal == null + ? "" + : " [${selectedItems!.isLocal ? translate("Local") : translate("Remote")}]"; + if (!(selectMode.value == SelectMode.none)) { + final selectedItemsLen = + "${selectedItems?.items.length ?? 0} ${translate("items")}"; + if (selectedItems == null || + selectedItems.items.isEmpty || + selectMode.value.eq(showLocal)) { + return BottomSheetBody( + leading: Icon(Icons.check), + title: translate("Selected"), + text: selectedItemsLen + localLabel, + onCanceled: () { + selectedItems?.items.clear(); + selectMode.value = SelectMode.none; + setState(() {}); + }, + actions: [ + IconButton( + icon: Icon(Icons.compare_arrows), + onPressed: () => setState(() => showLocal = !showLocal), + ), + IconButton( + icon: Icon(Icons.delete_forever), + onPressed: selectedItems != null + ? () async { + if (selectedItems.items.isNotEmpty) { + await currentFileController + .removeAction(selectedItems); + selectedItems.items.clear(); + selectMode.value = SelectMode.none; + } + } + : null, + ) + ]); + } else { + return BottomSheetBody( + leading: Icon(Icons.input), + title: translate("Paste here?"), + text: selectedItemsLen + localLabel, + onCanceled: () { + selectedItems.items.clear(); + selectMode.value = SelectMode.none; + setState(() {}); + }, + actions: [ + IconButton( + icon: Icon(Icons.compare_arrows), + onPressed: () => setState(() => showLocal = !showLocal), + ), + IconButton( + icon: Icon(Icons.paste), + onPressed: () { + selectMode.value = SelectMode.none; + final otherSide = showLocal + ? model.remoteController + : model.localController; + final thisSideData = + DirectoryData(currentDir, currentOptions); + otherSide.sendFiles(selectedItems, thisSideData); + selectedItems.items.clear(); + selectMode.value = SelectMode.none; + }, + ) + ]); + } + } + + if (jobTable.isEmpty) { + return Offstage(); + } + + switch (jobTable.last.state) { + case JobState.inProgress: + return BottomSheetBody( + leading: CircularProgressIndicator(), + title: translate("Waiting"), + text: + "${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s", + onCanceled: () { + model.jobController.cancelJob(jobTable.last.id); + jobTable.clear(); + }, + ); + case JobState.done: + return BottomSheetBody( + leading: Icon(Icons.check), + title: "${translate("Successful")}!", + text: jobTable.last.display(), + onCanceled: () => jobTable.clear(), + ); + case JobState.error: + return BottomSheetBody( + leading: Icon(Icons.error), + title: "${translate("Error")}!", + text: "", + onCanceled: () => jobTable.clear(), + ); + case JobState.none: + break; + case JobState.paused: + // TODO: Handle this case. + break; + } + return Offstage(); + }); } - Widget body() { - final isLocal = model.isLocal; - final fd = model.currentDir; - final entries = fd.entries; + SelectedItems? getActiveSelectedItems() { + final localSelectedItems = model.localController.selectedItems; + final remoteSelectedItems = model.remoteController.selectedItems; + + if (localSelectedItems.items.isNotEmpty && + remoteSelectedItems.items.isNotEmpty) { + // assert unreachable + debugPrint("Wrong SelectedItems state, reset"); + localSelectedItems.clear(); + remoteSelectedItems.clear(); + } + + if (localSelectedItems.items.isEmpty && remoteSelectedItems.items.isEmpty) { + return null; + } + + if (localSelectedItems.items.length > remoteSelectedItems.items.length) { + return localSelectedItems; + } else { + return remoteSelectedItems; + } + } +} + +class FileManagerView extends StatefulWidget { + final FileController controller; + final Rx selectMode; + + FileManagerView({required this.controller, required this.selectMode}); + + @override + State createState() => _FileManagerViewState(); +} + +class _FileManagerViewState extends State { + final _listScrollController = ScrollController(); + final _breadCrumbScroller = ScrollController(); + + bool get isLocal => widget.controller.isLocal; + FileController get controller => widget.controller; + SelectedItems get _selectedItems => widget.controller.selectedItems; + + @override + void initState() { + super.initState(); + controller.directory.listen((e) => breadCrumbScrollToEnd()); + } + + @override + Widget build(BuildContext context) { return Column(children: [ headTools(), - Expanded( - child: ListView.builder( - controller: ScrollController(), - itemCount: entries.length + 1, - itemBuilder: (context, index) { - if (index >= entries.length) { - return listTail(); - } - var selected = false; - if (model.selectMode) { - selected = _selectedItems.contains(entries[index]); - } + Expanded(child: Obx(() { + final entries = controller.directory.value.entries; + return ListView.builder( + controller: _listScrollController, + itemCount: entries.length + 1, + itemBuilder: (context, index) { + if (index >= entries.length) { + return listTail(); + } + var selected = false; + if (widget.selectMode.value != SelectMode.none) { + selected = _selectedItems.items.contains(entries[index]); + } - final sizeStr = entries[index].isFile - ? readableFileSize(entries[index].size.toDouble()) - : ""; - return Card( - child: ListTile( - leading: entries[index].isDrive - ? Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Image( - image: iconHardDrive, - fit: BoxFit.scaleDown, - color: Theme.of(context) - .iconTheme - .color - ?.withOpacity(0.7))) - : Icon( - entries[index].isFile - ? Icons.feed_outlined - : Icons.folder, - size: 40), - title: Text(entries[index].name), - selected: selected, - subtitle: entries[index].isDrive - ? null - : Text( - "${entries[index].lastModified().toString().replaceAll(".000", "")} $sizeStr", - style: TextStyle(fontSize: 12, color: MyTheme.darkGray), - ), - trailing: entries[index].isDrive - ? null - : showCheckBox() - ? Checkbox( - value: selected, - onChanged: (v) { - if (v == null) return; - if (v && !selected) { - _selectedItems.add(isLocal, entries[index]); - } else if (!v && selected) { - _selectedItems.remove(entries[index]); - } - setState(() {}); - }) - : PopupMenuButton( - icon: Icon(Icons.more_vert), - itemBuilder: (context) { - return [ - PopupMenuItem( - child: Text(translate("Delete")), - value: "delete", - ), - PopupMenuItem( - child: Text(translate("Multi Select")), - value: "multi_select", - ), - PopupMenuItem( - child: Text(translate("Properties")), - value: "properties", - enabled: false, - ) - ]; - }, - onSelected: (v) { - if (v == "delete") { - final items = SelectedItems(); - items.add(isLocal, entries[index]); - model.removeAction(items); - } else if (v == "multi_select") { - _selectedItems.clear(); - model.toggleSelectMode(); - } - }), - onTap: () { - if (model.selectMode && !_selectedItems.isOtherPage(isLocal)) { - if (selected) { - _selectedItems.remove(entries[index]); - } else { - _selectedItems.add(isLocal, entries[index]); + final sizeStr = entries[index].isFile + ? readableFileSize(entries[index].size.toDouble()) + : ""; + + final showCheckBox = () { + return widget.selectMode.value != SelectMode.none && + widget.selectMode.value.eq(controller.selectedItems.isLocal); + }(); + return Card( + child: ListTile( + leading: entries[index].isDrive + ? Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Image( + image: iconHardDrive, + fit: BoxFit.scaleDown, + color: Theme.of(context) + .iconTheme + .color + ?.withOpacity(0.7))) + : Icon( + entries[index].isFile + ? Icons.feed_outlined + : Icons.folder, + size: 40), + title: Text(entries[index].name), + selected: selected, + subtitle: entries[index].isDrive + ? null + : Text( + "${entries[index].lastModified().toString().replaceAll(".000", "")} $sizeStr", + style: TextStyle(fontSize: 12, color: MyTheme.darkGray), + ), + trailing: entries[index].isDrive + ? null + : showCheckBox + ? Checkbox( + value: selected, + onChanged: (v) { + if (v == null) return; + if (v && !selected) { + _selectedItems.add(entries[index]); + } else if (!v && selected) { + _selectedItems.remove(entries[index]); + } + setState(() {}); + }) + : PopupMenuButton( + icon: Icon(Icons.more_vert), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Text(translate("Delete")), + value: "delete", + ), + PopupMenuItem( + child: Text(translate("Multi Select")), + value: "multi_select", + ), + PopupMenuItem( + child: Text(translate("Properties")), + value: "properties", + enabled: false, + ) + ]; + }, + onSelected: (v) { + if (v == "delete") { + final items = SelectedItems(isLocal: isLocal); + items.add(entries[index]); + controller.removeAction(items); + } else if (v == "multi_select") { + _selectedItems.clear(); + widget.selectMode.toggle(isLocal); + setState(() {}); + } + }), + onTap: () { + if (showCheckBox) { + if (selected) { + _selectedItems.remove(entries[index]); + } else { + _selectedItems.add(entries[index]); + } + setState(() {}); + return; } - setState(() {}); - return; - } - if (entries[index].isDirectory || entries[index].isDrive) { - model.openDirectory(entries[index].path); - } else { - // Perform file-related tasks. - } - }, - onLongPress: entries[index].isDrive - ? null - : () { - _selectedItems.clear(); - model.toggleSelectMode(); - if (model.selectMode) { - _selectedItems.add(isLocal, entries[index]); - } - setState(() {}); - }, - ), - ); - }, - )) + if (entries[index].isDirectory || entries[index].isDrive) { + controller.openDirectory(entries[index].path); + } else { + // Perform file-related tasks. + } + }, + onLongPress: entries[index].isDrive + ? null + : () { + _selectedItems.clear(); + widget.selectMode.toggle(isLocal); + if (widget.selectMode.value != SelectMode.none) { + _selectedItems.add(entries[index]); + } + setState(() {}); + }, + ), + ); + }, + ); + })) ]); } - breadCrumbScrollToEnd() { + void breadCrumbScrollToEnd() { Future.delayed(Duration(milliseconds: 200), () { if (_breadCrumbScroller.hasClients) { _breadCrumbScroller.animateTo( @@ -342,35 +549,39 @@ class _FileManagerPageState extends State { Widget headTools() => Container( child: Row( children: [ - Expanded( - child: BreadCrumb( - items: getPathBreadCrumbItems(() => model.goHome(), (list) { - var path = ""; - if (model.currentHome.startsWith(list[0])) { - // absolute path - for (var item in list) { - path = PathUtil.join(path, item, model.getCurrentIsWindows()); + Expanded(child: Obx(() { + final home = controller.options.value.home; + final isWindows = controller.options.value.isWindows; + return BreadCrumb( + items: getPathBreadCrumbItems(controller.shortPath, isWindows, + () => controller.goToHomeDirectory(), (list) { + var path = ""; + if (home.startsWith(list[0])) { + // absolute path + for (var item in list) { + path = PathUtil.join(path, item, isWindows); + } + } else { + path += home; + for (var item in list) { + path = PathUtil.join(path, item, isWindows); + } } - } else { - path += model.currentHome; - for (var item in list) { - path = PathUtil.join(path, item, model.getCurrentIsWindows()); - } - } - model.openDirectory(path); - }), - divider: Icon(Icons.chevron_right), - overflow: ScrollableOverflow(controller: _breadCrumbScroller), - )), + controller.openDirectory(path); + }), + divider: Icon(Icons.chevron_right), + overflow: ScrollableOverflow(controller: _breadCrumbScroller), + ); + })), Row( children: [ IconButton( icon: Icon(Icons.arrow_back), - onPressed: model.goBack, + onPressed: controller.goBack, ), IconButton( icon: Icon(Icons.arrow_upward), - onPressed: model.goToParentDirectory, + onPressed: controller.goToParentDirectory, ), PopupMenuButton( icon: Icon(Icons.sort), @@ -382,123 +593,37 @@ class _FileManagerPageState extends State { )) .toList(); }, - onSelected: model.changeSortStyle), + onSelected: controller.changeSortStyle), ], ) ], )); - Widget listTail() { - return Container( - height: 100, - child: Column( - children: [ - Padding( - padding: EdgeInsets.fromLTRB(30, 5, 30, 0), - child: Text( - model.currentDir.path, - style: TextStyle(color: MyTheme.darkGray), - ), - ), - Padding( - padding: EdgeInsets.all(2), - child: Text( - "${translate("Total")}: ${model.currentDir.entries.length} ${translate("items")}", - style: TextStyle(color: MyTheme.darkGray), - ), - ) - ], - ), - ); - } - - Widget? bottomSheet() { - final state = model.jobState; - final isOtherPage = _selectedItems.isOtherPage(model.isLocal); - final selectedItemsLen = "${_selectedItems.length} ${translate("items")}"; - final local = _selectedItems.isLocal == null - ? "" - : " [${_selectedItems.isLocal! ? translate("Local") : translate("Remote")}]"; - - if (model.selectMode) { - if (_selectedItems.length == 0 || !isOtherPage) { - return BottomSheetBody( - leading: Icon(Icons.check), - title: translate("Selected"), - text: selectedItemsLen + local, - onCanceled: () => model.toggleSelectMode(), - actions: [ - IconButton( - icon: Icon(Icons.compare_arrows), - onPressed: model.togglePage, + Widget listTail() => Obx(() => Container( + height: 100, + child: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(30, 5, 30, 0), + child: Text( + controller.directory.value.path, + style: TextStyle(color: MyTheme.darkGray), ), - IconButton( - icon: Icon(Icons.delete_forever), - onPressed: () { - if (_selectedItems.length > 0) { - model.removeAction(_selectedItems); - } - }, - ) - ]); - } else { - return BottomSheetBody( - leading: Icon(Icons.input), - title: translate("Paste here?"), - text: selectedItemsLen + local, - onCanceled: () => model.toggleSelectMode(), - actions: [ - IconButton( - icon: Icon(Icons.compare_arrows), - onPressed: model.togglePage, + ), + Padding( + padding: EdgeInsets.all(2), + child: Text( + "${translate("Total")}: ${controller.directory.value.entries.length} ${translate("items")}", + style: TextStyle(color: MyTheme.darkGray), ), - IconButton( - icon: Icon(Icons.paste), - onPressed: () { - model.toggleSelectMode(); - model.sendFiles(_selectedItems); - }, - ) - ]); - } - } + ) + ], + ), + )); - switch (state) { - case JobState.inProgress: - return BottomSheetBody( - leading: CircularProgressIndicator(), - title: translate("Waiting"), - text: - "${translate("Speed")}: ${readableFileSize(model.jobProgress.speed)}/s", - onCanceled: () => model.cancelJob(model.jobProgress.id), - ); - case JobState.done: - return BottomSheetBody( - leading: Icon(Icons.check), - title: "${translate("Successful")}!", - text: model.jobProgress.display(), - onCanceled: () => model.jobReset(), - ); - case JobState.error: - return BottomSheetBody( - leading: Icon(Icons.error), - title: "${translate("Error")}!", - text: "", - onCanceled: () => model.jobReset(), - ); - case JobState.none: - break; - case JobState.paused: - // TODO: Handle this case. - break; - } - return null; - } - - List getPathBreadCrumbItems( + List getPathBreadCrumbItems(String shortPath, bool isWindows, void Function() onHome, void Function(List) onPressed) { - final path = model.currentShortPath; - final list = PathUtil.split(path, model.getCurrentIsWindows()); + final list = PathUtil.split(shortPath, isWindows); final breadCrumbList = [ BreadCrumbItem( content: IconButton( diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index feaecd356..4170a5461 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -24,298 +24,83 @@ enum SortBy { } } -class FileModel extends ChangeNotifier { - /// mobile, current selected page show on mobile screen - var _isSelectedLocal = false; - - /// mobile, select mode state - var _selectMode = false; - - final _localOption = DirectoryOption(); - final _remoteOption = DirectoryOption(); - - List localHistory = []; - List remoteHistory = []; - - var _jobId = 0; - - final _jobProgress = JobProgress(); // from rust update - - /// JobTable - final _jobTable = List.empty(growable: true).obs; - - /// `isLocal` bool - Function(bool)? onDirChanged; - - RxList get jobTable => _jobTable; - - bool get isLocal => _isSelectedLocal; - - bool get selectMode => _selectMode; - - JobProgress get jobProgress => _jobProgress; - - JobState get jobState => _jobProgress.state; - - SortBy _sortStyle = SortBy.name; - - SortBy get sortStyle => _sortStyle; - - SortBy _localSortStyle = SortBy.name; - - bool _localSortAscending = true; - - bool _remoteSortAscending = true; - - SortBy _remoteSortStyle = SortBy.name; - - bool get localSortAscending => _localSortAscending; - - SortBy getSortStyle(bool isLocal) { - return isLocal ? _localSortStyle : _remoteSortStyle; +class JobID { + int _count = 0; + int next() { + _count++; + return _count; } +} - bool getSortAscending(bool isLocal) { - return isLocal ? _localSortAscending : _remoteSortAscending; - } - - FileDirectory _currentLocalDir = FileDirectory(); - - FileDirectory get currentLocalDir => _currentLocalDir; - - FileDirectory _currentRemoteDir = FileDirectory(); - - FileDirectory get currentRemoteDir => _currentRemoteDir; - - FileDirectory get currentDir => - _isSelectedLocal ? currentLocalDir : currentRemoteDir; - - FileDirectory getCurrentDir(bool isLocal) { - return isLocal ? currentLocalDir : currentRemoteDir; - } - - String getCurrentShortPath(bool isLocal) { - final currentDir = getCurrentDir(isLocal); - final currentHome = getCurrentHome(isLocal); - if (currentDir.path.startsWith(currentHome)) { - var path = currentDir.path.replaceFirst(currentHome, ""); - if (path.isEmpty) return ""; - if (path[0] == "/" || path[0] == "\\") { - // remove more '/' or '\' - path = path.replaceFirst(path[0], ""); - } - return path; - } else { - return currentDir.path.replaceFirst(currentHome, ""); - } - } - - String get currentHome => - _isSelectedLocal ? _localOption.home : _remoteOption.home; - - String getCurrentHome(bool isLocal) { - return isLocal ? _localOption.home : _remoteOption.home; - } - - int getJob(int id) { - return jobTable.indexWhere((element) => element.id == id); - } - - String get currentShortPath { - if (currentDir.path.startsWith(currentHome)) { - var path = currentDir.path.replaceFirst(currentHome, ""); - if (path.isEmpty) return ""; - if (path[0] == "/" || path[0] == "\\") { - // remove more '/' or '\' - path = path.replaceFirst(path[0], ""); - } - return path; - } else { - return currentDir.path.replaceFirst(currentHome, ""); - } - } - - String shortPath(bool isLocal) { - final dir = isLocal ? currentLocalDir : currentRemoteDir; - if (dir.path.startsWith(currentHome)) { - var path = dir.path.replaceFirst(currentHome, ""); - if (path.isEmpty) return ""; - if (path[0] == "/" || path[0] == "\\") { - // remove more '/' or '\' - path = path.replaceFirst(path[0], ""); - } - return path; - } else { - return dir.path.replaceFirst(currentHome, ""); - } - } - - bool getCurrentShowHidden([bool? isLocal]) { - final isLocal_ = isLocal ?? _isSelectedLocal; - return isLocal_ ? _localOption.showHidden : _remoteOption.showHidden; - } - - bool getCurrentIsWindows([bool? isLocal]) { - final isLocal_ = isLocal ?? _isSelectedLocal; - return isLocal_ ? _localOption.isWindows : _remoteOption.isWindows; - } - - final _fileFetcher = FileFetcher(); - - final _jobResultListener = JobResultListener>(); +typedef GetSessionID = String Function(); +class FileModel { final WeakReference parent; + // late final String sessionID; + late final FileFetcher fileFetcher; + late final JobController jobController; - FileModel(this.parent); + late final FileController localController; + late final FileController remoteController; - toggleSelectMode() { - if (jobState == JobState.inProgress) { - return; - } - _selectMode = !_selectMode; - notifyListeners(); + late final GetSessionID getSessionID; + String get sessionID => getSessionID(); + + FileModel(this.parent) { + getSessionID = () => parent.target?.id ?? ""; + fileFetcher = FileFetcher(getSessionID); + jobController = JobController(getSessionID); + localController = FileController( + isLocal: true, + getSessionID: getSessionID, + dialogManager: parent.target?.dialogManager, + jobController: jobController, + fileFetcher: fileFetcher, + getOtherSideDirectoryData: () => remoteController.directoryData()); + remoteController = FileController( + isLocal: false, + getSessionID: getSessionID, + dialogManager: parent.target?.dialogManager, + jobController: jobController, + fileFetcher: fileFetcher, + getOtherSideDirectoryData: () => localController.directoryData()); } - togglePage() { - _isSelectedLocal = !_isSelectedLocal; - notifyListeners(); + Future onReady() async { + await localController.onReady(); + await remoteController.onReady(); } - toggleShowHidden({bool? showHidden, bool? local}) { - final isLocal = local ?? _isSelectedLocal; - if (isLocal) { - _localOption.showHidden = showHidden ?? !_localOption.showHidden; - } else { - _remoteOption.showHidden = showHidden ?? !_remoteOption.showHidden; - } - refresh(isLocal: local); + Future close() async { + parent.target?.dialogManager.dismissAll(); + await localController.close(); + await remoteController.close(); } - tryUpdateJobProgress(Map evt) { - try { - int id = int.parse(evt['id']); - if (!isDesktop) { - _jobProgress.id = id; - _jobProgress.fileNum = int.parse(evt['file_num']); - _jobProgress.speed = double.parse(evt['speed']); - _jobProgress.finishedSize = int.parse(evt['finished_size']); - } else { - // Desktop uses jobTable - // id = index + 1 - final jobIndex = getJob(id); - if (jobIndex >= 0 && _jobTable.length > jobIndex) { - final job = _jobTable[jobIndex]; - job.fileNum = int.parse(evt['file_num']); - job.speed = double.parse(evt['speed']); - job.finishedSize = int.parse(evt['finished_size']); - debugPrint("update job $id with $evt"); - } - } - notifyListeners(); - } catch (e) { - debugPrint("Failed to tryUpdateJobProgress,evt:${evt.toString()}"); - } + Future refreshAll() async { + await localController.refresh(); + await remoteController.refresh(); } - receiveFileDir(Map evt) { + void receiveFileDir(Map evt) { if (evt['is_local'] == "false") { - // init remote home, the connection will automatic read remote home when established, - try { - final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); - fd.format(_remoteOption.isWindows, sort: _sortStyle); - if (fd.id > 0) { - final jobIndex = getJob(fd.id); - if (jobIndex != -1) { - final job = jobTable[jobIndex]; - var totalSize = 0; - var fileCount = fd.entries.length; - for (var element in fd.entries) { - totalSize += element.size; - } - job.totalSize = totalSize; - job.fileCount = fileCount; - debugPrint("update receive details:${fd.path}"); - } - } else if (_remoteOption.home.isEmpty) { - _remoteOption.home = fd.path; - debugPrint("init remote home:${fd.path}"); - _currentRemoteDir = fd; - } - } catch (e) { - debugPrint("receiveFileDir err=$e"); - } + // init remote home, the remote connection will send one dir event when established. TODO opt + remoteController.initDirAndHome(evt); } - _fileFetcher.tryCompleteTask(evt['value'], evt['is_local']); - notifyListeners(); + fileFetcher.tryCompleteTask(evt['value'], evt['is_local']); } - jobDone(Map evt) async { - if (_jobResultListener.isListening) { - _jobResultListener.complete(evt); - return; - } - if (!isDesktop) { - _selectMode = false; - _jobProgress.state = JobState.done; - } else { - int id = int.parse(evt['id']); - final jobIndex = getJob(id); - if (jobIndex != -1) { - final job = jobTable[jobIndex]; - job.finishedSize = job.totalSize; - job.state = JobState.done; - job.fileNum = int.parse(evt['file_num']); - } - } - await Future.wait([ - refresh(isLocal: false), - refresh(isLocal: true), - ]); - } - - jobError(Map evt) { - final err = evt['err'].toString(); - if (!isDesktop) { - if (_jobResultListener.isListening) { - _jobResultListener.complete(evt); - return; - } - _selectMode = false; - _jobProgress.clear(); - _jobProgress.err = err; - _jobProgress.state = JobState.error; - _jobProgress.fileNum = int.parse(evt['file_num']); - if (err == "skipped") { - _jobProgress.state = JobState.done; - _jobProgress.finishedSize = _jobProgress.totalSize; - } - } else { - int jobIndex = getJob(int.parse(evt['id'])); - if (jobIndex != -1) { - final job = jobTable[jobIndex]; - job.state = JobState.error; - job.err = err; - job.fileNum = int.parse(evt['file_num']); - if (err == "skipped") { - job.state = JobState.done; - job.finishedSize = job.totalSize; - } - } - } - debugPrint("jobError $evt"); - notifyListeners(); - } - - overrideFileConfirm(Map evt) async { + void overrideFileConfirm(Map evt) async { final resp = await showFileConfirmDialog( translate("Overwrite"), "${evt['read_path']}", true); final id = int.tryParse(evt['id']) ?? 0; if (false == resp) { - final jobIndex = getJob(id); + final jobIndex = jobController.getJob(id); if (jobIndex != -1) { - cancelJob(id); - final job = jobTable[jobIndex]; + jobController.cancelJob(id); + final job = jobController.jobTable[jobIndex]; job.state = JobState.done; + jobController.jobTable.refresh(); } } else { var need_override = false; @@ -327,7 +112,7 @@ class FileModel extends ChangeNotifier { need_override = true; } bind.sessionSetConfirmOverrideFile( - id: parent.target?.id ?? "", + id: sessionID, actId: id, fileNum: int.parse(evt['file_num']), needOverride: need_override, @@ -336,367 +121,6 @@ class FileModel extends ChangeNotifier { } } - jobReset() { - _jobProgress.clear(); - notifyListeners(); - } - - onReady() async { - _localOption.home = await bind.mainGetHomeDir(); - _localOption.showHidden = (await bind.sessionGetPeerOption( - id: parent.target?.id ?? "", name: "local_show_hidden")) - .isNotEmpty; - _localOption.isWindows = Platform.isWindows; - - _remoteOption.showHidden = (await bind.sessionGetPeerOption( - id: parent.target?.id ?? "", name: "remote_show_hidden")) - .isNotEmpty; - _remoteOption.isWindows = - parent.target?.ffiModel.pi.platform == kPeerPlatformWindows; - - await Future.delayed(Duration(milliseconds: 100)); - - final local = (await bind.sessionGetPeerOption( - id: parent.target?.id ?? "", name: "local_dir")); - final remote = (await bind.sessionGetPeerOption( - id: parent.target?.id ?? "", name: "remote_dir")); - openDirectory(local.isEmpty ? _localOption.home : local, isLocal: true); - openDirectory(remote.isEmpty ? _remoteOption.home : remote, isLocal: false); - await Future.delayed(Duration(seconds: 1)); - if (_currentLocalDir.path.isEmpty) { - openDirectory(_localOption.home, isLocal: true); - } - if (_currentRemoteDir.path.isEmpty) { - openDirectory(_remoteOption.home, isLocal: false); - } - } - - Future onClose() async { - parent.target?.dialogManager.dismissAll(); - jobReset(); - - onDirChanged = null; - - // save config - Map msgMap = {}; - - msgMap["local_dir"] = _currentLocalDir.path; - msgMap["local_show_hidden"] = _localOption.showHidden ? "Y" : ""; - msgMap["remote_dir"] = _currentRemoteDir.path; - msgMap["remote_show_hidden"] = _remoteOption.showHidden ? "Y" : ""; - final id = parent.target?.id ?? ""; - for (final msg in msgMap.entries) { - await bind.sessionPeerOption(id: id, name: msg.key, value: msg.value); - } - _currentLocalDir.clear(); - _currentRemoteDir.clear(); - _localOption.clear(); - _remoteOption.clear(); - } - - Future refresh({bool? isLocal}) async { - if (isDesktop) { - isLocal = isLocal ?? _isSelectedLocal; - isLocal - ? await openDirectory(currentLocalDir.path, isLocal: isLocal) - : await openDirectory(currentRemoteDir.path, isLocal: isLocal); - } else { - await openDirectory(currentDir.path); - } - } - - openDirectory(String path, {bool? isLocal, bool isBack = false}) async { - isLocal = isLocal ?? _isSelectedLocal; - if (path == ".") { - refresh(isLocal: isLocal); - return; - } - if (path == "..") { - goToParentDirectory(isLocal: isLocal); - return; - } - if (!isBack) { - pushHistory(isLocal); - } - final showHidden = getCurrentShowHidden(isLocal); - final isWindows = getCurrentIsWindows(isLocal); - // process /C:\ -> C:\ on Windows - if (isWindows && path.length > 1 && path[0] == '/') { - path = path.substring(1); - if (path[path.length - 1] != '\\') { - path = "$path\\"; - } - } - try { - final fd = await _fileFetcher.fetchDirectory(path, isLocal, showHidden); - fd.format(isWindows, sort: _sortStyle); - if (isLocal) { - _currentLocalDir = fd; - } else { - _currentRemoteDir = fd; - } - notifyListeners(); - onDirChanged?.call(isLocal); - } catch (e) { - debugPrint("Failed to openDirectory $path: $e"); - } - } - - Future fetchDirectory(path, isLocal, showHidden) async { - return await _fileFetcher.fetchDirectory(path, isLocal, showHidden); - } - - void pushHistory(bool isLocal) { - final history = isLocal ? localHistory : remoteHistory; - final currPath = isLocal ? currentLocalDir.path : currentRemoteDir.path; - if (history.isNotEmpty && history.last == currPath) { - return; - } - history.add(currPath); - } - - goHome({bool? isLocal}) { - isLocal = isLocal ?? _isSelectedLocal; - openDirectory(getCurrentHome(isLocal), isLocal: isLocal); - } - - goBack({bool? isLocal}) { - isLocal = isLocal ?? _isSelectedLocal; - final history = isLocal ? localHistory : remoteHistory; - if (history.isEmpty) return; - final path = history.removeAt(history.length - 1); - if (path.isEmpty) return; - final currPath = isLocal ? currentLocalDir.path : currentRemoteDir.path; - if (currPath == path) { - goBack(isLocal: isLocal); - return; - } - openDirectory(path, isLocal: isLocal, isBack: true); - } - - goToParentDirectory({bool? isLocal}) { - isLocal = isLocal ?? _isSelectedLocal; - final isWindows = - isLocal ? _localOption.isWindows : _remoteOption.isWindows; - final currDir = isLocal ? currentLocalDir : currentRemoteDir; - var parent = PathUtil.dirname(currDir.path, isWindows); - // specially for C:\, D:\, goto '/' - if (parent == currDir.path && isWindows) { - openDirectory('/', isLocal: isLocal); - return; - } - openDirectory(parent, isLocal: isLocal); - } - - /// isRemote only for desktop now, [isRemote == true] means [remote -> local] - sendFiles(SelectedItems items, {bool isRemote = false}) { - if (isDesktop) { - // desktop sendFiles - final toPath = isRemote ? currentLocalDir.path : currentRemoteDir.path; - final isWindows = - isRemote ? _localOption.isWindows : _remoteOption.isWindows; - final showHidden = - isRemote ? _localOption.showHidden : _remoteOption.showHidden; - for (var from in items.items) { - final jobId = ++_jobId; - _jobTable.add(JobProgress() - ..fileName = path.basename(from.path) - ..jobName = from.path - ..totalSize = from.size - ..state = JobState.inProgress - ..id = jobId - ..isRemote = isRemote); - bind.sessionSendFiles( - id: '${parent.target?.id}', - actId: _jobId, - path: from.path, - to: PathUtil.join(toPath, from.name, isWindows), - fileNum: 0, - includeHidden: showHidden, - isRemote: isRemote); - debugPrint( - "path:${from.path}, toPath:$toPath, to:${PathUtil.join(toPath, from.name, isWindows)}"); - } - } else { - if (items.isLocal == null) { - debugPrint("Failed to sendFiles ,wrong path state"); - return; - } - _jobProgress.state = JobState.inProgress; - final toPath = - items.isLocal! ? currentRemoteDir.path : currentLocalDir.path; - final isWindows = - items.isLocal! ? _localOption.isWindows : _remoteOption.isWindows; - final showHidden = - items.isLocal! ? _localOption.showHidden : _remoteOption.showHidden; - items.items.forEach((from) async { - _jobId++; - await bind.sessionSendFiles( - id: '${parent.target?.id}', - actId: _jobId, - path: from.path, - to: PathUtil.join(toPath, from.name, isWindows), - fileNum: 0, - includeHidden: showHidden, - isRemote: !(items.isLocal!)); - }); - } - } - - bool removeCheckboxRemember = false; - - removeAction(SelectedItems items, {bool? isLocal}) async { - isLocal = isLocal ?? _isSelectedLocal; - removeCheckboxRemember = false; - if (items.isLocal == null) { - debugPrint("Failed to removeFile, wrong path state"); - return; - } - final isWindows = - items.isLocal! ? _localOption.isWindows : _remoteOption.isWindows; - await Future.forEach(items.items, (Entry item) async { - _jobId++; - var title = ""; - var content = ""; - late final List entries; - if (item.isFile) { - title = translate("Are you sure you want to delete this file?"); - content = item.name; - entries = [item]; - } else if (item.isDirectory) { - title = translate("Not an empty directory"); - parent.target?.dialogManager.showLoading(translate("Waiting")); - final fd = await _fileFetcher.fetchDirectoryRecursive( - _jobId, item.path, items.isLocal!, true); - if (fd.path.isEmpty) { - fd.path = item.path; - } - fd.format(isWindows); - parent.target?.dialogManager.dismissAll(); - if (fd.entries.isEmpty) { - final confirm = await showRemoveDialog( - translate( - "Are you sure you want to delete this empty directory?"), - item.name, - false); - if (confirm == true) { - sendRemoveEmptyDir(item.path, 0, items.isLocal!); - } - return; - } - entries = fd.entries; - } else { - entries = []; - } - - for (var i = 0; i < entries.length; i++) { - final dirShow = item.isDirectory - ? "${translate("Are you sure you want to delete the file of this directory?")}\n" - : ""; - final count = entries.length > 1 ? "${i + 1}/${entries.length}" : ""; - content = "$dirShow\n\n${entries[i].path}".trim(); - final confirm = await showRemoveDialog( - count.isEmpty ? title : "$title ($count)", - content, - item.isDirectory, - ); - try { - if (confirm == true) { - sendRemoveFile(entries[i].path, i, items.isLocal!); - final res = await _jobResultListener.start(); - // handle remove res; - if (item.isDirectory && - res['file_num'] == (entries.length - 1).toString()) { - sendRemoveEmptyDir(item.path, i, items.isLocal!); - } - } - if (removeCheckboxRemember) { - if (confirm == true) { - for (var j = i + 1; j < entries.length; j++) { - sendRemoveFile(entries[j].path, j, items.isLocal!); - final res = await _jobResultListener.start(); - if (item.isDirectory && - res['file_num'] == (entries.length - 1).toString()) { - sendRemoveEmptyDir(item.path, i, items.isLocal!); - } - } - } - break; - } - } catch (e) { - print("remove error: $e"); - } - } - }); - _selectMode = false; - refresh(isLocal: isLocal); - } - - Future showRemoveDialog( - String title, String content, bool showCheckbox) async { - return await parent.target?.dialogManager.show( - (setState, Function(bool v) close) { - cancel() => close(false); - submit() => close(true); - return CustomAlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.warning_rounded, color: Colors.red), - Text(title).paddingOnly( - left: 10, - ), - ], - ), - contentBoxConstraints: - BoxConstraints(minHeight: 100, minWidth: 400, maxWidth: 400), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(content), - Text( - translate("This is irreversible!"), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ).paddingOnly(top: 20), - showCheckbox - ? CheckboxListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - title: Text( - translate("Do this for all conflicts"), - ), - value: removeCheckboxRemember, - onChanged: (v) { - if (v == null) return; - setState(() => removeCheckboxRemember = v); - }, - ) - : const SizedBox.shrink() - ], - ), - actions: [ - dialogButton( - "Cancel", - icon: Icon(Icons.close_rounded), - onPressed: cancel, - isOutline: true, - ), - dialogButton( - "OK", - icon: Icon(Icons.done_rounded), - onPressed: submit, - ), - ], - onSubmit: submit, - onCancel: cancel, - ); - }, useAnimation: false); - } - bool fileConfirmCheckboxRemember = false; Future showFileConfirmDialog( @@ -765,65 +189,542 @@ class FileModel extends ChangeNotifier { ); }, useAnimation: false); } +} - sendRemoveFile(String path, int fileNum, bool isLocal) { +class DirectoryData { + final DirectoryOptions options; + final FileDirectory directory; + DirectoryData(this.directory, this.options); +} + +class FileController { + final bool isLocal; + final GetSessionID getSessionID; + String get sessionID => getSessionID(); + + final FileFetcher fileFetcher; + + final options = DirectoryOptions().obs; + final directory = FileDirectory().obs; + + final history = RxList.empty(growable: true); + final sortBy = SortBy.name.obs; + var sortAscending = true; + final JobController jobController; + final OverlayDialogManager? dialogManager; + + final DirectoryData Function() getOtherSideDirectoryData; + late final SelectedItems selectedItems = SelectedItems(isLocal: isLocal); + + FileController( + {required this.isLocal, + required this.getSessionID, + required this.dialogManager, + required this.jobController, + required this.fileFetcher, + required this.getOtherSideDirectoryData}); + + String get homePath => options.value.home; + + String get shortPath { + final dirPath = directory.value.path; + if (dirPath.startsWith(homePath)) { + var path = dirPath.replaceFirst(homePath, ""); + if (path.isEmpty) return ""; + if (path[0] == "/" || path[0] == "\\") { + // remove more '/' or '\' + path = path.replaceFirst(path[0], ""); + } + return path; + } else { + return dirPath.replaceFirst(homePath, ""); + } + } + + DirectoryData directoryData() { + return DirectoryData(directory.value, options.value); + } + + Future onReady() async { + options.value.home = await bind.mainGetHomeDir(); + options.value.showHidden = (await bind.sessionGetPeerOption( + id: sessionID, + name: isLocal ? "local_show_hidden" : "remote_show_hidden")) + .isNotEmpty; + options.value.isWindows = Platform.isWindows; + + await Future.delayed(Duration(milliseconds: 100)); + + final dir = (await bind.sessionGetPeerOption( + id: sessionID, name: isLocal ? "local_dir" : "remote_dir")); + openDirectory(dir.isEmpty ? options.value.home : dir); + + await Future.delayed(Duration(seconds: 1)); + + if (directory.value.path.isEmpty) { + openDirectory(options.value.home); + } + } + + Future close() async { + // save config + Map msgMap = {}; + msgMap[isLocal ? "local_dir" : "remote_dir"] = directory.value.path; + msgMap[isLocal ? "local_show_hidden" : "remote_show_hidden"] = + options.value.showHidden ? "Y" : ""; + for (final msg in msgMap.entries) { + await bind.sessionPeerOption( + id: sessionID, name: msg.key, value: msg.value); + } + directory.value.clear(); + options.value.clear(); + } + + void toggleShowHidden({bool? showHidden}) { + options.value.showHidden = showHidden ?? !options.value.showHidden; + refresh(); + } + + void changeSortStyle(SortBy sort, {bool? isLocal, bool ascending = true}) { + sortBy.value = sort; + sortAscending = ascending; + directory.update((dir) { + dir?.changeSortStyle(sort, ascending: ascending); + }); + } + + Future refresh() async { + await openDirectory(directory.value.path); + } + + Future openDirectory(String path, {bool isBack = false}) async { + if (path == ".") { + refresh(); + return; + } + if (path == "..") { + goToParentDirectory(); + return; + } + if (!isBack) { + pushHistory(); + } + final showHidden = options.value.showHidden; + final isWindows = options.value.isWindows; + // process /C:\ -> C:\ on Windows + if (isWindows && path.length > 1 && path[0] == '/') { + path = path.substring(1); + if (path[path.length - 1] != '\\') { + path = "$path\\"; + } + } + try { + final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); + fd.format(isWindows, sort: sortBy.value); + directory.value = fd; + } catch (e) { + debugPrint("Failed to openDirectory $path: $e"); + } + } + + void pushHistory() { + if (history.isNotEmpty && history.last == directory.value.path) { + return; + } + history.add(directory.value.path); + } + + void goToHomeDirectory() { + openDirectory(homePath); + } + + void goBack() { + if (history.isEmpty) return; + final path = history.removeAt(history.length - 1); + if (path.isEmpty) return; + if (directory.value.path == path) { + goBack(); + return; + } + openDirectory(path, isBack: true); + } + + void goToParentDirectory() { + final isWindows = options.value.isWindows; + final dirPath = directory.value.path; + var parent = PathUtil.dirname(dirPath, isWindows); + // specially for C:\, D:\, goto '/' + if (parent == dirPath && isWindows) { + openDirectory('/'); + return; + } + openDirectory(parent); + } + + // TODO deprecated this + void initDirAndHome(Map evt) { + try { + final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); + fd.format(options.value.isWindows, sort: sortBy.value); + if (fd.id > 0) { + final jobIndex = jobController.getJob(fd.id); + if (jobIndex != -1) { + final job = jobController.jobTable[jobIndex]; + var totalSize = 0; + var fileCount = fd.entries.length; + for (var element in fd.entries) { + totalSize += element.size; + } + job.totalSize = totalSize; + job.fileCount = fileCount; + debugPrint("update receive details:${fd.path}"); + jobController.jobTable.refresh(); + } + } else if (options.value.home.isEmpty) { + options.value.home = fd.path; + debugPrint("init remote home:${fd.path}"); + directory.value = fd; + } + } catch (e) { + debugPrint("initDirAndHome err=$e"); + } + } + + /// sendFiles from current side (FileController.isLocal) to other side (SelectedItems). + void sendFiles(SelectedItems items, DirectoryData otherSideData) { + /// ignore wrong items side status + if (items.isLocal != isLocal) { + return; + } + + // alias + final isRemoteToLocal = !isLocal; + + final toPath = otherSideData.directory.path; + final isWindows = otherSideData.options.isWindows; + final showHidden = otherSideData.options.showHidden; + for (var from in items.items) { + final jobID = jobController.add(from, isRemoteToLocal); + bind.sessionSendFiles( + id: sessionID, + actId: jobID, + path: from.path, + to: PathUtil.join(toPath, from.name, isWindows), + fileNum: 0, + includeHidden: showHidden, + isRemote: isRemoteToLocal); + debugPrint( + "path:${from.path}, toPath:$toPath, to:${PathUtil.join(toPath, from.name, isWindows)}"); + } + } + + bool _removeCheckboxRemember = false; + + Future removeAction(SelectedItems items) async { + _removeCheckboxRemember = false; + if (items.isLocal != isLocal) { + debugPrint("Failed to removeFile, wrong files"); + return; + } + final isWindows = options.value.isWindows; + await Future.forEach(items.items, (Entry item) async { + final jobID = JobController.jobID.next(); + var title = ""; + var content = ""; + late final List entries; + if (item.isFile) { + title = translate("Are you sure you want to delete this file?"); + content = item.name; + entries = [item]; + } else if (item.isDirectory) { + title = translate("Not an empty directory"); + dialogManager?.showLoading(translate("Waiting")); + final fd = await fileFetcher.fetchDirectoryRecursive( + jobID, item.path, items.isLocal!, true); + if (fd.path.isEmpty) { + fd.path = item.path; + } + fd.format(isWindows); + dialogManager?.dismissAll(); + if (fd.entries.isEmpty) { + final confirm = await showRemoveDialog( + translate( + "Are you sure you want to delete this empty directory?"), + item.name, + false); + if (confirm == true) { + sendRemoveEmptyDir(item.path, 0); + } + return; + } + entries = fd.entries; + } else { + entries = []; + } + + for (var i = 0; i < entries.length; i++) { + final dirShow = item.isDirectory + ? "${translate("Are you sure you want to delete the file of this directory?")}\n" + : ""; + final count = entries.length > 1 ? "${i + 1}/${entries.length}" : ""; + content = "$dirShow\n\n${entries[i].path}".trim(); + final confirm = await showRemoveDialog( + count.isEmpty ? title : "$title ($count)", + content, + item.isDirectory, + ); + try { + if (confirm == true) { + sendRemoveFile(entries[i].path, i); + final res = await jobController.jobResultListener.start(); + // handle remove res; + if (item.isDirectory && + res['file_num'] == (entries.length - 1).toString()) { + sendRemoveEmptyDir(item.path, i); + } + } + if (_removeCheckboxRemember) { + if (confirm == true) { + for (var j = i + 1; j < entries.length; j++) { + sendRemoveFile(entries[j].path, j); + final res = await jobController.jobResultListener.start(); + if (item.isDirectory && + res['file_num'] == (entries.length - 1).toString()) { + sendRemoveEmptyDir(item.path, i); + } + } + } + break; + } + } catch (e) { + print("remove error: $e"); + } + } + }); + refresh(); + } + + Future showRemoveDialog( + String title, String content, bool showCheckbox) async { + return await dialogManager?.show((setState, Function(bool v) close) { + cancel() => close(false); + submit() => close(true); + return CustomAlertDialog( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.warning_rounded, color: Colors.red), + Text(title).paddingOnly( + left: 10, + ), + ], + ), + contentBoxConstraints: + BoxConstraints(minHeight: 100, minWidth: 400, maxWidth: 400), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(content), + Text( + translate("This is irreversible!"), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ).paddingOnly(top: 20), + showCheckbox + ? CheckboxListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + translate("Do this for all conflicts"), + ), + value: _removeCheckboxRemember, + onChanged: (v) { + if (v == null) return; + setState(() => _removeCheckboxRemember = v); + }, + ) + : const SizedBox.shrink() + ], + ), + actions: [ + dialogButton( + "Cancel", + icon: Icon(Icons.close_rounded), + onPressed: cancel, + isOutline: true, + ), + dialogButton( + "OK", + icon: Icon(Icons.done_rounded), + onPressed: submit, + ), + ], + onSubmit: submit, + onCancel: cancel, + ); + }, useAnimation: false); + } + + void sendRemoveFile(String path, int fileNum) { bind.sessionRemoveFile( - id: '${parent.target?.id}', - actId: _jobId, + id: sessionID, + actId: JobController.jobID.next(), path: path, isRemote: !isLocal, fileNum: fileNum); } - sendRemoveEmptyDir(String path, int fileNum, bool isLocal) { - final history = isLocal ? localHistory : remoteHistory; + void sendRemoveEmptyDir(String path, int fileNum) { history.removeWhere((element) => element.contains(path)); bind.sessionRemoveAllEmptyDirs( - id: '${parent.target?.id}', - actId: _jobId, + id: sessionID, + actId: JobController.jobID.next(), path: path, isRemote: !isLocal); } - createDir(String path, {bool? isLocal}) async { - isLocal = isLocal ?? this.isLocal; - _jobId++; + Future createDir(String path) async { bind.sessionCreateDir( - id: '${parent.target?.id}', - actId: _jobId, + id: sessionID, + actId: JobController.jobID.next(), path: path, isRemote: !isLocal); } +} - cancelJob(int id) async { - bind.sessionCancelJob(id: '${parent.target?.id}', actId: id); - jobReset(); +class JobController { + static final JobID jobID = JobID(); + final jobTable = List.empty(growable: true).obs; + final jobResultListener = JobResultListener>(); + final GetSessionID getSessionID; + String get sessionID => getSessionID(); + + JobController(this.getSessionID); + + int getJob(int id) { + return jobTable.indexWhere((element) => element.id == id); } - changeSortStyle(SortBy sort, {bool? isLocal, bool ascending = true}) { - _sortStyle = sort; - if (isLocal == null) { - // compatible for mobile logic - _currentLocalDir.changeSortStyle(sort, ascending: ascending); - _currentRemoteDir.changeSortStyle(sort, ascending: ascending); - _localSortStyle = sort; - _localSortAscending = ascending; - _remoteSortStyle = sort; - _remoteSortAscending = ascending; - } else if (isLocal) { - _currentLocalDir.changeSortStyle(sort, ascending: ascending); - _localSortStyle = sort; - _localSortAscending = ascending; - } else { - _currentRemoteDir.changeSortStyle(sort, ascending: ascending); - _remoteSortStyle = sort; - _remoteSortAscending = ascending; + // JobProgress? getJob(int id) { + // return jobTable.firstWhere((element) => element.id == id); + // } + + // return jobID + int add(Entry from, bool isRemoteToLocal) { + final jobID = JobController.jobID.next(); + jobTable.add(JobProgress() + ..fileName = path.basename(from.path) + ..jobName = from.path + ..totalSize = from.size + ..state = JobState.inProgress + ..id = jobID + ..isRemoteToLocal = isRemoteToLocal); + return jobID; + } + + void tryUpdateJobProgress(Map evt) { + try { + int id = int.parse(evt['id']); + // id = index + 1 + final jobIndex = getJob(id); + if (jobIndex >= 0 && jobTable.length > jobIndex) { + final job = jobTable[jobIndex]; + job.fileNum = int.parse(evt['file_num']); + job.speed = double.parse(evt['speed']); + job.finishedSize = int.parse(evt['finished_size']); + debugPrint("update job $id with $evt"); + jobTable.refresh(); + } + } catch (e) { + debugPrint("Failed to tryUpdateJobProgress,evt:${evt.toString()}"); } - notifyListeners(); } - initFileFetcher() { - _fileFetcher.id = parent.target?.id; + void jobDone(Map evt) async { + if (jobResultListener.isListening) { + jobResultListener.complete(evt); + return; + } + + int id = int.parse(evt['id']); + final jobIndex = getJob(id); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + job.finishedSize = job.totalSize; + job.state = JobState.done; + job.fileNum = int.parse(evt['file_num']); + jobTable.refresh(); + } + } + + void jobError(Map evt) { + final err = evt['err'].toString(); + int jobIndex = getJob(int.parse(evt['id'])); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + job.state = JobState.error; + job.err = err; + job.fileNum = int.parse(evt['file_num']); + if (err == "skipped") { + job.state = JobState.done; + job.finishedSize = job.totalSize; + } + jobTable.refresh(); + } + debugPrint("jobError $evt"); + } + + void cancelJob(int id) async { + bind.sessionCancelJob(id: sessionID, actId: id); + } + + void loadLastJob(Map evt) { + debugPrint("load last job: $evt"); + Map jobDetail = json.decode(evt['value']); + // int id = int.parse(jobDetail['id']); + String remote = jobDetail['remote']; + String to = jobDetail['to']; + bool showHidden = jobDetail['show_hidden']; + int fileNum = jobDetail['file_num']; + bool isRemote = jobDetail['is_remote']; + final currJobId = JobController.jobID.next(); + String fileName = path.basename(isRemote ? remote : to); + var jobProgress = JobProgress() + ..fileName = fileName + ..jobName = isRemote ? remote : to + ..id = currJobId + ..isRemoteToLocal = isRemote + ..fileNum = fileNum + ..remote = remote + ..to = to + ..showHidden = showHidden + ..state = JobState.paused; + jobTable.add(jobProgress); + bind.sessionAddJob( + id: sessionID, + isRemote: isRemote, + includeHidden: showHidden, + actId: currJobId, + path: isRemote ? remote : to, + to: isRemote ? to : remote, + fileNum: fileNum, + ); + } + + void resumeJob(int jobId) { + final jobIndex = getJob(jobId); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + bind.sessionResumeJob( + id: sessionID, actId: job.id, isRemote: job.isRemoteToLocal); + job.state = JobState.inProgress; + jobTable.refresh(); + } else { + debugPrint("jobId $jobId is not exists"); + } } void updateFolderFiles(Map evt) { @@ -837,57 +738,9 @@ class FileModel extends ChangeNotifier { final job = jobTable[jobIndex]; job.fileCount = num_entries; job.totalSize = total_size.toInt(); + jobTable.refresh(); } debugPrint("update folder files: $info"); - notifyListeners(); - } - - bool get remoteSortAscending => _remoteSortAscending; - - void loadLastJob(Map evt) { - debugPrint("load last job: $evt"); - Map jobDetail = json.decode(evt['value']); - // int id = int.parse(jobDetail['id']); - String remote = jobDetail['remote']; - String to = jobDetail['to']; - bool showHidden = jobDetail['show_hidden']; - int fileNum = jobDetail['file_num']; - bool isRemote = jobDetail['is_remote']; - final currJobId = _jobId++; - String fileName = path.basename(isRemote ? remote : to); - var jobProgress = JobProgress() - ..fileName = fileName - ..jobName = isRemote ? remote : to - ..id = currJobId - ..isRemote = isRemote - ..fileNum = fileNum - ..remote = remote - ..to = to - ..showHidden = showHidden - ..state = JobState.paused; - jobTable.add(jobProgress); - bind.sessionAddJob( - id: '${parent.target?.id}', - isRemote: isRemote, - includeHidden: showHidden, - actId: currJobId, - path: isRemote ? remote : to, - to: isRemote ? to : remote, - fileNum: fileNum, - ); - } - - resumeJob(int jobId) { - final jobIndex = getJob(jobId); - if (jobIndex != -1) { - final job = jobTable[jobIndex]; - bind.sessionResumeJob( - id: '${parent.target?.id}', actId: job.id, isRemote: job.isRemote); - job.state = JobState.inProgress; - } else { - debugPrint("jobId $jobId is not exists"); - } - notifyListeners(); } } @@ -936,10 +789,10 @@ class FileFetcher { Map> remoteTasks = {}; Map> readRecursiveTasks = {}; - String? id; + final GetSessionID getSessionID; + String get sessionID => getSessionID(); - // if id == null, means to fetch global FFI - FFI get _ffi => ffi(id ?? ""); + FileFetcher(this.getSessionID); Future registerReadTask(bool isLocal, String path) { // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later @@ -958,16 +811,16 @@ class FileFetcher { return c.future; } - Future registerReadRecursiveTask(int id) { + Future registerReadRecursiveTask(int actID) { final tasks = readRecursiveTasks; - if (tasks.containsKey(id)) { + if (tasks.containsKey(actID)) { throw "Failed to registerRemoveTask, already have same ReadRecursive job"; } final c = Completer(); - tasks[id] = c; + tasks[actID] = c; Timer(Duration(seconds: 2), () { - tasks.remove(id); + tasks.remove(actID); if (c.isCompleted) return; c.completeError("Failed to read dir,timeout"); }); @@ -1002,12 +855,12 @@ class FileFetcher { try { if (isLocal) { final res = await bind.sessionReadLocalDirSync( - id: id ?? "", path: path, showHidden: showHidden); + id: sessionID ?? "", path: path, showHidden: showHidden); final fd = FileDirectory.fromJson(jsonDecode(res)); return fd; } else { await bind.sessionReadRemoteDir( - id: id ?? "", path: path, includeHidden: showHidden); + id: sessionID ?? "", path: path, includeHidden: showHidden); return registerReadTask(isLocal, path); } } catch (e) { @@ -1016,16 +869,16 @@ class FileFetcher { } Future fetchDirectoryRecursive( - int id, String path, bool isLocal, bool showHidden) async { + int actID, String path, bool isLocal, bool showHidden) async { // TODO test Recursive is show hidden default? try { await bind.sessionReadDirRecursive( - id: _ffi.id, - actId: id, + id: sessionID, + actId: actID, path: path, isRemote: !isLocal, showHidden: showHidden); - return registerReadRecursiveTask(id); + return registerReadRecursiveTask(actID); } catch (e) { return Future.error(e); } @@ -1122,7 +975,10 @@ class JobProgress { var finishedSize = 0; var totalSize = 0; var fileCount = 0; - var isRemote = false; + // [isRemote == true] means [remote -> local] + // var isRemote = false; + // to-do use enum + var isRemoteToLocal = false; var jobName = ""; var fileName = ""; var remote = ""; @@ -1179,12 +1035,12 @@ class PathUtil { } } -class DirectoryOption { +class DirectoryOptions { String home; bool showHidden; bool isWindows; - DirectoryOption( + DirectoryOptions( {this.home = "", this.showHidden = false, this.isWindows = false}); clear() { @@ -1195,53 +1051,37 @@ class DirectoryOption { } class SelectedItems { - bool? _isLocal; - final List _items = []; + final bool isLocal; + final items = RxList.empty(growable: true); - List get items => _items; + SelectedItems({required this.isLocal}); - int get length => _items.length; - - bool? get isLocal => _isLocal; - - add(bool isLocal, Entry e) { + void add(Entry e) { if (e.isDrive) return; - _isLocal ??= isLocal; - if (_isLocal != null && _isLocal != isLocal) { - return; - } - if (!_items.contains(e)) { - _items.add(e); + if (!items.contains(e)) { + items.add(e); } } - bool contains(Entry e) { - return _items.contains(e); + void remove(Entry e) { + items.remove(e); } - remove(Entry e) { - _items.remove(e); - if (_items.isEmpty) { - _isLocal = null; - } - } - - bool isOtherPage(bool currentIsLocal) { - if (_isLocal == null) { - return false; - } else { - return _isLocal != currentIsLocal; - } - } - - clear() { - _items.clear(); - _isLocal = null; + void clear() { + items.clear(); } void selectAll(List entries) { - _items.clear(); - _items.addAll(entries); + items.clear(); + items.addAll(entries); + } + + static bool valid(RxList items) { + if (items.isNotEmpty) { + // exclude DirDrive type + return items.any((item) => !item.isDrive); + } + return false; } } diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 8f46fdca8..94e28ea21 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -166,17 +166,18 @@ class FfiModel with ChangeNotifier { } else if (name == 'file_dir') { parent.target?.fileModel.receiveFileDir(evt); } else if (name == 'job_progress') { - parent.target?.fileModel.tryUpdateJobProgress(evt); + parent.target?.fileModel.jobController.tryUpdateJobProgress(evt); } else if (name == 'job_done') { - parent.target?.fileModel.jobDone(evt); + parent.target?.fileModel.jobController.jobDone(evt); + parent.target?.fileModel.refreshAll(); } else if (name == 'job_error') { - parent.target?.fileModel.jobError(evt); + parent.target?.fileModel.jobController.jobError(evt); } else if (name == 'override_file_confirm') { parent.target?.fileModel.overrideFileConfirm(evt); } else if (name == 'load_last_job') { - parent.target?.fileModel.loadLastJob(evt); + parent.target?.fileModel.jobController.loadLastJob(evt); } else if (name == 'update_folder_files') { - parent.target?.fileModel.updateFolderFiles(evt); + parent.target?.fileModel.jobController.updateFolderFiles(evt); } else if (name == 'add_connection') { parent.target?.serverModel.addConnection(evt); } else if (name == 'on_client_remove') { @@ -1575,9 +1576,6 @@ class FFI { }(); // every instance will bind a stream this.id = id; - if (isFileTransfer) { - fileModel.initFileFetcher(); - } } /// Login with [password], choose if the client should [remember] it.