feat(selection): implement multi-select functionality with action bar (#730) (#566)

This commit is contained in:
wanghongenpin
2026-05-19 03:29:26 +08:00
parent 6ae7b0962c
commit bb88aac6b8
10 changed files with 923 additions and 242 deletions

View File

@@ -15,7 +15,6 @@
*/
import 'dart:convert';
import 'dart:math';
import 'package:proxypin/network/channel/host_port.dart';
import 'package:proxypin/network/http/content_type.dart';
@@ -23,6 +22,7 @@ import 'package:proxypin/network/http/websocket.dart';
import 'package:proxypin/network/util/compress.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/network/util/process_info.dart';
import 'package:proxypin/network/util/random.dart';
import 'http_headers.dart';
@@ -68,7 +68,7 @@ abstract class HttpMessage {
String? remoteHost;
int? remotePort;
String requestId = (DateTime.now().millisecondsSinceEpoch + Random().nextInt(999999)).toRadixString(36);
String requestId = (DateTime.now().millisecondsSinceEpoch).toRadixString(36) + RandomUtil.randomString(8); //请求id
int? streamId; // http2 streamId
HttpMessage(this.protocolVersion);

View File

@@ -0,0 +1,121 @@
import 'package:get/get.dart';
import 'package:proxypin/utils/listenable_list.dart';
class MultiSelectController {
final ListenableList<String> selectedIds = ListenableList<String>();
String? _anchorId;
RxBool selectionMode = false.obs;
bool get isSelectionMode => selectionMode.value;
int get selectedCount => selectedIds.length;
bool contains(String requestId) => selectedIds.contains(requestId);
void clear() {
if (selectedIds.isEmpty && !selectionMode.value) {
return;
}
selectedIds.clear();
_anchorId = null;
selectionMode.value = false;
}
void enterSelectionMode([String? requestId]) {
selectionMode.value = true;
if (requestId != null) {
selectedIds.add(requestId);
_anchorId = requestId;
}
}
void selectOnly(String requestId) {
selectionMode.value = true;
selectedIds
..clear()
..add(requestId);
_anchorId = requestId;
}
void toggleSelectionMode([String? requestId]) {
if (selectionMode.value) {
clear();
} else {
enterSelectionMode(requestId);
}
}
void toggle(String requestId) {
selectionMode.value = true;
if (selectedIds.contains(requestId)) {
selectedIds.remove(requestId);
} else {
selectedIds.add(requestId);
}
if (selectedIds.isEmpty) {
clear();
return;
}
_anchorId = requestId;
}
void selectRange(List<String> orderedIds, String requestId) {
final targetIndex = orderedIds.indexOf(requestId);
if (targetIndex < 0) {
return;
}
final anchorIndex = _anchorId == null ? -1 : orderedIds.indexOf(_anchorId!);
if (anchorIndex < 0) {
selectOnly(requestId);
return;
}
final start = anchorIndex < targetIndex ? anchorIndex : targetIndex;
final end = anchorIndex > targetIndex ? anchorIndex : targetIndex;
selectionMode.value = true;
selectedIds
..clear()
..addAll(orderedIds.sublist(start, end + 1));
_anchorId = requestId;
}
void prune(Iterable<String> visibleIds) {
final visibleIdSet = visibleIds.toSet();
selectedIds.removeWhere((requestId) => !visibleIdSet.contains(requestId));
if (selectedIds.isEmpty) {
clear();
return;
}
selectionMode.value = true;
if (_anchorId == null || !selectedIds.contains(_anchorId)) {
_anchorId = selectedIds.last;
}
}
}
class MultiSelectListener<T> extends ListenerListEvent<T> {
final Function(List<T> items) onChange;
MultiSelectListener(this.onChange);
@override
void onAdd(T item) => onChange.call([item]);
@override
void onRemove(T item) => onChange.call([item]);
@override
void onUpdate(T item) => onChange.call([item]);
@override
void onBatchRemove(List<T> items) => onChange.call(items);
@override
void clear(List<T> items) => onChange.call(items);
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/utils/listenable_list.dart';
class SelectionActionBar extends StatelessWidget {
final MultiSelectController selectionController;
final VoidCallback? onRepeat;
final VoidCallback? onExport;
final VoidCallback? onDelete;
const SelectionActionBar({super.key, required this.selectionController, this.onRepeat, this.onExport, this.onDelete});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
return SizedBox(
height: 36,
child: Row(children: [
const SizedBox(width: 8),
_SelectLabel(selectionController: selectionController),
const Spacer(),
if (onRepeat != null)
IconButton(onPressed: onRepeat, tooltip: localizations?.repeat, icon: const Icon(Icons.repeat, size: 18)),
if (onExport != null)
IconButton(
onPressed: onExport, tooltip: localizations?.export, icon: const Icon(Icons.share_outlined, size: 18)),
if (onDelete != null)
IconButton(
onPressed: onDelete, tooltip: localizations?.delete, icon: const Icon(Icons.delete_outline, size: 18)),
IconButton(onPressed: _onCancel, tooltip: localizations?.cancel, icon: const Icon(Icons.close, size: 18)),
]));
}
void _onCancel() {
selectionController.clear();
}
}
class _SelectLabel extends StatefulWidget {
final MultiSelectController selectionController;
const _SelectLabel({required this.selectionController});
@override
State<StatefulWidget> createState() => _SelectLabelState();
}
class _SelectLabelState extends State<_SelectLabel> {
late final OnchangeListEvent<String> _listener;
@override
void initState() {
super.initState();
_listener = OnchangeListEvent(_onSelectionChanged);
widget.selectionController.selectedIds.addListener(_listener);
}
@override
void dispose() {
widget.selectionController.selectedIds.removeListener(_listener);
super.dispose();
}
void _onSelectionChanged() {
if (!mounted) {
return;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final selectLabel = AppLocalizations.of(context)?.selectAction;
final selectedCount = widget.selectionController.selectedIds.length;
final label = (selectLabel == null || selectLabel.isEmpty) ? '$selectedCount' : '$selectedCount $selectLabel';
return Text(label, style: Theme.of(context).textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis);
}
}
class ClearSelectionIntent extends Intent {
const ClearSelectionIntent();
}

View File

@@ -21,16 +21,17 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_desktop_context_menu/flutter_desktop_context_menu.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:proxypin/network/bin/configuration.dart';
import 'package:proxypin/network/bin/server.dart';
import 'package:proxypin/network/channel/channel.dart';
import 'package:proxypin/network/channel/channel_context.dart';
import 'package:proxypin/network/components/host_filter.dart';
import 'package:proxypin/network/channel/host_port.dart';
import 'package:proxypin/network/components/host_filter.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/component/transition.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/content/panel.dart';
@@ -52,6 +53,8 @@ class DomainList extends StatefulWidget {
final ListenableList<HttpRequest> list;
final bool shrinkWrap;
final Function(List<HttpRequest>)? onRemove;
final MultiSelectController selectionController;
final RequestSelectionHandlers selectionHandlers;
const DomainList(
{super.key,
@@ -59,7 +62,9 @@ class DomainList extends StatefulWidget {
required this.list,
this.shrinkWrap = true,
required this.panel,
this.onRemove});
this.onRemove,
required this.selectionController,
required this.selectionHandlers});
@override
State<StatefulWidget> createState() {
@@ -79,11 +84,14 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
bool changing = false; //
//
late VoidCallback highlightListener;
late MultiSelectListener<String> selectionListener;
bool sortDesc = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
MultiSelectController get selectionController => widget.selectionController;
void changeState() {
if (!changing) {
changing = true;
@@ -110,10 +118,19 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
});
};
KeywordHighlights.addListener(highlightListener);
selectionListener = MultiSelectListener((items) {
if (!mounted) {
return;
}
_refreshRequestSelection(items);
});
selectionController.selectedIds.addListener(selectionListener);
}
@override
dispose() {
void dispose() {
selectionController.selectedIds.removeListener(selectionListener);
KeywordHighlights.removeListener(highlightListener);
super.dispose();
}
@@ -130,6 +147,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
if (searchModel?.isNotEmpty == true) {
searchView = searchFilter(searchModel!);
list = searchView.values;
selectionController.prune(list.expand((e) => e.body).map((e) => e.request.requestId).toSet());
} else {
searchView.clear();
}
@@ -161,7 +179,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
}
///
highlightHandler() {
void highlightHandler() {
//Widget
List<RequestWidget> requests = containerMap.values.map((e) => e.body).expand((element) => element).toList();
for (RequestWidget request in requests) {
@@ -171,7 +189,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
}
///
add(Channel channel, HttpRequest request) {
void add(Channel channel, HttpRequest request) {
String? host = request.remoteDomain();
if (host == null) {
return;
@@ -208,6 +226,8 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
widget.onRemove?.call([req]);
changeState();
},
selectionController: selectionController,
selectionHandlers: widget.selectionHandlers,
);
containerMap[host] = domainRequests;
}
@@ -228,7 +248,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
}
///
deleteHost(String host) {
void deleteHost(String host) {
DomainRequests? domainRequests = containerMap.remove(host);
if (domainRequests == null) {
return;
@@ -258,7 +278,7 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
}
}
remove(List<HttpRequest> list) {
void remove(List<HttpRequest> list) {
for (var request in list) {
String? host = request.remoteDomain();
containerMap[host]?._removeRequest(request);
@@ -330,6 +350,37 @@ class DomainWidgetState extends State<DomainList> with AutomaticKeepAliveClientM
request.changeState();
});
}
List<HttpRequest> selectedRequests() {
final selectedIds = selectionController.selectedIds;
if (selectedIds.isEmpty) {
return [];
}
return currentView().where((request) => selectedIds.contains(request.requestId)).toList();
}
void selectRange(HttpRequest request) {
final currentIds = currentView().map((item) => item.requestId).toList();
if (currentIds.isEmpty) {
return;
}
selectionController.selectRange(currentIds, request.requestId);
}
void _refreshRequestSelection(List<String> selectedIds) {
var container = containerMap.values;
if (searchModel?.isNotEmpty == true) {
container = searchView.values;
}
for (var domain in container) {
for (var requestWidget in domain.body) {
if (selectedIds.contains(requestWidget.request.requestId)) {
requestWidget.changeState();
}
}
}
}
}
///
@@ -351,6 +402,8 @@ class DomainRequests extends StatefulWidget {
final Function(String host)? onDelete;
final Function(String host)? onExportHar;
final Function(HttpRequest request)? onRequestRemove;
final RequestSelectionHandlers selectionHandlers;
final MultiSelectController selectionController;
DomainRequests(this.domain,
{this.selected = false,
@@ -358,7 +411,9 @@ class DomainRequests extends StatefulWidget {
this.onExportHar,
required this.proxyServer,
this.onRequestRemove,
this.trailing})
required this.selectionHandlers,
this.trailing,
required this.selectionController})
: super(key: GlobalKey<_DomainRequestsState>());
///
@@ -366,7 +421,12 @@ class DomainRequests extends StatefulWidget {
if (requestMap.containsKey(requestId)) return;
var requestWidget = RequestWidget(request,
index: body.length, proxyServer: proxyServer, displayDomain: false, remove: (it) => _remove(it));
index: body.length,
proxyServer: proxyServer,
displayDomain: false,
multiSelectController: selectionController,
selectionHandlers: selectionHandlers,
remove: (it) => _remove(it));
sortDesc ? body.addFirst(requestWidget) : body.addLast(requestWidget);
if (requestId == null) {
@@ -381,19 +441,19 @@ class DomainRequests extends StatefulWidget {
return requestMap[response.request?.requestId ?? response.requestId];
}
setTrailing(Widget? trailing) {
void setTrailing(Widget? trailing) {
var state = key as GlobalKey<_DomainRequestsState>;
state.currentState?.trailing = trailing;
}
_remove(RequestWidget requestWidget) {
void _remove(RequestWidget requestWidget) {
if (body.remove(requestWidget)) {
onRequestRemove?.call(requestWidget.request);
changeState();
}
}
_removeRequest(HttpRequest request) {
void _removeRequest(HttpRequest request) {
var requestWidget = requestMap.remove(request.requestId);
if (requestWidget != null) {
_remove(requestWidget);
@@ -415,6 +475,8 @@ class DomainRequests extends StatefulWidget {
onDelete: onDelete,
onExportHar: onExportHar,
onRequestRemove: onRequestRemove,
selectionController: selectionController,
selectionHandlers: selectionHandlers,
proxyServer: proxyServer);
if (body != null) {
headerBody.body.addAll(body);
@@ -427,7 +489,7 @@ class DomainRequests extends StatefulWidget {
return state.currentState?.selected == true;
}
changeState() {
void changeState() {
var state = key as GlobalKey<_DomainRequestsState>;
state.currentState?.changeState();
}
@@ -455,7 +517,7 @@ class _DomainRequestsState extends State<DomainRequests> {
trailing = widget.trailing;
}
changeState() {
void changeState() {
//
if (!changing) {
changing = true;
@@ -510,7 +572,7 @@ class _DomainRequestsState extends State<DomainRequests> {
}
//
menu() {
void menu() {
Menu menu = Menu(items: [
MenuItem(
label: localizations.copyHost,
@@ -580,7 +642,7 @@ class _DomainRequestsState extends State<DomainRequests> {
]);
}
_delete() {
void _delete() {
widget.onDelete?.call(widget.domain);
widget.requestMap.clear();
widget.body.clear();

View File

@@ -17,6 +17,8 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:proxypin/network/bin/server.dart';
@@ -25,17 +27,20 @@ import 'package:proxypin/network/channel/channel_context.dart';
import 'package:proxypin/network/channel/host_port.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
import 'package:proxypin/ui/content/panel.dart';
import 'package:proxypin/ui/desktop/request/request_sequence.dart';
import 'package:proxypin/ui/desktop/request/request.dart';
import 'package:proxypin/ui/desktop/request/search.dart';
import 'package:proxypin/ui/component/selection_action_bar.dart';
import 'package:proxypin/utils/har.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/utils/listenable_list.dart';
import '../../component/model/search_model.dart';
import 'domians.dart';
import 'domains.dart';
import 'package:proxypin/ui/desktop/request/report_servers.dart';
/// @author wanghongen
@@ -56,12 +61,16 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
final GlobalKey<RequestSequenceState> requestSequenceKey = GlobalKey<RequestSequenceState>();
final GlobalKey<DomainWidgetState> domainListKey = GlobalKey<DomainWidgetState>();
final GlobalKey<SearchState> searchKey = GlobalKey<SearchState>();
TabController? _tabController;
//请求列表容器
ListenableList<HttpRequest> container = ListenableList();
bool sortDesc = true;
// 选择控制器
final MultiSelectController selectionController = MultiSelectController();
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
@@ -72,12 +81,15 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
}
}
bool get isSelectionMode => selectionController.isSelectionMode;
@override
bool get wantKeepAlive => true;
@override
void dispose() {
RequestWidget.removeAutoReadByIds(container.map((request) => request.requestId));
selectionController.clear();
super.dispose();
}
@@ -90,84 +102,141 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
Tab(child: Text(localizations.sequence, style: const TextStyle(fontSize: 13))),
];
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
toolbarHeight: 40,
title: SizedBox(height: 40, child: TabBar(tabs: tabs, dividerColor: Colors.transparent)),
automaticallyImplyLeading: false,
actions: [popupMenus()],
),
bottomNavigationBar: Search(key: searchKey, onSearch: search),
body: Padding(
padding: const EdgeInsets.only(right: 5),
child: TabBarView(physics: const NeverScrollableScrollPhysics(), children: [
DomainList(
key: domainListKey,
list: container,
panel: widget.panel,
proxyServer: widget.proxyServer,
onRemove: domainListRemove),
RequestSequence(
key: requestSequenceKey,
container: container,
proxyServer: widget.proxyServer,
onRemove: sequenceRemove),
]))));
return FocusableActionDetector(
autofocus: true,
shortcuts: {
SingleActivator(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
},
actions: {
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(onInvoke: (intent) {
if (_isTextInputFocused()) {
return null;
}
if (isSelectionMode) {
selectionController.clear();
}
return null;
}),
},
child: DefaultTabController(
length: tabs.length,
child: Builder(builder: (tabContext) {
_tabController = DefaultTabController.of(tabContext);
return Scaffold(
appBar: AppBar(
toolbarHeight: 40,
title: SizedBox(height: 40, child: TabBar(tabs: tabs, dividerColor: Colors.transparent)),
automaticallyImplyLeading: false,
actions: [popupMenus()],
),
bottomNavigationBar: Search(key: searchKey, onSearch: search),
body: Padding(
padding: const EdgeInsets.only(right: 5),
child: Obx(() {
return Column(children: [
if (isSelectionMode)
SelectionActionBar(
selectionController: selectionController,
onRepeat: repeatSelected,
onExport: exportSelected,
onDelete: deleteSelected),
Expanded(
child: TabBarView(physics: const NeverScrollableScrollPhysics(), children: [
DomainList(
key: domainListKey,
list: container,
panel: widget.panel,
proxyServer: widget.proxyServer,
selectionController: selectionController,
selectionHandlers: RequestSelectionHandlers(
onRangeSelection: rangeSelectRequest,
onDeleteSelected: deleteSelected,
onRepeatSelected: repeatSelected,
onExportSelected: exportSelected,
),
onRemove: domainListRemove,
),
RequestSequence(
key: requestSequenceKey,
container: container,
proxyServer: widget.proxyServer,
selectionController: selectionController,
// onSelectionChanged: syncDomainSelectionVisuals,
selectionHandlers: RequestSelectionHandlers(
onRangeSelection: rangeSelectRequest,
onDeleteSelected: deleteSelected,
onRepeatSelected: repeatSelected,
onExportSelected: exportSelected,
),
onRemove: sequenceRemove,
),
])),
]);
})));
})));
}
bool _isTextInputFocused() {
return FocusManager.instance.primaryFocus?.context?.widget is EditableText;
}
Widget popupMenus() {
return PopupMenuButton(
return PopupMenuButton<_RequestListMenuAction>(
offset: const Offset(0, 32),
icon: const Icon(Icons.more_vert_outlined, size: 20),
onSelected: _onMenuSelected,
itemBuilder: (BuildContext context) {
return <PopupMenuEntry>[
CustomPopupMenuItem(
height: 37,
onTap: () => searchKey.currentState?.searchDialog(),
child: IconText(
icon: const Icon(Icons.search, size: 17),
text: localizations.search,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 37,
onTap: () => export('ProxyPin_${DateTime.now().dateFormat()}.har'),
child: IconText(
icon: const Icon(Icons.share, size: 16),
text: localizations.viewExport,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 37,
onTap: () => repeatAllRequests(),
child: IconText(
icon: const Icon(Icons.repeat, size: 16),
text: localizations.repeatAllRequests,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 37,
onTap: () {
sortDesc = !sortDesc;
requestSequenceKey.currentState?.sort(sortDesc);
domainListKey.currentState?.sort(sortDesc);
},
child: IconText(
icon: const Icon(Icons.sort, size: 16),
text: sortDesc ? localizations.timeAsc : localizations.timeDesc,
textStyle: const TextStyle(fontSize: 13))),
CustomPopupMenuItem(
height: 37,
onTap: () {
showReportServersDialog(context);
},
child: IconText(
icon: Icon(Icons.cloud_upload_outlined, size: 16),
text: localizations.reportServers,
textStyle: TextStyle(fontSize: 13))),
return <PopupMenuEntry<_RequestListMenuAction>>[
_menuItem(_RequestListMenuAction.search,
icon: const Icon(Icons.search, size: 17), text: localizations.search),
_menuItem(_RequestListMenuAction.export,
icon: const Icon(Icons.share, size: 16), text: localizations.viewExport),
_menuItem(_RequestListMenuAction.repeat,
icon: const Icon(Icons.repeat, size: 16), text: localizations.repeatAllRequests),
_menuItem(_RequestListMenuAction.select,
icon: const Icon(Icons.checklist_outlined, size: 16), text: localizations.selectAction),
_menuItem(_RequestListMenuAction.sort,
icon: const Icon(Icons.sort, size: 16),
text: sortDesc ? localizations.timeAsc : localizations.timeDesc),
_menuItem(_RequestListMenuAction.report,
icon: const Icon(Icons.cloud_upload_outlined, size: 16), text: localizations.reportServers),
];
});
}
PopupMenuEntry<_RequestListMenuAction> _menuItem(_RequestListMenuAction value,
{required Icon icon, required String text}) {
return CustomPopupMenuItem<_RequestListMenuAction>(
value: value, height: 37, child: IconText(icon: icon, text: text, textStyle: const TextStyle(fontSize: 13)));
}
void _onMenuSelected(_RequestListMenuAction action) {
switch (action) {
case _RequestListMenuAction.search:
searchKey.currentState?.searchDialog();
break;
case _RequestListMenuAction.export:
export('ProxyPin_${DateTime.now().dateFormat()}.har');
break;
case _RequestListMenuAction.repeat:
repeatAllRequests();
break;
case _RequestListMenuAction.select:
selectionController.toggleSelectionMode();
break;
case _RequestListMenuAction.sort:
setState(() {
sortDesc = !sortDesc;
});
requestSequenceKey.currentState?.sort(sortDesc);
domainListKey.currentState?.sort(sortDesc);
break;
case _RequestListMenuAction.report:
showReportServersDialog(context);
break;
}
}
///添加请求
void add(Channel channel, HttpRequest request) {
container.add(request);
@@ -181,11 +250,19 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
requestSequenceKey.currentState?.addResponse(response);
}
void remove(List<HttpRequest> list) {
container.removeWhere((element) => list.contains(element));
domainListKey.currentState?.remove(list);
requestSequenceKey.currentState?.remove(list);
RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId));
}
///移除
void domainListRemove(List<HttpRequest> list) {
container.removeWhere((element) => list.contains(element));
requestSequenceKey.currentState?.remove(list);
RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId));
selectionController.prune(container.map((request) => request.requestId));
}
///全部请求删除
@@ -193,6 +270,7 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
container.removeWhere((element) => list.contains(element));
domainListKey.currentState?.remove(list);
RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId));
selectionController.prune(container.map((request) => request.requestId));
}
void search(SearchModel searchModel) {
@@ -212,6 +290,7 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
domainListKey.currentState?.clean();
requestSequenceKey.currentState?.clean();
widget.panel.change(null, null);
selectionController.clear();
});
}
@@ -227,19 +306,67 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
requestSequenceKey.currentState?.clean();
RequestWidget.removeAutoReadByIds(removeRange.map((request) => request.requestId));
selectionController.prune(container.map((request) => request.requestId));
}
void deleteSelected() {
final selectedRequests = domainListKey.currentState?.selectedRequests();
if (selectedRequests == null || selectedRequests.isEmpty) {
return;
}
showConfirmDialog(context, content: '${localizations.delete} ${selectedRequests.length} ${localizations.request}?',
onConfirm: () {
setState(() {
remove(selectedRequests);
selectionController.clear();
});
if (mounted) {
FlutterToastr.show(localizations.deleteSuccess, context);
}
});
}
void rangeSelectRequest(HttpRequest request) {
switch (_tabController?.index) {
case 1:
requestSequenceKey.currentState?.selectRange(request);
break;
case 0:
default:
domainListKey.currentState?.selectRange(request);
break;
}
}
void repeatSelected() {
final selectedRequests = domainListKey.currentState?.selectedRequests();
_repeatRequests(selectedRequests);
}
Future<void> exportSelected() async {
final selectedRequests = domainListKey.currentState?.selectedRequests();
if (selectedRequests == null || selectedRequests.isEmpty) {
return;
}
final fileName = 'ProxyPin_selected_${DateTime.now().dateFormat()}.har';
_doExport(fileName, selectedRequests);
}
///导出
Future<void> export(String fileName) async {
//
List<HttpRequest>? requests = currentView();
if (requests == null) return;
_doExport(fileName, requests);
}
Future<void> _doExport(String fileName, List<HttpRequest> requests) async {
var path = await FilePicker.platform.saveFile(fileName: fileName);
if (path == null) {
return;
}
//获取请求
List<HttpRequest>? requests = currentView();
if (requests == null) return;
var file = await File(path).create();
await Har.writeFile(requests, file, title: fileName);
@@ -249,6 +376,10 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
///重发所有请求
void repeatAllRequests() async {
var requests = currentView();
_repeatRequests(requests);
}
void _repeatRequests(List<HttpRequest>? requests) async {
if (requests == null) return;
var localizations = AppLocalizations.of(context);
@@ -270,3 +401,9 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
}
}
}
class _ClearSelectionIntent extends Intent {
const _ClearSelectionIntent();
}
enum _RequestListMenuAction { search, export, repeat, select, sort, report }

View File

@@ -30,6 +30,7 @@ import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/network/util/cache.dart';
import 'package:proxypin/storage/favorites.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/component/multi_window.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
@@ -61,10 +62,19 @@ class RequestWidget extends StatefulWidget {
final ProxyServer proxyServer;
final Function(RequestWidget)? remove;
final Widget? trailing;
final MultiSelectController multiSelectController;
final RequestSelectionHandlers selectionHandlers;
RequestWidget(this.request,
{Key? key, required this.proxyServer, this.remove, this.displayDomain = true, this.trailing, required this.index})
: super(key: GlobalKey<_RequestWidgetState>());
{Key? key,
required this.proxyServer,
this.remove,
this.displayDomain = true,
this.trailing,
required this.selectionHandlers,
required this.index,
required this.multiSelectController})
: super(key: key ?? GlobalKey<_RequestWidgetState>());
@override
State<RequestWidget> createState() => _RequestWidgetState();
@@ -75,10 +85,14 @@ class RequestWidget extends StatefulWidget {
state.currentState?.changeState();
}
void changeState() {
var state = key as GlobalKey<_RequestWidgetState>;
state.currentState?.changeState();
}
static void removeAutoReadByIds(Iterable<String> requestIds) {
_RequestWidgetState.removeAutoReadByIds(requestIds);
}
}
class _RequestWidgetState extends State<RequestWidget> {
@@ -101,6 +115,10 @@ class _RequestWidgetState extends State<RequestWidget> {
AppLocalizations get localizations => AppLocalizations.of(context)!;
bool get selectionMode => widget.multiSelectController.isSelectionMode;
int get selectionCount => widget.multiSelectController.selectedCount;
@override
void initState() {
super.initState();
@@ -118,15 +136,20 @@ class _RequestWidgetState extends State<RequestWidget> {
var packagesSize = getPackagesSize(request, response);
var requestColor = color(path);
bool selectedInSelectionMode = widget.multiSelectController.contains(request.requestId);
return GestureDetector(
onLongPress: () {
if (!selectionMode) {
widget.multiSelectController.enterSelectionMode(widget.request.requestId);
}
},
onSecondaryTap: contextualMenu,
child: ListTile(
minLeadingWidth: 5,
textColor: requestColor,
selectedColor: requestColor,
selectedTileColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
leading: getIcon(widget.response.get() ?? widget.request.response, color: requestColor),
leading: _leading(requestColor),
trailing: widget.trailing,
title: Text(title.fixAutoLines(), overflow: TextOverflow.ellipsis, maxLines: 2),
subtitle: Container(
@@ -142,13 +165,29 @@ class _RequestWidgetState extends State<RequestWidget> {
style: const TextStyle(fontSize: 11, color: Colors.grey))
],
))),
selected: selected,
selected: selected || selectedInSelectionMode,
dense: true,
visualDensity: const VisualDensity(vertical: -4),
contentPadding: const EdgeInsets.only(left: 28),
contentPadding: EdgeInsets.only(left: selectedInSelectionMode ? 6 : 28),
onTap: onClick));
}
Widget _leading(Color? requestColor) {
bool selectedInSelectionMode = widget.multiSelectController.contains(widget.request.requestId);
var icon = getIcon(widget.response.get() ?? widget.request.response, color: requestColor);
if (!selectedInSelectionMode) {
return icon;
}
return Row(mainAxisSize: MainAxisSize.min, children: [
Icon(selectedInSelectionMode ? Icons.check_box_outlined : Icons.check_box_outline_blank_outlined,
size: 18, color: selectedInSelectionMode ? Theme.of(context).colorScheme.primary : Colors.grey),
const SizedBox(width: 4),
icon,
]);
}
Color? color(String url) {
if (highlightColor != null) {
return highlightColor;
@@ -167,129 +206,195 @@ class _RequestWidgetState extends State<RequestWidget> {
}
void contextualMenu() {
Menu menu = Menu(items: [
MenuItem(
label: localizations.copyUrl,
onClick: (_) {
var requestUrl = widget.request.requestUrl;
Clipboard.setData(ClipboardData(text: requestUrl))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
}),
MenuItem(
label: localizations.copy,
type: 'submenu',
submenu: Menu(items: [
MenuItem(
label: localizations.copyCurl,
onClick: (_) {
Clipboard.setData(ClipboardData(text: curlRequest(widget.request)))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
}),
MenuItem(
label: localizations.copyRawRequest,
onClick: (_) {
Clipboard.setData(ClipboardData(text: copyRawRequest(widget.request)))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
}),
MenuItem(
label: localizations.copyRequestResponse,
onClick: (_) {
Clipboard.setData(ClipboardData(text: copyRequest(widget.request, widget.response.get())))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
}),
MenuItem(
label: localizations.copyAsPythonRequests,
onClick: (_) {
Clipboard.setData(ClipboardData(text: copyAsPythonRequests(widget.request)))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
},
),
MenuItem(
label: localizations.copyAsFetch,
onClick: (_) {
Clipboard.setData(ClipboardData(text: copyAsFetch(widget.request)))
.then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context));
},
),
])),
MenuItem.separator(),
MenuItem(
label: localizations.openNewWindow,
onClick: (_) {
openDetailInNewWindow();
}),
MenuItem.separator(),
MenuItem(
label: localizations.export,
type: 'submenu',
submenu: Menu(items: [
MenuItem(label: localizations.request, onClick: (_) => exportRequest(widget.request)),
MenuItem(label: localizations.requestBody, onClick: (_) => exportRequestBody(widget.request)),
MenuItem.separator(),
MenuItem(label: localizations.response, onClick: (_) => exportResponse(widget.response.get())),
MenuItem(label: localizations.responseBody, onClick: (_) => exportResponseBody(widget.response.get())),
MenuItem.separator(),
MenuItem(label: "HAR", onClick: (_) => exportHar(widget.request)),
])),
MenuItem.separator(),
MenuItem(label: localizations.repeat, onClick: (_) => onRepeat(widget.request)),
MenuItem(label: localizations.customRepeat, onClick: (_) => showCustomRepeat(widget.request)),
MenuItem(
label: localizations.editRequest,
onClick: (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
requestEdit();
});
}),
MenuItem.separator(),
MenuItem(label: localizations.requestRewrite, onClick: (_) => showRequestRewriteDialog(context, widget.request)),
MenuItem(
label: localizations.requestMap,
onClick: (_) async {
showDialog(
context: context,
builder: (context) =>
RequestMapEdit(url: widget.request.domainPath, title: widget.request.hostAndPort?.host));
}),
MenuItem(
label: localizations.script,
onClick: (_) async {
var scriptManager = await ScriptManager.instance;
var url = widget.request.domainPath;
var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.urls.contains(url));
popUpContextMenu(selectionMode && selectionCount > 1 ? _batchMenu() : _requestMenu());
}
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
if (!mounted) return;
showDialog(
context: context,
builder: (context) => ScriptEdit(
scriptItem: scriptItem, script: script, url: url, title: widget.request.hostAndPort?.host));
}),
Menu _batchMenu() {
return Menu(items: [
_menuAction(localizations.repeat, _RequestMenuAction.batchRepeat),
_menuAction(localizations.export, _RequestMenuAction.batchExport),
MenuItem.separator(),
MenuItem(
label: localizations.favorite,
onClick: (_) {
FavoriteStorage.addFavorite(widget.request);
FlutterToastr.show(localizations.operationSuccess, context, rootNavigator: true);
}),
MenuItem(
label: localizations.highlight,
type: 'submenu',
submenu: highlightMenu(),
onClick: (_) {
setState(() {
highlightColor = Colors.red;
});
}),
_menuAction(localizations.delete, _RequestMenuAction.batchDelete),
MenuItem.separator(),
MenuItem(
label: localizations.delete,
onClick: (_) {
widget.remove?.call(widget);
}),
_menuAction(localizations.cancel, _RequestMenuAction.batchCancel),
]);
}
popUpContextMenu(menu);
Menu _requestMenu() {
return Menu(items: [
_menuAction(localizations.copyUrl, _RequestMenuAction.copyUrl),
MenuItem(label: localizations.copy, type: 'submenu', submenu: _copySubmenu()),
MenuItem.separator(),
_menuAction(localizations.openNewWindow, _RequestMenuAction.openNewWindow),
MenuItem.separator(),
MenuItem(label: localizations.export, type: 'submenu', submenu: _exportSubmenu()),
MenuItem.separator(),
_menuAction(localizations.repeat, _RequestMenuAction.repeat),
_menuAction(localizations.customRepeat, _RequestMenuAction.customRepeat),
_menuAction(localizations.editRequest, _RequestMenuAction.editRequest),
MenuItem.separator(),
_menuAction(localizations.requestRewrite, _RequestMenuAction.requestRewrite),
_menuAction(localizations.requestMap, _RequestMenuAction.requestMap),
_menuAction(localizations.script, _RequestMenuAction.script),
MenuItem.separator(),
_menuAction(localizations.favorite, _RequestMenuAction.favorite),
MenuItem(label: localizations.highlight, type: 'submenu', submenu: highlightMenu()),
MenuItem.separator(),
_menuAction(localizations.selectAction, _RequestMenuAction.select),
MenuItem.separator(),
_menuAction(localizations.delete, _RequestMenuAction.delete),
]);
}
Menu _copySubmenu() {
return Menu(items: [
_copyMenuAction(localizations.copyCurl, _RequestCopyMenuAction.curl),
_copyMenuAction(localizations.copyRawRequest, _RequestCopyMenuAction.rawRequest),
_copyMenuAction(localizations.copyRequestResponse, _RequestCopyMenuAction.requestResponse),
_copyMenuAction(localizations.copyAsPythonRequests, _RequestCopyMenuAction.pythonRequests),
_copyMenuAction(localizations.copyAsFetch, _RequestCopyMenuAction.fetch),
]);
}
Menu _exportSubmenu() {
return Menu(items: [
_exportMenuAction(localizations.request, _RequestExportMenuAction.request),
_exportMenuAction(localizations.requestBody, _RequestExportMenuAction.requestBody),
MenuItem.separator(),
_exportMenuAction(localizations.response, _RequestExportMenuAction.response),
_exportMenuAction(localizations.responseBody, _RequestExportMenuAction.responseBody),
MenuItem.separator(),
_exportMenuAction('HAR', _RequestExportMenuAction.har),
]);
}
MenuItem _menuAction(String label, _RequestMenuAction action) {
return MenuItem(label: label, onClick: (_) => _onMenuAction(action));
}
MenuItem _copyMenuAction(String label, _RequestCopyMenuAction action) {
return MenuItem(label: label, onClick: (_) => _onCopyMenuAction(action));
}
MenuItem _exportMenuAction(String label, _RequestExportMenuAction action) {
return MenuItem(label: label, onClick: (_) => _onExportMenuAction(action));
}
Future<void> _onMenuAction(_RequestMenuAction action) async {
switch (action) {
case _RequestMenuAction.copyUrl:
await _copyText(widget.request.requestUrl);
break;
case _RequestMenuAction.openNewWindow:
openDetailInNewWindow();
break;
case _RequestMenuAction.repeat:
onRepeat(widget.request);
break;
case _RequestMenuAction.customRepeat:
await showCustomRepeat(widget.request);
break;
case _RequestMenuAction.editRequest:
WidgetsBinding.instance.addPostFrameCallback((_) {
requestEdit();
});
break;
case _RequestMenuAction.requestRewrite:
showRequestRewriteDialog(context, widget.request);
break;
case _RequestMenuAction.requestMap:
showDialog(
context: context,
builder: (context) =>
RequestMapEdit(url: widget.request.domainPath, title: widget.request.hostAndPort?.host));
break;
case _RequestMenuAction.script:
await _openScriptDialog();
break;
case _RequestMenuAction.favorite:
FavoriteStorage.addFavorite(widget.request);
FlutterToastr.show(localizations.operationSuccess, context, rootNavigator: true);
break;
case _RequestMenuAction.select:
widget.multiSelectController.selectOnly(widget.request.requestId);
break;
case _RequestMenuAction.delete:
widget.remove?.call(widget);
break;
case _RequestMenuAction.batchRepeat:
widget.selectionHandlers.onRepeatSelected?.call();
break;
case _RequestMenuAction.batchExport:
widget.selectionHandlers.onExportSelected?.call();
break;
case _RequestMenuAction.batchDelete:
widget.selectionHandlers.onDeleteSelected?.call();
break;
case _RequestMenuAction.batchCancel:
widget.multiSelectController.clear();
break;
}
}
Future<void> _onCopyMenuAction(_RequestCopyMenuAction action) async {
switch (action) {
case _RequestCopyMenuAction.curl:
await _copyText(curlRequest(widget.request));
break;
case _RequestCopyMenuAction.rawRequest:
await _copyText(copyRawRequest(widget.request));
break;
case _RequestCopyMenuAction.requestResponse:
await _copyText(copyRequest(widget.request, widget.response.get()));
break;
case _RequestCopyMenuAction.pythonRequests:
await _copyText(copyAsPythonRequests(widget.request));
break;
case _RequestCopyMenuAction.fetch:
await _copyText(copyAsFetch(widget.request));
break;
}
}
void _onExportMenuAction(_RequestExportMenuAction action) {
switch (action) {
case _RequestExportMenuAction.request:
exportRequest(widget.request);
break;
case _RequestExportMenuAction.requestBody:
exportRequestBody(widget.request);
break;
case _RequestExportMenuAction.response:
exportResponse(widget.response.get());
break;
case _RequestExportMenuAction.responseBody:
exportResponseBody(widget.response.get());
break;
case _RequestExportMenuAction.har:
exportHar(widget.request);
break;
}
}
Future<void> _openScriptDialog() async {
var scriptManager = await ScriptManager.instance;
var url = widget.request.domainPath;
var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
if (!mounted) {
return;
}
showDialog(
context: context,
builder: (context) =>
ScriptEdit(scriptItem: scriptItem, script: script, url: url, title: widget.request.hostAndPort?.host));
}
Future<void> _copyText(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (mounted) {
FlutterToastr.show(localizations.copied, rootNavigator: true, context);
}
}
///高亮
@@ -415,6 +520,22 @@ class _RequestWidgetState extends State<RequestWidget> {
//点击事件
void onClick() {
final keyboard = HardwareKeyboard.instance;
final useToggleSelection = keyboard.isMetaPressed || keyboard.isControlPressed;
final useRangeSelection = keyboard.isShiftPressed;
if (useRangeSelection) {
widget.selectionHandlers.onRangeSelection?.call(widget.request);
return;
}
if (selectionMode || useToggleSelection) {
setState(() {
widget.multiSelectController.toggle(widget.request.requestId);
});
return;
}
if (!selected) {
setState(() {
selected = true;
@@ -436,3 +557,39 @@ class _RequestWidgetState extends State<RequestWidget> {
NetworkTabController.current?.change(widget.request, widget.response.get() ?? widget.request.response);
}
}
class RequestSelectionHandlers {
final Function(HttpRequest request)? onRangeSelection;
final VoidCallback? onDeleteSelected;
final VoidCallback? onRepeatSelected;
final VoidCallback? onExportSelected;
const RequestSelectionHandlers({
this.onRangeSelection,
this.onDeleteSelected,
this.onRepeatSelected,
this.onExportSelected,
});
}
enum _RequestMenuAction {
copyUrl,
openNewWindow,
repeat,
customRepeat,
editRequest,
requestRewrite,
requestMap,
script,
favorite,
select,
delete,
batchRepeat,
batchExport,
batchDelete,
batchCancel,
}
enum _RequestCopyMenuAction { curl, rawRequest, requestResponse, pythonRequests, fetch }
enum _RequestExportMenuAction { request, requestBody, response, responseBody, har }

View File

@@ -17,9 +17,11 @@ import 'dart:collection';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:proxypin/network/bin/configuration.dart';
import 'package:proxypin/network/bin/server.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/desktop/request/request.dart';
import 'package:proxypin/utils/keyword_highlight.dart';
@@ -34,9 +36,17 @@ class RequestSequence extends StatefulWidget {
final ProxyServer proxyServer;
final bool displayDomain;
final Function(List<HttpRequest>)? onRemove;
final MultiSelectController selectionController;
final RequestSelectionHandlers selectionHandlers;
const RequestSequence(
{super.key, required this.container, required this.proxyServer, this.displayDomain = true, this.onRemove});
{super.key,
required this.container,
required this.proxyServer,
this.displayDomain = true,
this.onRemove,
required this.selectionController,
required this.selectionHandlers});
@override
State<StatefulWidget> createState() {
@@ -49,15 +59,21 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
///显示的请求列表 最新的在前面
Queue<HttpRequest> view = Queue();
final Map<String, GlobalKey> rowKeys = <String, GlobalKey>{};
bool changing = false;
bool sortDesc = true;
AppLocalizations get localizations => AppLocalizations.of(context)!;
//搜索的内容
SearchModel? searchModel;
//关键词高亮监听
late VoidCallback highlightListener;
late MultiSelectListener<String> selectionListener;
MultiSelectController get selectionController => widget.selectionController;
@override
void initState() {
@@ -72,6 +88,14 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
});
};
KeywordHighlights.addListener(highlightListener);
selectionListener = MultiSelectListener((items) {
if (!mounted) {
return;
}
_refreshChangedRows(items);
});
selectionController.selectedIds.addListener(selectionListener);
}
void changeState() {
@@ -91,6 +115,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
@override
void dispose() {
selectionController.selectedIds.removeListener(selectionListener);
KeywordHighlights.removeListener(highlightListener);
super.dispose();
}
@@ -98,26 +123,34 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.separated(
cacheExtent: 1000,
separatorBuilder: (context, index) => Divider(thickness: 0.2, height: 0, color: Theme.of(context).dividerColor),
itemCount: view.length,
itemBuilder: (context, index) {
return RequestWidget(
key: ValueKey(view.elementAt(index).requestId),
view.elementAt(index),
index: sortDesc ? view.length - index : index,
trailing: appIcon(view.elementAt(index)),
proxyServer: widget.proxyServer,
displayDomain: widget.displayDomain,
remove: (requestWidget) {
setState(() {
view.remove(requestWidget.request);
});
cacheExtent: 1000,
separatorBuilder: (context, index) => Divider(thickness: 0.2, height: 0, color: Theme.of(context).dividerColor),
itemCount: view.length,
itemBuilder: (context, index) {
final request = view.elementAt(index);
final requestId = request.requestId;
final key = rowKeys.putIfAbsent(requestId, () => GlobalKey());
return RequestWidget(
key: key,
request,
index: sortDesc ? view.length - index : index,
trailing: appIcon(request),
proxyServer: widget.proxyServer,
displayDomain: widget.displayDomain,
multiSelectController: selectionController,
selectionHandlers: widget.selectionHandlers,
remove: (requestWidget) {
setState(() {
view.remove(requestWidget.request);
rowKeys.remove(requestWidget.request.requestId);
widget.onRemove?.call([requestWidget.request]);
},
);
});
});
},
);
},
);
}
Widget? appIcon(HttpRequest request) {
@@ -156,6 +189,8 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
view.addLast(request);
}
rowKeys.putIfAbsent(request.requestId, () => GlobalKey());
changeState();
}
@@ -170,6 +205,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
if (searchModel?.filter(response.request!, response) == true) {
if (!view.contains(response.request)) {
view.addFirst(response.request!);
rowKeys.putIfAbsent(response.request!.requestId, () => GlobalKey());
changeState();
}
}
@@ -183,22 +219,34 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
} else {
view = Queue.of(widget.container.where((it) => searchModel.filter(it, it.response)).toList().reversed);
}
rowKeys.removeWhere((requestId, _) => !view.any((request) => request.requestId == requestId));
selectionController.prune(view.map((request) => request.requestId));
setState(() {});
}
void remove(List<HttpRequest> list) {
setState(() {
view.removeWhere((element) => list.contains(element));
for (final request in list) {
rowKeys.remove(request.requestId);
}
});
}
void clean() {
setState(() {
view.clear();
rowKeys.clear();
view.addAll(widget.container.source.reversed);
});
}
void selectRange(HttpRequest request) {
setState(() {
selectionController.selectRange(view.map((item) => item.requestId).toList(), request.requestId);
});
}
///排序
void sort(bool desc) {
sortDesc = desc;
@@ -206,4 +254,15 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
view = Queue.of(view.toList().reversed);
});
}
void _refreshChangedRows(List<String> changedIds) {
if (changedIds.isEmpty) {
return;
}
for (final requestId in changedIds) {
final key = rowKeys[requestId];
key?.currentState?.setState(() {});
}
}
}

