feat(selection): implement mobile multi-select functionality (#730) (#566)

This commit is contained in:
wanghongenpin
2026-05-26 16:24:21 +08:00
parent cc6c454eff
commit 215a4af98a
16 changed files with 458 additions and 187 deletions

View File

@@ -152,6 +152,7 @@
"domainListSubtitle": "Last Request Time: {time}, Count: {count}",
"selectAction": "Select action",
"select": "Select",
"copy": "Copy",
"copyHost": "Copy Host",
"copyUrl": "Copy URL",

View File

@@ -972,6 +972,12 @@ abstract class AppLocalizations {
/// **'Select action'**
String get selectAction;
/// No description provided for @select.
///
/// In en, this message translates to:
/// **'Select'**
String get select;
/// No description provided for @copy.
///
/// In en, this message translates to:

View File

@@ -452,6 +452,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get selectAction => 'Select action';
@override
String get select => 'Select';
@override
String get copy => 'Copy';

View File

@@ -450,6 +450,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get selectAction => '选择操作';
@override
String get select => '选择';
@override
String get copy => '复制';
@@ -1541,6 +1544,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get selectAction => '選擇操作';
@override
String get select => '選擇';
@override
String get copy => '複製';

View File

@@ -153,6 +153,7 @@
"domainListSubtitle": "最后请求时间: {time}, 次数: {count}",
"selectAction": "选择操作",
"select": "选择",
"copy": "复制",
"copyHost": "复制域名",
"copyUrl": "复制URL",

View File

@@ -145,6 +145,7 @@
"deleteWhitelist": "刪除代理白名單",
"domainListSubtitle": "最後請求時間: {time}, 次數: {count}",
"selectAction": "選擇操作",
"select": "選擇",
"copy": "複製",
"copyHost": "複製網域名稱",
"copyUrl": "複製URL",

View File

@@ -22,6 +22,10 @@ class MultiSelectController {
selectionMode.value = false;
}
void remove(String requestId) {
selectedIds.remove(requestId);
}
void enterSelectionMode([String? requestId]) {
selectionMode.value = true;
if (requestId != null) {

View File

@@ -340,6 +340,7 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
void repeatSelected() {
final selectedRequests = domainListKey.currentState?.selectedRequests();
_repeatRequests(selectedRequests);
selectionController.clear();
}
Future<void> exportSelected() async {
@@ -350,6 +351,7 @@ class DesktopRequestListState extends State<DesktopRequestListWidget> with Autom
final fileName = 'ProxyPin_selected_${DateTime.now().dateFormat()}.har';
_doExport(fileName, selectedRequests);
selectionController.clear();
}
///导出

View File

@@ -240,7 +240,7 @@ class _RequestWidgetState extends State<RequestWidget> {
_menuAction(localizations.favorite, _RequestMenuAction.favorite),
MenuItem(label: localizations.highlight, type: 'submenu', submenu: highlightMenu()),
MenuItem.separator(),
_menuAction(localizations.selectAction, _RequestMenuAction.select),
_menuAction(localizations.select, _RequestMenuAction.select),
MenuItem.separator(),
_menuAction(localizations.delete, _RequestMenuAction.delete),
]);

View File

@@ -124,6 +124,17 @@ class MoreMenu extends StatelessWidget {
MobileApp.requestStateKey.currentState?.export(context, 'ProxyPin$name');
},
)),
PopupMenuItem(
height: 32,
child: ListTile(
dense: true,
leading: const Icon(Icons.checklist_rtl_outlined),
title: Text(localizations.selectAction),
onTap: () async {
await Navigator.maybePop(context);
MobileApp.multiSelectController.toggleSelectionMode();
},
)),
PopupMenuItem(
height: 32,
child: ListTile(

View File

@@ -35,6 +35,7 @@ import 'package:proxypin/network/http/websocket.dart';
import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/storage/histories.dart';
import 'package:proxypin/ui/component/memory_cleanup.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/toolbox/toolbox.dart';
import 'package:proxypin/ui/configuration.dart';
import 'package:proxypin/ui/content/panel.dart';
@@ -79,6 +80,8 @@ class MobileApp {
///请求列表容器
static final container = ListenableList<HttpRequest>();
static final multiSelectController = MultiSelectController();
}
class MobileHomeState extends State<MobileHomePage> implements EventListener, LifecycleListener {
@@ -434,7 +437,10 @@ class RequestPageState extends State<RequestPage> {
value.connect ? remoteConnect(value) : const SizedBox(),
Expanded(
child: RequestListWidget(
key: MobileApp.requestStateKey, proxyServer: proxyServer, list: MobileApp.container))
key: MobileApp.requestStateKey,
proxyServer: proxyServer,
list: MobileApp.container,
selectionController: MobileApp.multiSelectController))
]);
}),
),

