Merge pull request #2540 from Kingtous/master
feat: add list file search listener and simulate behaviors of the listview on desktop
This commit is contained in:
commit
bb85e994e1
@ -7,6 +7,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.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/desktop/widgets/tabbar_widget.dart';
|
||||||
import 'package:flutter_hbb/models/file_model.dart';
|
import 'package:flutter_hbb/models/file_model.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@ -32,6 +33,18 @@ enum LocationStatus {
|
|||||||
fileSearchBar
|
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 {
|
class FileManagerPage extends StatefulWidget {
|
||||||
const FileManagerPage({Key? key, required this.id}) : super(key: key);
|
const FileManagerPage({Key? key, required this.id}) : super(key: key);
|
||||||
final String id;
|
final String id;
|
||||||
@ -55,6 +68,11 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
final _searchTextRemote = "".obs;
|
final _searchTextRemote = "".obs;
|
||||||
final _breadCrumbScrollerLocal = ScrollController();
|
final _breadCrumbScrollerLocal = ScrollController();
|
||||||
final _breadCrumbScrollerRemote = 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
|
/// [_lastClickTime], [_lastClickEntry] help to handle double click
|
||||||
int _lastClickTime =
|
int _lastClickTime =
|
||||||
@ -197,6 +215,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget body({bool isLocal = false}) {
|
Widget body({bool isLocal = false}) {
|
||||||
|
final scrollController = ScrollController();
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(border: Border.all(color: Colors.black26)),
|
decoration: BoxDecoration(border: Border.all(color: Colors.black26)),
|
||||||
margin: const EdgeInsets.all(16.0),
|
margin: const EdgeInsets.all(16.0),
|
||||||
@ -217,8 +236,8 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: ScrollController(),
|
controller: scrollController,
|
||||||
child: _buildDataTable(context, isLocal),
|
child: _buildDataTable(context, isLocal, scrollController),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@ -228,7 +247,9 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDataTable(BuildContext context, bool isLocal) {
|
Widget _buildDataTable(
|
||||||
|
BuildContext context, bool isLocal, ScrollController scrollController) {
|
||||||
|
const rowHeight = 25.0;
|
||||||
final fd = model.getCurrentDir(isLocal);
|
final fd = model.getCurrentDir(isLocal);
|
||||||
final entries = fd.entries;
|
final entries = fd.entries;
|
||||||
final sortIndex = (SortBy style) {
|
final sortIndex = (SortBy style) {
|
||||||
@ -246,130 +267,219 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
final sortAscending =
|
final sortAscending =
|
||||||
isLocal ? model.localSortAscending : model.remoteSortAscending;
|
isLocal ? model.localSortAscending : model.remoteSortAscending;
|
||||||
|
|
||||||
return ObxValue<RxString>(
|
return MouseRegion(
|
||||||
(searchText) {
|
onEnter: (evt) {
|
||||||
final filteredEntries = searchText.isNotEmpty
|
_mouseFocusScope.value =
|
||||||
? entries.where((element) {
|
isLocal ? MouseFocusScope.local : MouseFocusScope.remote;
|
||||||
return element.name.contains(searchText.value);
|
if (isLocal) {
|
||||||
}).toList(growable: false)
|
_keyboardNodeLocal.requestFocus();
|
||||||
: entries;
|
} else {
|
||||||
return DataTable(
|
_keyboardNodeRemote.requestFocus();
|
||||||
key: ValueKey(isLocal ? 0 : 1),
|
}
|
||||||
showCheckboxColumn: false,
|
},
|
||||||
dataRowHeight: 25,
|
onExit: (evt) {
|
||||||
headingRowHeight: 30,
|
_mouseFocusScope.value = MouseFocusScope.none;
|
||||||
horizontalMargin: 8,
|
},
|
||||||
columnSpacing: 8,
|
child: ListSearchActionListener(
|
||||||
showBottomBorder: true,
|
node: isLocal ? _keyboardNodeLocal : _keyboardNodeRemote,
|
||||||
sortColumnIndex: sortIndex,
|
buffer: isLocal ? _listSearchBufferLocal : _listSearchBufferRemote,
|
||||||
sortAscending: sortAscending,
|
onNext: (buffer) {
|
||||||
columns: [
|
debugPrint("searching next for $buffer");
|
||||||
DataColumn(
|
assert(buffer.length == 1);
|
||||||
label: Text(
|
final selectedEntries = getSelectedItems(isLocal);
|
||||||
translate("Name"),
|
assert(selectedEntries.length <= 1);
|
||||||
).marginSymmetric(horizontal: 4),
|
var skipCount = 0;
|
||||||
onSort: (columnIndex, ascending) {
|
if (selectedEntries.items.isNotEmpty) {
|
||||||
model.changeSortStyle(SortBy.name,
|
final index = entries.indexOf(selectedEntries.items.first);
|
||||||
isLocal: isLocal, ascending: ascending);
|
if (index < 0) {
|
||||||
}),
|
return;
|
||||||
DataColumn(
|
}
|
||||||
label: Text(
|
skipCount = index + 1;
|
||||||
translate("Modified"),
|
}
|
||||||
),
|
var searchResult = entries
|
||||||
onSort: (columnIndex, ascending) {
|
.skip(skipCount)
|
||||||
model.changeSortStyle(SortBy.modified,
|
.where((element) => element.name.startsWith(buffer));
|
||||||
isLocal: isLocal, ascending: ascending);
|
if (searchResult.isEmpty) {
|
||||||
}),
|
// cannot find next, lets restart search from head
|
||||||
DataColumn(
|
searchResult =
|
||||||
label: Text(translate("Size")),
|
entries.where((element) => element.name.startsWith(buffer));
|
||||||
onSort: (columnIndex, ascending) {
|
}
|
||||||
model.changeSortStyle(SortBy.size,
|
if (searchResult.isEmpty) {
|
||||||
isLocal: isLocal, ascending: ascending);
|
setState(() {
|
||||||
}),
|
getSelectedItems(isLocal).clear();
|
||||||
],
|
});
|
||||||
rows: filteredEntries.map((entry) {
|
return;
|
||||||
final sizeStr =
|
}
|
||||||
entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
|
_jumpToEntry(
|
||||||
final lastModifiedStr = entry.isDrive
|
isLocal, searchResult.first, scrollController, rowHeight, buffer);
|
||||||
? " "
|
},
|
||||||
: "${entry.lastModified().toString().replaceAll(".000", "")} ";
|
onSearch: (buffer) {
|
||||||
return DataRow(
|
debugPrint("searching for $buffer");
|
||||||
key: ValueKey(entry.name),
|
final selectedEntries = getSelectedItems(isLocal);
|
||||||
onSelectChanged: (s) {
|
final searchResult =
|
||||||
_onSelectedChanged(getSelectedItems(isLocal), filteredEntries,
|
entries.where((element) => element.name.startsWith(buffer));
|
||||||
entry, isLocal);
|
selectedEntries.clear();
|
||||||
},
|
if (searchResult.isEmpty) {
|
||||||
selected: getSelectedItems(isLocal).contains(entry),
|
setState(() {
|
||||||
cells: [
|
getSelectedItems(isLocal).clear();
|
||||||
DataCell(
|
});
|
||||||
Container(
|
return;
|
||||||
width: 200,
|
}
|
||||||
child: Tooltip(
|
_jumpToEntry(
|
||||||
waitDuration: Duration(milliseconds: 500),
|
isLocal, searchResult.first, scrollController, rowHeight, buffer);
|
||||||
message: entry.name,
|
},
|
||||||
child: Row(children: [
|
child: ObxValue<RxString>(
|
||||||
entry.isDrive
|
(searchText) {
|
||||||
? Image(
|
final filteredEntries = searchText.isNotEmpty
|
||||||
image: iconHardDrive,
|
? entries.where((element) {
|
||||||
fit: BoxFit.scaleDown,
|
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)
|
color: Theme.of(context)
|
||||||
.iconTheme
|
.iconTheme
|
||||||
.color
|
.color
|
||||||
?.withOpacity(0.7))
|
?.withOpacity(0.7),
|
||||||
.paddingAll(4)
|
).marginSymmetric(horizontal: 2),
|
||||||
: Icon(
|
Expanded(
|
||||||
entry.isFile
|
child: Text(entry.name,
|
||||||
? Icons.feed_outlined
|
overflow: TextOverflow.ellipsis))
|
||||||
: Icons.folder,
|
]),
|
||||||
size: 20,
|
)),
|
||||||
color: Theme.of(context)
|
onTap: () {
|
||||||
.iconTheme
|
final items = getSelectedItems(isLocal);
|
||||||
.color
|
|
||||||
?.withOpacity(0.7),
|
|
||||||
).marginSymmetric(horizontal: 2),
|
|
||||||
Expanded(
|
|
||||||
child: Text(entry.name,
|
|
||||||
overflow: TextOverflow.ellipsis))
|
|
||||||
]),
|
|
||||||
)),
|
|
||||||
onTap: () {
|
|
||||||
final items = getSelectedItems(isLocal);
|
|
||||||
|
|
||||||
// handle double click
|
// handle double click
|
||||||
if (_checkDoubleClick(entry)) {
|
if (_checkDoubleClick(entry)) {
|
||||||
openDirectory(entry.path, isLocal: isLocal);
|
openDirectory(entry.path, isLocal: isLocal);
|
||||||
items.clear();
|
items.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_onSelectedChanged(
|
_onSelectedChanged(
|
||||||
items, filteredEntries, entry, isLocal);
|
items, filteredEntries, entry, isLocal);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DataCell(FittedBox(
|
DataCell(FittedBox(
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
|
waitDuration: Duration(milliseconds: 500),
|
||||||
|
message: lastModifiedStr,
|
||||||
|
child: Text(
|
||||||
|
lastModifiedStr,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: MyTheme.darkGray),
|
||||||
|
)))),
|
||||||
|
DataCell(Tooltip(
|
||||||
waitDuration: Duration(milliseconds: 500),
|
waitDuration: Duration(milliseconds: 500),
|
||||||
message: lastModifiedStr,
|
message: sizeStr,
|
||||||
child: Text(
|
child: Text(
|
||||||
lastModifiedStr,
|
sizeStr,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12, color: MyTheme.darkGray),
|
fontSize: 10, color: MyTheme.darkGray),
|
||||||
)))),
|
))),
|
||||||
DataCell(Tooltip(
|
]);
|
||||||
waitDuration: Duration(milliseconds: 500),
|
}).toList(growable: false),
|
||||||
message: sizeStr,
|
);
|
||||||
child: Text(
|
},
|
||||||
sizeStr,
|
isLocal ? _searchTextLocal : _searchTextRemote,
|
||||||
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,
|
void _onSelectedChanged(SelectedItems selectedItems, List<Entry> entries,
|
||||||
Entry entry, bool isLocal) {
|
Entry entry, bool isLocal) {
|
||||||
final isCtrlDown = RawKeyboard.instance.keysPressed
|
final isCtrlDown = RawKeyboard.instance.keysPressed
|
||||||
@ -1015,4 +1125,14 @@ class _FileManagerPageState extends State<FileManagerPage>
|
|||||||
}
|
}
|
||||||
model.sendFiles(items, isRemote: false);
|
model.sendFiles(items, isRemote: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void refocusKeyboardListener(bool isLocal) {
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (isLocal) {
|
||||||
|
_keyboardNodeLocal.requestFocus();
|
||||||
|
} else {
|
||||||
|
_keyboardNodeRemote.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
75
flutter/lib/desktop/widgets/list_search_action_listener.dart
Normal file
75
flutter/lib/desktop/widgets/list_search_action_listener.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ListSearchActionListener extends StatelessWidget {
|
||||||
|
final FocusNode node;
|
||||||
|
final TimeoutStringBuffer buffer;
|
||||||
|
final Widget child;
|
||||||
|
final Function(String) onNext;
|
||||||
|
final Function(String) onSearch;
|
||||||
|
|
||||||
|
const ListSearchActionListener(
|
||||||
|
{super.key,
|
||||||
|
required this.node,
|
||||||
|
required this.buffer,
|
||||||
|
required this.child,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onSearch});
|
||||||
|
|
||||||
|
@mustCallSuper
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return KeyboardListener(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: (kv) {
|
||||||
|
final ch = kv.character;
|
||||||
|
if (ch == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final action = buffer.input(ch);
|
||||||
|
switch (action) {
|
||||||
|
case ListSearchAction.search:
|
||||||
|
onSearch(buffer.buffer);
|
||||||
|
break;
|
||||||
|
case ListSearchAction.next:
|
||||||
|
onNext(buffer.buffer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusNode: node,
|
||||||
|
child: child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListSearchAction { search, next }
|
||||||
|
|
||||||
|
class TimeoutStringBuffer {
|
||||||
|
var _buffer = "";
|
||||||
|
late DateTime _duration;
|
||||||
|
|
||||||
|
static int timeoutMilliSec = 1500;
|
||||||
|
|
||||||
|
String get buffer => _buffer;
|
||||||
|
|
||||||
|
TimeoutStringBuffer() {
|
||||||
|
_duration = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
ListSearchAction input(String ch) {
|
||||||
|
final curr = DateTime.now();
|
||||||
|
try {
|
||||||
|
if (curr.difference(_duration).inMilliseconds > timeoutMilliSec) {
|
||||||
|
_buffer = ch;
|
||||||
|
return ListSearchAction.search;
|
||||||
|
} else {
|
||||||
|
if (ch == _buffer) {
|
||||||
|
return ListSearchAction.next;
|
||||||
|
} else {
|
||||||
|
_buffer += ch;
|
||||||
|
return ListSearchAction.search;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_duration = curr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -213,7 +213,7 @@ class FileModel extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
receiveFileDir(Map<String, dynamic> evt) {
|
receiveFileDir(Map<String, dynamic> evt) {
|
||||||
debugPrint("recv file dir:$evt");
|
// debugPrint("recv file dir:$evt");
|
||||||
if (evt['is_local'] == "false") {
|
if (evt['is_local'] == "false") {
|
||||||
// init remote home, the connection will automatic read remote home when established,
|
// init remote home, the connection will automatic read remote home when established,
|
||||||
try {
|
try {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user