View File

@@ -52,6 +52,10 @@ class _ToolbarState extends State<Toolbar> {
}
bool onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) {
return false;
}
if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape)) {
if (ModalRoute.of(context)?.isCurrent == false) {
Navigator.maybePop(context);

View File

@@ -15,7 +15,7 @@
*/
abstract class ListenerListEvent<T> {
/// 监听的源
sourceAware(List<T> source) {}
void sourceAware(List<T> source) {}
void onAdd(T item);
@@ -25,7 +25,7 @@ abstract class ListenerListEvent<T> {
void onBatchRemove(List<T> items);
clear();
void clear(List<T> items);
}
class OnchangeListEvent<T> extends ListenerListEvent<T> {
@@ -46,7 +46,7 @@ class OnchangeListEvent<T> extends ListenerListEvent<T> {
void onBatchRemove(List<T> items) => onChange.call();
@override
clear() => onChange.call();
clear(List<T> items) => onChange.call();
}
/// 可监听list
@@ -85,6 +85,15 @@ class ListenableList<T> extends Iterable<T> {
return source.sublist(start, end);
}
void addAll(Iterable<T> items) {
source.addAll(items);
for (var element in _listeners) {
for (var item in items) {
element.onAdd(item);
}
}
}
List<T> removeRange(int start, int end) {
final normalizedEnd = end > source.length ? source.length : end;
if (start < 0 || start >= normalizedEnd) {
@@ -94,7 +103,7 @@ class ListenableList<T> extends Iterable<T> {
final removed = source.sublist(start, normalizedEnd);
source.removeRange(start, normalizedEnd);
for (var element in _listeners) {
element.clear();
element.clear(removed);
}
return removed;
}
@@ -134,9 +143,10 @@ class ListenableList<T> extends Iterable<T> {
}
void clear() {
final removed = List<T>.from(source);
source.clear();
for (var element in _listeners) {
element.clear();
element.clear(removed);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
void main() {
group('MultiSelectController', () {
test('toggle enables and clears selection mode', () {
final controller = MultiSelectController();
controller.toggle('a');
expect(controller.isSelectionMode, isTrue);
expect(controller.selectedIds, {'a'});
controller.toggle('a');
expect(controller.isSelectionMode, isFalse);
expect(controller.selectedIds, isEmpty);
});
test('selectRange uses anchor item', () {
final controller = MultiSelectController();
controller.selectOnly('b');
controller.selectRange(['a', 'b', 'c', 'd'], 'd');
expect(controller.selectedIds, {'b', 'c', 'd'});
});
test('selectAll and prune keep only visible ids', () {
final controller = MultiSelectController();
controller.prune(['b', 'c', 'd']);
expect(controller.isSelectionMode, isTrue);
expect(controller.selectedIds, {'b', 'c'});
});
test('prune clears selection mode when all selected ids disappear', () {
final controller = MultiSelectController();
controller.prune(['c']);
expect(controller.isSelectionMode, isFalse);
expect(controller.selectedIds, isEmpty);
});
});
}