rustdesk/flutter/lib/desktop/pages/file_manager_page.dart
2022-12-20 23:57:06 +09:00

1141 lines
43 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:flutter_hbb/desktop/widgets/list_search_action_listener.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:wakelock/wakelock.dart';
import '../../consts.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import '../../common.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../widgets/popup_menu.dart';
/// status of location bar
enum LocationStatus {
/// normal bread crumb bar
bread,
/// show path text field
pathLocation,
/// show file search bar text field
fileSearchBar
}
/// The status of currently focused scope of the mouse
enum MouseFocusScope {
/// Mouse is in local field.
local,
/// Mouse is in remote field.
remote,
/// Mouse is not in local field, remote neither.
none
}
class FileManagerPage extends StatefulWidget {
const FileManagerPage({Key? key, required this.id}) : super(key: key);
final String id;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
}
class _FileManagerPageState extends State<FileManagerPage>
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>(MouseFocusScope.none);
final _keyboardNodeLocal = FocusNode(debugLabel: "keyboardNodeLocal");
final _keyboardNodeRemote = FocusNode(debugLabel: "keyboardNodeRemote");
final _listSearchBufferLocal = TimeoutStringBuffer();
final _listSearchBufferRemote = TimeoutStringBuffer();
/// [_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
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;
}
@override
void initState() {
super.initState();
_ffi = FFI();
_ffi.start(widget.id, isFileTransfer: true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
Get.put(_ffi, tag: 'ft_${widget.id}');
if (!Platform.isLinux) {
Wakelock.enable();
}
debugPrint("File manager page init success with id ${widget.id}");
model.onDirChanged = breadCrumbScrollToEnd;
// register location listener
_locationNodeLocal.addListener(onLocalLocationFocusChanged);
_locationNodeRemote.addListener(onRemoteLocationFocusChanged);
}
@override
void dispose() {
model.onClose().whenComplete(() {
_ffi.close();
_ffi.dialogManager.dismissAll();
if (!Platform.isLinux) {
Wakelock.disable();
}
Get.delete<FFI>(tag: 'ft_${widget.id}');
_locationNodeLocal.removeListener(onLocalLocationFocusChanged);
_locationNodeRemote.removeListener(onRemoteLocationFocusChanged);
_locationNodeLocal.dispose();
_locationNodeRemote.dispose();
});
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Overlay(initialEntries: [
OverlayEntry(builder: (context) {
_ffi.dialogManager.setOverlayState(Overlay.of(context));
return ChangeNotifierProvider.value(
value: _ffi.fileModel,
child: Consumer<FileModel>(builder: (context, model, child) {
return Scaffold(
backgroundColor: Theme.of(context).backgroundColor,
body: Row(
children: [
Flexible(flex: 3, child: body(isLocal: true)),
Flexible(flex: 3, child: body(isLocal: false)),
Flexible(flex: 2, child: statusList())
],
),
);
}));
})
]);
}
Widget menu({bool isLocal = false}) {
var menuPos = RelativeRect.fill;
final List<MenuEntryBase<String>> items = [
MenuEntrySwitch<String>(
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: IconButton(
icon: const Icon(Icons.more_vert),
splashRadius: kDesktopIconButtonSplashRadius,
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,
),
));
}
Widget body({bool isLocal = false}) {
final scrollController = ScrollController();
return Container(
decoration: BoxDecoration(border: Border.all(color: Colors.black26)),
margin: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(8.0),
child: DropTarget(
onDragDone: (detail) => handleDragDone(detail, 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: SingleChildScrollView(
controller: scrollController,
child: _buildDataTable(context, isLocal, scrollController),
),
)
],
)),
]),
),
);
}
Widget _buildDataTable(
BuildContext context, bool isLocal, ScrollController scrollController) {
const rowHeight = 25.0;
final fd = model.getCurrentDir(isLocal);
final entries = fd.entries;
final sortIndex = (SortBy style) {
switch (style) {
case SortBy.name:
return 0;
case SortBy.type:
return 0;
case SortBy.modified:
return 1;
case SortBy.size:
return 2;
}
}(model.getSortStyle(isLocal));
final sortAscending =
isLocal ? model.localSortAscending : model.remoteSortAscending;
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);
final selectedEntries = getSelectedItems(isLocal);
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.startsWith(buffer));
if (searchResult.isEmpty) {
// cannot find next, lets restart search from head
searchResult =
entries.where((element) => element.name.startsWith(buffer));
}
if (searchResult.isEmpty) {
setState(() {
getSelectedItems(isLocal).clear();
});
return;
}
_jumpToEntry(
isLocal, searchResult.first, scrollController, rowHeight, buffer);
},
onSearch: (buffer) {
debugPrint("searching for $buffer");
final selectedEntries = getSelectedItems(isLocal);
final searchResult =
entries.where((element) => element.name.startsWith(buffer));
selectedEntries.clear();
if (searchResult.isEmpty) {
setState(() {
getSelectedItems(isLocal).clear();
});
return;
}
_jumpToEntry(
isLocal, searchResult.first, scrollController, rowHeight, buffer);
},
child: ObxValue<RxString>(
(searchText) {
final filteredEntries = searchText.isNotEmpty
? entries.where((element) {
return element.name.contains(searchText.value);
}).toList(growable: false)
: entries;
return DataTable(
key: ValueKey(isLocal ? 0 : 1),
showCheckboxColumn: false,
dataRowHeight: rowHeight,
headingRowHeight: 30,
horizontalMargin: 8,
columnSpacing: 8,
showBottomBorder: true,
sortColumnIndex: sortIndex,
sortAscending: sortAscending,
columns: [
DataColumn(
label: Text(
translate("Name"),
).marginSymmetric(horizontal: 4),
onSort: (columnIndex, ascending) {
model.changeSortStyle(SortBy.name,
isLocal: isLocal, ascending: ascending);
}),
DataColumn(
label: Text(
translate("Modified"),
),
onSort: (columnIndex, ascending) {
model.changeSortStyle(SortBy.modified,
isLocal: isLocal, ascending: ascending);
}),
DataColumn(
label: Text(translate("Size")),
onSort: (columnIndex, ascending) {
model.changeSortStyle(SortBy.size,
isLocal: isLocal, ascending: ascending);
}),
],
rows: filteredEntries.map((entry) {
final sizeStr =
entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
final lastModifiedStr = entry.isDrive
? " "
: "${entry.lastModified().toString().replaceAll(".000", "")} ";
return DataRow(
key: ValueKey(entry.name),
onSelectChanged: (s) {
_onSelectedChanged(getSelectedItems(isLocal),
filteredEntries, entry, isLocal);
},
selected: getSelectedItems(isLocal).contains(entry),
cells: [
DataCell(
Container(
width: 200,
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)
: Icon(
entry.isFile
? Icons.feed_outlined
: Icons.folder,
size: 20,
color: Theme.of(context)
.iconTheme
.color
?.withOpacity(0.7),
).marginSymmetric(horizontal: 2),
Expanded(
child: Text(entry.name,
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);
},
),
DataCell(FittedBox(
child: Tooltip(
waitDuration: Duration(milliseconds: 500),
message: lastModifiedStr,
child: Text(
lastModifiedStr,
style: TextStyle(
fontSize: 12, color: MyTheme.darkGray),
)))),
DataCell(Tooltip(
waitDuration: Duration(milliseconds: 500),
message: sizeStr,
child: Text(
sizeStr,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10, color: MyTheme.darkGray),
))),
]);
}).toList(growable: false),
);
},
isLocal ? _searchTextLocal : _searchTextRemote,
),
),
);
}
void _jumpToEntry(bool isLocal, Entry entry,
ScrollController scrollController, double rowHeight, String buffer) {
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.name.startsWith(buffer));
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<Entry> 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<int> 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;
}
/// transfer status list
/// watch transfer status
Widget statusList() {
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),
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
child: Obx(
() => ListView.builder(
controller: ScrollController(),
itemBuilder: (BuildContext context, int index) {
final item = model.jobTable[index];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Transform.rotate(
angle: item.isRemote ? pi : 0,
child: const Icon(Icons.send)),
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.jobName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)),
Wrap(
children: [
Text(
'${item.display()} ${max(0, item.fileNum)}/${item.fileCount} '),
Text(
'${translate("files")} ${readableFileSize(item.totalSize.toDouble())} '),
Offstage(
offstage:
item.state != JobState.inProgress,
child: Text(
'${"${readableFileSize(item.speed)}/s"} ')),
Offstage(
offstage: item.totalSize <= 0,
child: Text(
'${(item.finishedSize.toDouble() * 100 / item.totalSize.toDouble()).toStringAsFixed(2)}%'),
),
],
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Offstage(
offstage: item.state != JobState.paused,
child: IconButton(
onPressed: () {
model.resumeJob(item.id);
},
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.restart_alt_rounded)),
),
IconButton(
icon: const Icon(Icons.close),
splashRadius: 1,
onPressed: () {
model.jobTable.removeAt(index);
model.cancelJob(item.id);
},
),
],
)
],
),
SizedBox(
height: 8.0,
),
Divider(
height: 2.0,
)
],
);
},
itemCount: model.jobTable.length,
),
),
));
}
Widget headTools(bool isLocal) {
final locationStatus =
isLocal ? _locationStatusLocal : _locationStatusRemote;
final locationFocus = isLocal ? _locationNodeLocal : _locationNodeRemote;
final selectedItems = getSelectedItems(isLocal);
return Container(
child: Column(
children: [
// symbols
PreferredSize(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(color: Colors.blue),
padding: EdgeInsets.all(8.0),
child: FutureBuilder<String>(
future: bind.sessionGetPlatform(
id: _ffi.id, isRemote: !isLocal),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return getPlatformImage('${snapshot.data}');
} else {
return CircularProgressIndicator(
color: Colors.white,
);
}
})),
Text(isLocal
? translate("Local Computer")
: translate("Remote Computer"))
.marginOnly(left: 8.0)
],
),
preferredSize: Size(double.infinity, 70)),
// buttons
Row(
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
splashRadius: kDesktopIconButtonSplashRadius,
onPressed: () {
selectedItems.clear();
model.goBack(isLocal: isLocal);
},
),
IconButton(
icon: const Icon(Icons.arrow_upward),
splashRadius: kDesktopIconButtonSplashRadius,
onPressed: () {
selectedItems.clear();
model.goToParentDirectory(isLocal: isLocal);
},
),
],
),
Expanded(
child: GestureDetector(
onTap: () {
locationStatus.value =
locationStatus.value == LocationStatus.bread
? LocationStatus.pathLocation
: LocationStatus.bread;
Future.delayed(Duration.zero, () {
if (locationStatus.value == LocationStatus.pathLocation) {
locationFocus.requestFocus();
}
});
},
child: Obx(() => Container(
decoration: BoxDecoration(
border: Border.all(
color: locationStatus.value == LocationStatus.bread
? Colors.black12
: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.5))),
child: Row(
children: [
Expanded(
child: locationStatus.value == LocationStatus.bread
? buildBread(isLocal)
: buildPathLocation(isLocal)),
],
))),
)),
Obx(() {
switch (locationStatus.value) {
case LocationStatus.bread:
return IconButton(
onPressed: () {
locationStatus.value = LocationStatus.fileSearchBar;
final focusNode =
isLocal ? _locationNodeLocal : _locationNodeRemote;
Future.delayed(
Duration.zero, () => focusNode.requestFocus());
},
splashRadius: kDesktopIconButtonSplashRadius,
icon: Icon(Icons.search));
case LocationStatus.pathLocation:
return IconButton(
color: Theme.of(context).disabledColor,
onPressed: null,
splashRadius: kDesktopIconButtonSplashRadius,
icon: Icon(Icons.close));
case LocationStatus.fileSearchBar:
return IconButton(
color: Theme.of(context).disabledColor,
onPressed: () {
onSearchText("", isLocal);
locationStatus.value = LocationStatus.bread;
},
splashRadius: 1,
icon: Icon(Icons.close));
}
}),
IconButton(
onPressed: () {
model.refresh(isLocal: isLocal);
},
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.refresh)),
],
),
Row(
textDirection: isLocal ? TextDirection.ltr : TextDirection.rtl,
children: [
Expanded(
child: Row(
mainAxisAlignment:
isLocal ? MainAxisAlignment.start : MainAxisAlignment.end,
children: [
IconButton(
onPressed: () {
model.goHome(isLocal: isLocal);
},
icon: const Icon(Icons.home_outlined),
splashRadius: kDesktopIconButtonSplashRadius,
),
IconButton(
onPressed: () {
final name = TextEditingController();
_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);
close();
}
}
cancel() => close(false);
return CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText: translate(
"Please enter the folder name"),
),
controller: name,
focusNode: FocusNode()..requestFocus(),
),
],
),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: cancel,
child: Text(translate("Cancel"))),
ElevatedButton(
style: flatButtonStyle,
onPressed: submit,
child: Text(translate("OK")))
],
onSubmit: submit,
onCancel: cancel,
);
});
},
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.create_new_folder_outlined)),
IconButton(
onPressed: validItems(selectedItems)
? () async {
await (model.removeAction(selectedItems,
isLocal: isLocal));
selectedItems.clear();
}
: null,
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.delete_forever_outlined)),
menu(isLocal: isLocal),
],
),
),
TextButton.icon(
onPressed: validItems(selectedItems)
? () {
model.sendFiles(selectedItems, isRemote: !isLocal);
selectedItems.clear();
}
: null,
icon: Transform.rotate(
angle: isLocal ? 0 : pi,
child: const Icon(
Icons.send,
),
),
label: Text(
isLocal ? translate('Send') : translate('Receive'),
)),
],
).marginOnly(top: 8.0)
],
));
}
bool validItems(SelectedItems items) {
if (items.length > 0) {
// exclude DirDrive type
return items.items.any((item) => !item.isDrive);
}
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;
}
}
}
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 buildBread(bool isLocal) {
final items = getPathBreadCrumbItems(isLocal, (list) {
var path = "";
for (var item in list) {
path = PathUtil.join(path, item, model.getCurrentIsWindows(isLocal));
}
openDirectory(path, isLocal: isLocal);
});
final locationBarKey = getLocationBarKey(isLocal);
return items.isEmpty
? Offstage()
: Row(
key: locationBarKey,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Listener(
// handle mouse wheel
onPointerSignal: (e) {
if (e is PointerScrollEvent) {
final sc = getBreadCrumbScrollController(isLocal);
final scale = Platform.isWindows ? 2 : 4;
sc.jumpTo(sc.offset + e.scrollDelta.dy / scale);
}
},
child: BreadCrumb(
items: items,
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(
controller:
getBreadCrumbScrollController(isLocal)),
))),
ActionIcon(
message: "",
icon: Icons.arrow_drop_down,
onTap: () async {
final renderBox = locationBarKey.currentContext
?.findRenderObject() as RenderBox;
locationBarKey.currentContext?.size;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final x = offset.dx;
final y = offset.dy + size.height + 1;
final isPeerWindows = model.getCurrentIsWindows(isLocal);
final List<MenuEntryBase> menuItems = [
MenuEntryButton(
childBuilder: (TextStyle? style) => isPeerWindows
? buildWindowsThisPC(style)
: Text(
'/',
style: style,
),
proc: () {
openDirectory('/', isLocal: isLocal);
},
dismissOnClicked: true),
MenuEntryDivider()
];
if (isPeerWindows) {
var loadingTag = "";
if (!isLocal) {
loadingTag = _ffi.dialogManager.showLoading("Waiting");
}
try {
final fd =
await model.fetchDirectory("/", isLocal, isLocal);
for (var entry in fd.entries) {
menuItems.add(MenuEntryButton(
childBuilder: (TextStyle? style) =>
Row(children: [
Image(
image: iconHardDrive,
fit: BoxFit.scaleDown,
color: Theme.of(context)
.iconTheme
.color
?.withOpacity(0.7)),
SizedBox(width: 10),
Text(
entry.name,
style: style,
)
]),
proc: () {
openDirectory('${entry.name}\\',
isLocal: isLocal);
},
dismissOnClicked: true));
}
} catch (e) {
debugPrint("buildBread fetchDirectory err=$e");
} finally {
if (!isLocal) {
_ffi.dialogManager.dismissByTag(loadingTag);
}
}
}
menuItems.add(MenuEntryDivider());
mod_menu.showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
elevation: 4,
items: menuItems
.map((e) => e.build(
context,
MenuConfig(
commonColor:
CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight:
CustomPopupMenuTheme.dividerHeight,
boxWidth: size.width)))
.expand((i) => i)
.toList());
},
iconSize: 20,
)
]);
}
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<BreadCrumbItem> getPathBreadCrumbItems(
bool isLocal, void Function(List<String>) onPressed) {
final path = model.getCurrentDir(isLocal).path;
final breadCrumbList = List<BreadCrumbItem>.empty(growable: true);
final isWindows = model.getCurrentIsWindows(isLocal);
if (isWindows && path == '/') {
breadCrumbList.add(BreadCrumbItem(
content: TextButton(
child: buildWindowsThisPC(),
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(Size(0, 0))),
onPressed: () => onPressed(['/']))
.marginSymmetric(horizontal: 4)));
} else {
final list = PathUtil.split(path, isWindows);
breadCrumbList.addAll(list.asMap().entries.map((e) => BreadCrumbItem(
content: TextButton(
child: Text(e.value),
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(Size(0, 0))),
onPressed: () => onPressed(list.sublist(0, e.key + 1)))
.marginSymmetric(horizontal: 4))));
}
return breadCrumbList;
}
breadCrumbScrollToEnd(bool isLocal) {
Future.delayed(Duration(milliseconds: 200), () {
final breadCrumbScroller = getBreadCrumbScrollController(isLocal);
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;
final textController = TextEditingController(text: text)
..selection = TextSelection.collapsed(offset: text.length);
return Row(children: [
Icon(
locationStatus.value == LocationStatus.pathLocation
? Icons.folder
: Icons.search,
color: Theme.of(context).hintColor,
).paddingSymmetric(horizontal: 2),
Expanded(
child: TextField(
focusNode: focusNode,
decoration: InputDecoration(
border: InputBorder.none,
isDense: true,
prefix: Padding(padding: EdgeInsets.only(left: 4.0))),
controller: textController,
onSubmitted: (path) {
openDirectory(path, isLocal: isLocal);
},
onChanged: locationStatus.value == LocationStatus.fileSearchBar
? (searchText) => onSearchText(searchText, isLocal)
: null,
))
]);
}
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();
}
});
}
}