mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-06-01 17:15:48 +08:00
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
121
lib/ui/component/multi_select_controller.dart
Normal file
121
lib/ui/component/multi_select_controller.dart
Normal 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);
|
||||
}
|
||||
85
lib/ui/component/selection_action_bar.dart
Normal file
85
lib/ui/component/selection_action_bar.dart
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
test/multi_select_controller_test.dart
Normal file
46
test/multi_select_controller_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user