feat: add list file search listener

This commit is contained in:
Kingtous 2022-12-13 12:55:41 +08:00
parent ac14e462f6
commit 7a938ace02
3 changed files with 295 additions and 121 deletions

View File

@ -7,6 +7,7 @@ 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';
@ -32,6 +33,18 @@ enum LocationStatus {
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;
@ -55,6 +68,11 @@ class _FileManagerPageState extends State<FileManagerPage>
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 =
@ -197,6 +215,7 @@ class _FileManagerPageState extends State<FileManagerPage>
}
Widget body({bool isLocal = false}) {
final scrollController = ScrollController();
return Container(
decoration: BoxDecoration(border: Border.all(color: Colors.black26)),
margin: const EdgeInsets.all(16.0),
@ -209,7 +228,8 @@ class _FileManagerPageState extends State<FileManagerPage>
onDragExited: (exit) {
_dropMaskVisible.value = false;
},
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
headTools(isLocal),
Expanded(
child: Row(
@ -217,8 +237,8 @@ class _FileManagerPageState extends State<FileManagerPage>
children: [
Expanded(
child: SingleChildScrollView(
controller: ScrollController(),
child: _buildDataTable(context, isLocal),
controller: scrollController,
child: _buildDataTable(context, isLocal, scrollController),
),
)
],
@ -228,7 +248,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 entries = fd.entries;
final sortIndex = (SortBy style) {
@ -246,127 +268,194 @@ class _FileManagerPageState extends State<FileManagerPage>
final sortAscending =
isLocal ? model.localSortAscending : model.remoteSortAscending;
return 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: 25,
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,
return MouseRegion(
onEnter: (evt) {
print("enter $evt");
_mouseFocusScope.value =
isLocal ? MouseFocusScope.local : MouseFocusScope.remote;
if (isLocal) {
_keyboardNodeLocal.requestFocus();
} else {
_keyboardNodeRemote.requestFocus();
}
},
onExit: (evt) {
print("exit $evt");
_mouseFocusScope.value = MouseFocusScope.none;
// FocusManager.instance.primaryFocus?.unfocus();
},
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) {
// loop
searchResult =
entries.where((element) => element.name.startsWith(buffer));
}
if (searchResult.isEmpty) {
return;
}
final offset = entries.indexOf(searchResult.first) * rowHeight;
setState(() {
selectedEntries.clear();
selectedEntries.add(isLocal, searchResult.first);
debugPrint("focused on ${searchResult.first.name}");
});
},
onSearch: (buffer) {
debugPrint("searching for $buffer");
final selectedEntries = getSelectedItems(isLocal);
final searchResult =
entries.where((element) => element.name.startsWith(buffer));
selectedEntries.clear();
if (searchResult.isEmpty) {
return;
}
setState(() {
selectedEntries.add(isLocal, searchResult.first);
debugPrint("focused on ${searchResult.first.name}");
});
},
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))
.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(
?.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: lastModifiedStr,
message: sizeStr,
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,
sizeStr,
overflow: TextOverflow.ellipsis,
style:
TextStyle(fontSize: 10, color: MyTheme.darkGray),
))),
]);
}).toList(growable: false),
);
},
isLocal ? _searchTextLocal : _searchTextRemote,
),
),
);
}
@ -1015,4 +1104,14 @@ class _FileManagerPageState extends State<FileManagerPage>
}
model.sendFiles(items, isRemote: false);
}
void refocusKeyboardListener(bool isLocal) {
Future.delayed(Duration.zero, () {
if (isLocal) {
_keyboardNodeLocal.requestFocus();
} else {
_keyboardNodeRemote.requestFocus();
}
});
}
}

View 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;
}
}
}

View File

@ -213,7 +213,7 @@ class FileModel extends ChangeNotifier {
}
receiveFileDir(Map<String, dynamic> evt) {
debugPrint("recv file dir:$evt");
// debugPrint("recv file dir:$evt");
if (evt['is_local'] == "false") {
// init remote home, the connection will automatic read remote home when established,
try {