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/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),
@ -209,7 +228,8 @@ class _FileManagerPageState extends State<FileManagerPage>
onDragExited: (exit) { onDragExited: (exit) {
_dropMaskVisible.value = false; _dropMaskVisible.value = false;
}, },
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
headTools(isLocal), headTools(isLocal),
Expanded( Expanded(
child: Row( child: Row(
@ -217,8 +237,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 +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 fd = model.getCurrentDir(isLocal);
final entries = fd.entries; final entries = fd.entries;
final sortIndex = (SortBy style) { final sortIndex = (SortBy style) {
@ -246,127 +268,194 @@ 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 print("enter $evt");
? entries.where((element) { _mouseFocusScope.value =
return element.name.contains(searchText.value); isLocal ? MouseFocusScope.local : MouseFocusScope.remote;
}).toList(growable: false) if (isLocal) {
: entries; _keyboardNodeLocal.requestFocus();
return DataTable( } else {
key: ValueKey(isLocal ? 0 : 1), _keyboardNodeRemote.requestFocus();
showCheckboxColumn: false, }
dataRowHeight: 25, },
headingRowHeight: 30, onExit: (evt) {
horizontalMargin: 8, print("exit $evt");
columnSpacing: 8, _mouseFocusScope.value = MouseFocusScope.none;
showBottomBorder: true, // FocusManager.instance.primaryFocus?.unfocus();
sortColumnIndex: sortIndex, },
sortAscending: sortAscending, child: ListSearchActionListener(
columns: [ node: isLocal ? _keyboardNodeLocal : _keyboardNodeRemote,
DataColumn( buffer: isLocal ? _listSearchBufferLocal : _listSearchBufferRemote,
label: Text( onNext: (buffer) {
translate("Name"), debugPrint("searching next for $buffer");
).marginSymmetric(horizontal: 4), assert(buffer.length == 1);
onSort: (columnIndex, ascending) { final selectedEntries = getSelectedItems(isLocal);
model.changeSortStyle(SortBy.name, assert(selectedEntries.length <= 1);
isLocal: isLocal, ascending: ascending); var skipCount = 0;
}), if (selectedEntries.items.isNotEmpty) {
DataColumn( final index = entries.indexOf(selectedEntries.items.first);
label: Text( if (index < 0) {
translate("Modified"), return;
), }
onSort: (columnIndex, ascending) { skipCount = index + 1;
model.changeSortStyle(SortBy.modified, }
isLocal: isLocal, ascending: ascending); var searchResult = entries
}), .skip(skipCount)
DataColumn( .where((element) => element.name.startsWith(buffer));
label: Text(translate("Size")), if (searchResult.isEmpty) {
onSort: (columnIndex, ascending) { // loop
model.changeSortStyle(SortBy.size, searchResult =
isLocal: isLocal, ascending: ascending); entries.where((element) => element.name.startsWith(buffer));
}), }
], if (searchResult.isEmpty) {
rows: filteredEntries.map((entry) { return;
final sizeStr = }
entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; final offset = entries.indexOf(searchResult.first) * rowHeight;
final lastModifiedStr = entry.isDrive setState(() {
? " " selectedEntries.clear();
: "${entry.lastModified().toString().replaceAll(".000", "")} "; selectedEntries.add(isLocal, searchResult.first);
return DataRow( debugPrint("focused on ${searchResult.first.name}");
key: ValueKey(entry.name), });
onSelectChanged: (s) { },
_onSelectedChanged(getSelectedItems(isLocal), filteredEntries, onSearch: (buffer) {
entry, isLocal); debugPrint("searching for $buffer");
}, final selectedEntries = getSelectedItems(isLocal);
selected: getSelectedItems(isLocal).contains(entry), final searchResult =
cells: [ entries.where((element) => element.name.startsWith(buffer));
DataCell( selectedEntries.clear();
Container( if (searchResult.isEmpty) {
width: 200, return;
child: Tooltip( }
waitDuration: Duration(milliseconds: 500), setState(() {
message: entry.name, selectedEntries.add(isLocal, searchResult.first);
child: Row(children: [ debugPrint("focused on ${searchResult.first.name}");
entry.isDrive });
? Image( },
image: iconHardDrive, child: ObxValue<RxString>(
fit: BoxFit.scaleDown, (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) 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,
style: TextStyle( overflow: TextOverflow.ellipsis,
fontSize: 12, color: MyTheme.darkGray), style:
)))), TextStyle(fontSize: 10, color: MyTheme.darkGray),
DataCell(Tooltip( ))),
waitDuration: Duration(milliseconds: 500), ]);
message: sizeStr, }).toList(growable: false),
child: Text( );
sizeStr, },
overflow: TextOverflow.ellipsis, isLocal ? _searchTextLocal : _searchTextRemote,
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); 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) { 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 {