View File

@@ -29,6 +29,7 @@ import 'package:proxypin/network/components/host_filter.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/widgets.dart';
import 'package:proxypin/ui/mobile/request/request_sequence.dart';
import 'package:proxypin/utils/har.dart';
@@ -238,22 +239,24 @@ class DomainListState extends State<DomainList> with AutomaticKeepAliveClientMix
return Scaffold(
appBar: AppBar(title: Text(view.elementAt(index).domain, style: const TextStyle(fontSize: 16))),
body: RequestSequence(
key: requestSequenceKey,
displayDomain: false,
container: ListenableList(sortDesc ? list : list?.reversed.toList()),
sortDesc: sortDesc,
onRemove: widget.onRemove,
proxyServer: widget.proxyServer));
key: requestSequenceKey,
displayDomain: false,
container: ListenableList(sortDesc ? list : list?.reversed.toList()),
sortDesc: sortDesc,
onRemove: widget.onRemove,
proxyServer: widget.proxyServer,
selectionController: MultiSelectController(),
));
}));
});
}
scrollToTop() {
void scrollToTop() {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
///菜单
menu(int index) {
void menu(int index) {
var hostAndPort = view.elementAt(index);
showModalBottomSheet(

View File

@@ -31,6 +31,7 @@ import 'package:proxypin/network/http/http_client.dart';
import 'package:proxypin/network/util/logger.dart';
import 'package:proxypin/storage/histories.dart';
import 'package:proxypin/ui/component/history_cache_time.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/mobile/request/list.dart';
@@ -412,11 +413,13 @@ class HistoryRecord extends StatefulWidget {
}
class _HistoryRecordState extends State<HistoryRecord> {
GlobalKey<RequestListState> requestStateKey = GlobalKey<RequestListState>();
final GlobalKey<RequestListState> requestStateKey = GlobalKey<RequestListState>();
///搜索key
final GlobalKey<MobileSearchState> searchStateKey = GlobalKey<MobileSearchState>();
final MultiSelectController multiSelectController = MultiSelectController();
var searchEnabled = ValueNotifier(false);
AppLocalizations get localizations => AppLocalizations.of(context)!;
@@ -424,6 +427,7 @@ class _HistoryRecordState extends State<HistoryRecord> {
@override
void dispose() {
searchEnabled.dispose();
multiSelectController.clear();
super.dispose();
}
@@ -462,6 +466,16 @@ class _HistoryRecordState extends State<HistoryRecord> {
PopupMenuItem(
onTap: () => export(context),
child: IconText(icon: const Icon(Icons.share), text: localizations.viewExport)),
PopupMenuItem(
height: 32,
child: ListTile(
dense: true,
leading: const Icon(Icons.checklist_rtl_outlined),
title: Text(localizations.selectAction),
onTap: () async {
await Navigator.maybePop(context);
multiSelectController.toggleSelectionMode();
})),
PopupMenuItem(
onTap: () async {
var requests = requestStateKey.currentState?.currentView();
@@ -476,10 +490,15 @@ class _HistoryRecordState extends State<HistoryRecord> {
],
)),
body: futureWidget(
loading: true,
HistoryStorage.instance.then((storage) => storage.getRequests(widget.history)),
(data) =>
RequestListWidget(proxyServer: widget.proxyServer, list: ListenableList(data), key: requestStateKey)));
loading: true,
HistoryStorage.instance.then((storage) => storage.getRequests(widget.history)),
(data) => RequestListWidget(
proxyServer: widget.proxyServer,
list: ListenableList(data),
key: requestStateKey,
selectionController: multiSelectController,
),
));
}
//导出har

View File

@@ -22,6 +22,7 @@ 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/http/http.dart';
import 'package:proxypin/ui/component/multi_select_controller.dart';
import 'package:proxypin/ui/mobile/request/domians.dart';
import 'package:proxypin/ui/mobile/request/request.dart';
import 'package:proxypin/ui/mobile/request/request_sequence.dart';
@@ -37,8 +38,9 @@ import '../../component/model/search_model.dart';
class RequestListWidget extends StatefulWidget {
final ProxyServer proxyServer;
final ListenableList<HttpRequest>? list;
final MultiSelectController selectionController;
const RequestListWidget({super.key, required this.proxyServer, this.list});
const RequestListWidget({super.key, required this.proxyServer, this.list, required this.selectionController});
@override
State<StatefulWidget> createState() {
@@ -91,7 +93,8 @@ class RequestListState extends State<RequestListWidget> {
key: requestSequenceKey,
container: container,
proxyServer: widget.proxyServer,
onRemove: sequenceRemove),
onRemove: sequenceRemove,
selectionController: widget.selectionController),
DomainList(
key: domainListKey, list: container, proxyServer: widget.proxyServer, onRemove: domainListRemove),
],
@@ -182,7 +185,6 @@ class RequestListState extends State<RequestListWidget> {
requestSequenceKey.currentState?.sort(sortDesc);
domainListKey.currentState?.sort(sortDesc);
}
}
class DoubleClickHandle {

View File

@@ -29,10 +29,12 @@ 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/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
import 'package:proxypin/ui/configuration.dart';
import 'package:proxypin/ui/content/panel.dart';
import 'package:proxypin/ui/desktop/request/request.dart';
import 'package:proxypin/ui/mobile/request/repeat.dart';
import 'package:proxypin/ui/mobile/request/request_editor.dart';
import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
@@ -50,14 +52,19 @@ class RequestRow extends StatefulWidget {
final ProxyServer proxyServer;
final bool displayDomain;
final Function(HttpRequest)? onRemove;
final MultiSelectController selectionController;
final RequestSelectionHandlers selectionHandlers;
const RequestRow(
{super.key,
required this.request,
required this.proxyServer,
this.displayDomain = true,
this.onRemove,
required this.index});
const RequestRow({
super.key,
required this.request,
required this.proxyServer,
this.displayDomain = true,
this.onRemove,
required this.selectionController,
required this.index,
required this.selectionHandlers,
});
@override
State<StatefulWidget> createState() {
@@ -134,10 +141,11 @@ class RequestRowState extends State<RequestRow> {
child: ListTile(
visualDensity: const VisualDensity(vertical: -4),
minLeadingWidth: 5,
selected: selected,
selected: selected ||
(widget.selectionController.isSelectionMode && widget.selectionController.contains(request.requestId)),
textColor: highlightColor,
selectedColor: highlightColor,
leading: appIcon(),
leading: rowLeading(),
title: Text(title.fixAutoLines(),
overflow: TextOverflow.ellipsis, maxLines: 2, style: const TextStyle(fontSize: 14)),
subtitle: Text.rich(
@@ -150,6 +158,11 @@ class RequestRowState extends State<RequestRow> {
contentPadding:
Platform.isIOS ? const EdgeInsets.symmetric(horizontal: 8) : const EdgeInsets.only(left: 3, right: 5),
onTap: () {
if (widget.selectionController.isSelectionMode) {
widget.selectionController.toggle(request.requestId);
return;
}
if (AppConfiguration.current?.autoReadEnabled == true) {
if (markAutoRead(request.requestId)) {
setState(() {});
@@ -167,6 +180,23 @@ class RequestRowState extends State<RequestRow> {
));
}
Widget? rowLeading() {
var icon = appIcon();
if (!widget.selectionController.isSelectionMode) {
return icon;
}
bool isSelected = widget.selectionController.contains(request.requestId);
var checkbox = Icon(isSelected ? Icons.check_box_outlined : Icons.check_box_outline_blank_outlined,
size: 20, color: isSelected ? Theme.of(context).colorScheme.primary : null);
if (icon == null) {
return checkbox;
}
return Row(mainAxisSize: MainAxisSize.min, children: [checkbox, const SizedBox(width: 6), icon]);
}
Widget? appIcon() {
if (Platform.isIOS) {
return null;
@@ -201,6 +231,7 @@ class RequestRowState extends State<RequestRow> {
var globalPosition = details.globalPosition;
MediaQueryData mediaQuery = MediaQuery.of(context);
var position = RelativeRect.fromLTRB(globalPosition.dx, globalPosition.dy, globalPosition.dx, globalPosition.dy);
final selectionMode = widget.selectionController.isSelectionMode;
// Trigger haptic feedback
if (Platform.isAndroid) HapticFeedback.mediumImpact();
@@ -209,145 +240,189 @@ class RequestRowState extends State<RequestRow> {
constraints: BoxConstraints(maxWidth: mediaQuery.size.width * 0.88),
position: position,
items: [
//复制url
PopupMenuContainer(
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: 20, top: 5),
child: Text(localizations.selectAction, style: Theme.of(context).textTheme.bodyLarge)),
),
//copy
menuItem(
left: itemButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: request.requestUrl)).then((value) {
FlutterToastr.show(localizations.copied, getContext());
Navigator.maybePop(getContext());
});
},
label: localizations.copyUrl,
icon: Icons.link,
iconSize: 22),
right: itemButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: curlRequest(request))).then((value) {
FlutterToastr.show(localizations.copied, getContext());
Navigator.maybePop(getContext());
});
},
label: localizations.copyCurl,
icon: Icons.code),
),
//repeat
menuItem(
left: itemButton(
onPressed: () {
onRepeat(request);
Navigator.maybePop(getContext());
},
label: localizations.repeat,
icon: Icons.repeat_one),
right: itemButton(
onPressed: () => showCustomRepeat(request), label: localizations.customRepeat, icon: Icons.repeat),
),
//favorite and edit
menuItem(
left: itemButton(
onPressed: () {
FavoriteStorage.addFavorite(widget.request);
FlutterToastr.show(localizations.addSuccess, availableContext);
Navigator.maybePop(availableContext);
},
label: localizations.favorite,
icon: Icons.favorite_outline),
right: itemButton(
onPressed: () async {
await Navigator.maybePop(availableContext);
children: selectionMode
? [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: 20, top: 5),
child: Text(localizations.selectAction, style: Theme.of(context).textTheme.bodyLarge)),
),
menuItem(
left: itemButton(
onPressed: () {
widget.selectionHandlers.onExportSelected?.call();
Navigator.maybePop(availableContext);
},
label: localizations.export,
icon: Icons.checklist_rtl_outlined),
right: itemButton(
onPressed: () {
widget.selectionHandlers.onRepeatSelected?.call();
Navigator.maybePop(availableContext);
},
label: localizations.repeat,
icon: Icons.delete_outline),
),
SizedBox(height: 1),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
itemButton(
onPressed: () {
widget.selectionHandlers.onDeleteSelected?.call();
Navigator.maybePop(availableContext);
},
label: localizations.delete,
icon: Icons.delete_outline),
SizedBox(width: 15),
]),
]
: [
//复制url
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: 20, top: 5),
child: Text(localizations.selectAction, style: Theme.of(context).textTheme.bodyLarge)),
),
//copy
menuItem(
left: itemButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: request.requestUrl)).then((value) {
FlutterToastr.show(localizations.copied, getContext());
Navigator.maybePop(getContext());
});
},
label: localizations.copyUrl,
icon: Icons.link,
iconSize: 22),
right: itemButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: curlRequest(request))).then((value) {
FlutterToastr.show(localizations.copied, getContext());
Navigator.maybePop(getContext());
});
},
label: localizations.copyCurl,
icon: Icons.code),
),
//repeat
menuItem(
left: itemButton(
onPressed: () {
onRepeat(request);
Navigator.maybePop(getContext());
},
label: localizations.repeat,
icon: Icons.repeat_one),
right: itemButton(
onPressed: () => showCustomRepeat(request),
label: localizations.customRepeat,
icon: Icons.repeat),
),
//favorite and edit
menuItem(
left: itemButton(
onPressed: () {
FavoriteStorage.addFavorite(widget.request);
FlutterToastr.show(localizations.addSuccess, availableContext);
Navigator.maybePop(availableContext);
},
label: localizations.favorite,
icon: Icons.favorite_outline),
right: itemButton(
onPressed: () async {
await Navigator.maybePop(availableContext);
var pageRoute = MaterialPageRoute(
builder: (context) =>
MobileRequestEditor(request: widget.request, proxyServer: widget.proxyServer));
Navigator.push(getContext(), pageRoute);
},
label: localizations.editRequest,
icon: Icons.replay_outlined),
),
//script and rewrite
menuItem(
left: itemButton(
onPressed: () async {
Navigator.maybePop(availableContext);
var pageRoute = MaterialPageRoute(
builder: (context) =>
MobileRequestEditor(request: widget.request, proxyServer: widget.proxyServer));
Navigator.push(getContext(), pageRoute);
},
label: localizations.editRequest,
icon: Icons.replay_outlined),
),
//script and rewrite
menuItem(
left: itemButton(
onPressed: () async {
Navigator.maybePop(availableContext);
var scriptManager = await ScriptManager.instance;
var url = request.domainPath;
var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
var scriptManager = await ScriptManager.instance;
var url = request.domainPath;
var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.urls.contains(url));
String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem);
var pageRoute = MaterialPageRoute(
builder: (context) => ScriptEdit(
scriptItem: scriptItem,
script: script,
urls: scriptItem?.urls ?? [url],
title: request.hostAndPort?.host));
var pageRoute = MaterialPageRoute(
builder: (context) => ScriptEdit(
scriptItem: scriptItem,
script: script,
urls: scriptItem?.urls ?? [url],
title: request.hostAndPort?.host));
Navigator.push(getContext(), pageRoute);
},
label: localizations.script,
icon: Icons.javascript_outlined),
right: itemButton(
onPressed: () async {
Navigator.maybePop(availableContext);
bool isRequest = response == null;
var requestRewrites = await RequestRewriteManager.instance;
Navigator.push(getContext(), pageRoute);
},
label: localizations.script,
icon: Icons.javascript_outlined),
right: itemButton(
onPressed: () async {
Navigator.maybePop(availableContext);
bool isRequest = response == null;
var requestRewrites = await RequestRewriteManager.instance;
var ruleType = isRequest ? RuleType.requestReplace : RuleType.responseReplace;
var rule = requestRewrites.getRequestRewriteRule(request, ruleType);
var ruleType = isRequest ? RuleType.requestReplace : RuleType.responseReplace;
var rule = requestRewrites.getRequestRewriteRule(request, ruleType);
var rewriteItems = await requestRewrites.getRewriteItems(rule);
var rewriteItems = await requestRewrites.getRewriteItems(rule);
var pageRoute = MaterialPageRoute(
builder: (_) => RewriteRule(rule: rule, items: rewriteItems, request: request));
var context = availableContext;
if (context.mounted) Navigator.push(context, pageRoute);
},
label: localizations.requestRewrite,
icon: Icons.edit_outlined),
),
menuItem(
left: itemButton(
onPressed: () {
highlightColor = Theme.of(availableContext).colorScheme.primary;
Navigator.maybePop(availableContext);
},
label: localizations.highlight,
icon: Icons.highlight_outlined),
right: itemButton(
onPressed: () {
AppConfiguration.current?.autoReadEnabled = !AppConfiguration.current!.autoReadEnabled;
highlightColor = Colors.grey;
Navigator.maybePop(availableContext);
},
label: localizations.autoRead,
icon: AppConfiguration.current?.autoReadEnabled == true
? Icons.check_box_outlined
: Icons.check_box_outline_blank_outlined),
),
SizedBox(height: 2),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
itemButton(
onPressed: () {
widget.onRemove?.call(request);
FlutterToastr.show(localizations.deleteSuccess, availableContext);
Navigator.maybePop(availableContext);
},
label: localizations.delete,
icon: Icons.delete_outline),
SizedBox(width: 15),
]),
],
var pageRoute = MaterialPageRoute(
builder: (_) => RewriteRule(rule: rule, items: rewriteItems, request: request));
var context = availableContext;
if (context.mounted) Navigator.push(context, pageRoute);
},
label: localizations.requestRewrite,
icon: Icons.edit_outlined),
),
menuItem(
left: itemButton(
onPressed: () {
highlightColor = Theme.of(availableContext).colorScheme.primary;
Navigator.maybePop(availableContext);
},
label: localizations.highlight,
icon: Icons.highlight_outlined),
right: itemButton(
onPressed: () {
AppConfiguration.current?.autoReadEnabled = !AppConfiguration.current!.autoReadEnabled;
highlightColor = Colors.grey;
Navigator.maybePop(availableContext);
},
label: localizations.autoRead,
icon: AppConfiguration.current?.autoReadEnabled == true
? Icons.check_box_outlined
: Icons.check_box_outline_blank_outlined),
),
SizedBox(height: 1),
menuItem(
left: itemButton(
onPressed: () {
widget.selectionController.toggle(request.requestId);
Navigator.maybePop(availableContext);
},
label: localizations.select,
icon: Icons.checklist_rtl_outlined),
right: itemButton(
onPressed: () {
widget.onRemove?.call(request);
FlutterToastr.show(localizations.deleteSuccess, availableContext);
Navigator.maybePop(availableContext);
},
label: localizations.delete,
icon: Icons.delete_outline),
),
],
)),
]).then((value) {
selected = false;

View File

@@ -1,12 +1,25 @@
import 'dart:collection';
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:get/get.dart';
import 'package:proxypin/l10n/app_localizations.dart';
import 'package:proxypin/network/bin/server.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/selection_action_bar.dart';
import 'package:proxypin/ui/component/utils.dart';
import 'package:proxypin/ui/desktop/request/request.dart';
import 'package:proxypin/ui/mobile/request/request.dart';
import 'package:proxypin/utils/har.dart';
import 'package:proxypin/utils/keyword_highlight.dart';
import 'package:proxypin/utils/listenable_list.dart';
import '../../../network/channel/host_port.dart' show ProxyInfo;
import '../../../utils/lang.dart';
import '../../component/model/search_model.dart';
///请求序列 列表
@@ -17,6 +30,7 @@ class RequestSequence extends StatefulWidget {
final bool displayDomain;
final bool? sortDesc;
final Function(List<HttpRequest>)? onRemove;
final MultiSelectController selectionController;
const RequestSequence(
{super.key,
@@ -24,7 +38,8 @@ class RequestSequence extends StatefulWidget {
required this.proxyServer,
this.displayDomain = true,
this.onRemove,
this.sortDesc});
this.sortDesc,
required this.selectionController});
@override
State<StatefulWidget> createState() {
@@ -35,6 +50,7 @@ class RequestSequence extends StatefulWidget {
class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliveClientMixin {
///请求id和对应的row的映射
Map<String, GlobalKey<RequestRowState>> indexes = HashMap();
late final MultiSelectListener<String> selectionListener;
///显示的请求列表 最新的在前面
Queue<HttpRequest> view = Queue();
@@ -48,11 +64,23 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
//关键词高亮监听
late VoidCallback highlightListener;
MultiSelectController get selectionController => widget.selectionController;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void initState() {
void initState() {
super.initState();
sortDesc = widget.sortDesc ?? true;
view.addAll(widget.container.source.reversed);
selectionListener = MultiSelectListener((items) {
if (!mounted) {
return;
}
setState(() {});
});
widget.selectionController.selectedIds.addListener(selectionListener);
highlightListener = () {
//回调时机在高亮设置页面dispose之后。所以需要在下一帧刷新否则会报错
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
@@ -64,6 +92,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
@override
void dispose() {
widget.selectionController.selectedIds.removeListener(selectionListener);
KeywordHighlights.removeListener(highlightListener);
super.dispose();
}
@@ -103,6 +132,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
}
void clean() {
widget.selectionController.clear();
setState(() {
view.clear();
indexes.clear();
@@ -119,6 +149,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
indexes.remove(requestId);
}
});
selectionController.prune(view.map((request) => request.requestId));
}
///过滤
@@ -129,6 +160,7 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
} else {
view = Queue.of(widget.container.where((it) => searchModel.filter(it, it.response)).toList().reversed);
}
selectionController.prune(view.map((request) => request.requestId));
changeState();
}
@@ -136,7 +168,37 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
return view;
}
changeState() {
void deleteSelected() {
final selected = selectedRequests();
if (selected.isEmpty) {
return;
}
showConfirmDialog(context, content: '${localizations.delete} ${selected.length} ${localizations.request}?',
onConfirm: () {
final removedRequestIds = selected.map((request) => request.requestId).toSet();
setState(() {
view.removeWhere((request) => removedRequestIds.contains(request.requestId));
indexes.removeWhere((requestId, _) => removedRequestIds.contains(requestId));
selectionController.clear();
widget.onRemove?.call(selected);
});
if (mounted) {
FlutterToastr.show(localizations.deleteSuccess, context);
}
});
}
List<HttpRequest> selectedRequests() {
final selectedIds = selectionController.selectedIds.toSet();
if (selectedIds.isEmpty) {
return [];
}
return view.where((request) => selectedIds.contains(request.requestId)).toList();
}
void changeState() {
//防止频繁刷新
if (!changing) {
changing = true;
@@ -155,37 +217,57 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
Widget build(BuildContext context) {
super.build(context);
return Scrollbar(
controller: PrimaryScrollController.maybeOf(context),
child: ListView.separated(
controller: PrimaryScrollController.maybeOf(context),
cacheExtent: 1000,
separatorBuilder: (context, index) =>
Divider(thickness: 0.2, height: 0, color: Theme.of(context).dividerColor),
itemCount: view.length,
itemBuilder: (context, index) {
final requestId = view.elementAt(index).requestId;
return Obx(() {
final selectionMode = selectionController.isSelectionMode;
final key = GlobalKey<RequestRowState>();
indexes[requestId] = key;
return Column(children: [
if (selectionMode)
SelectionActionBar(
selectionController: selectionController,
onRepeat: repeatSelected,
onExport: exportSelected,
onDelete: deleteSelected),
Expanded(
child: Scrollbar(
controller: PrimaryScrollController.maybeOf(context),
child: ListView.separated(
controller: PrimaryScrollController.maybeOf(context),
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;
return RequestRow(
index: sortDesc ? view.length - index : index,
key: key,
request: view.elementAt(index),
proxyServer: widget.proxyServer,
displayDomain: widget.displayDomain,
onRemove: (request) {
setState(() {
view.remove(request);
indexes.remove(requestId);
});
widget.onRemove?.call([request]);
});
}));
final key = GlobalKey<RequestRowState>();
indexes[requestId] = key;
return RequestRow(
index: sortDesc ? view.length - index : index,
key: key,
request: request,
proxyServer: widget.proxyServer,
displayDomain: widget.displayDomain,
selectionController: selectionController,
selectionHandlers: RequestSelectionHandlers(
onDeleteSelected: deleteSelected,
onExportSelected: exportSelected,
onRepeatSelected: repeatSelected),
onRemove: (item) {
setState(() {
view.remove(item);
indexes.remove(requestId);
});
selectionController.remove(request.requestId);
widget.onRemove?.call([item]);
});
})))
]);
});
}
scrollToTop() {
void scrollToTop() {
PrimaryScrollController.maybeOf(context)
?.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.ease);
}
@@ -201,4 +283,53 @@ class RequestSequenceState extends State<RequestSequence> with AutomaticKeepAliv
view = Queue.of(view.toList().reversed);
});
}
void exportSelected() {
final selected = selectedRequests();
if (selected.isEmpty) {
return;
}
_doExport('ProxyPin_selected_${DateTime.now().dateFormat()}.har', selected);
}
void repeatSelected() {
final selected = selectedRequests();
if (selected.isEmpty) {
return;
}
_repeatRequests(selected);
}
Future<void> _doExport(String fileName, List<HttpRequest> requests) async {
var json = await Har.writeJson(requests, title: fileName);
final path = await FilePicker.platform.saveFile(fileName: fileName, bytes: utf8.encode(json));
if (path == null) {
return;
}
selectionController.clear();
if (mounted) {
FlutterToastr.show(localizations.exportSuccess, context);
}
}
Future<void> _repeatRequests(List<HttpRequest> requests) async {
final proxyServer = widget.proxyServer;
selectionController.clear();
for (final request in requests) {
final httpRequest = request.copy(uri: request.requestUrl);
final proxyInfo = proxyServer.isRunning ? ProxyInfo.of('127.0.0.1', proxyServer.port) : null;
try {
await HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo, timeout: const Duration(seconds: 3));
if (mounted) {
FlutterToastr.show(localizations.reSendRequest, rootNavigator: true, context);
}
} catch (e) {
if (mounted) {
FlutterToastr.show('${localizations.fail} $e', rootNavigator: true, context);
}
}
}
}
}