diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dbcc221..a78d200 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -152,6 +152,7 @@ "domainListSubtitle": "Last Request Time: {time}, Count: {count}", "selectAction": "Select action", + "select": "Select", "copy": "Copy", "copyHost": "Copy Host", "copyUrl": "Copy URL", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9b82d15..9dbef6e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index edf5a7d..b333bad 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -452,6 +452,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectAction => 'Select action'; + @override + String get select => 'Select'; + @override String get copy => 'Copy'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9efa432..c6fa0e6 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '複製'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 27a687c..bd73be5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -153,6 +153,7 @@ "domainListSubtitle": "最后请求时间: {time}, 次数: {count}", "selectAction": "选择操作", + "select": "选择", "copy": "复制", "copyHost": "复制域名", "copyUrl": "复制URL", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index f5d7635..2ebfa4a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -145,6 +145,7 @@ "deleteWhitelist": "刪除代理白名單", "domainListSubtitle": "最後請求時間: {time}, 次數: {count}", "selectAction": "選擇操作", + "select": "選擇", "copy": "複製", "copyHost": "複製網域名稱", "copyUrl": "複製URL", diff --git a/lib/ui/component/multi_select_controller.dart b/lib/ui/component/multi_select_controller.dart index dec9f80..5a1077e 100644 --- a/lib/ui/component/multi_select_controller.dart +++ b/lib/ui/component/multi_select_controller.dart @@ -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) { diff --git a/lib/ui/desktop/request/list.dart b/lib/ui/desktop/request/list.dart index 02ffe0e..59d2c4f 100644 --- a/lib/ui/desktop/request/list.dart +++ b/lib/ui/desktop/request/list.dart @@ -340,6 +340,7 @@ class DesktopRequestListState extends State with Autom void repeatSelected() { final selectedRequests = domainListKey.currentState?.selectedRequests(); _repeatRequests(selectedRequests); + selectionController.clear(); } Future exportSelected() async { @@ -350,6 +351,7 @@ class DesktopRequestListState extends State with Autom final fileName = 'ProxyPin_selected_${DateTime.now().dateFormat()}.har'; _doExport(fileName, selectedRequests); + selectionController.clear(); } ///导出 diff --git a/lib/ui/desktop/request/request.dart b/lib/ui/desktop/request/request.dart index 8e5be73..9d3e799 100644 --- a/lib/ui/desktop/request/request.dart +++ b/lib/ui/desktop/request/request.dart @@ -240,7 +240,7 @@ class _RequestWidgetState extends State { _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), ]); diff --git a/lib/ui/mobile/menu/menu.dart b/lib/ui/mobile/menu/menu.dart index 2aacb67..0384739 100644 --- a/lib/ui/mobile/menu/menu.dart +++ b/lib/ui/mobile/menu/menu.dart @@ -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( diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index f4a8ac6..ed8d589 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -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(); + + static final multiSelectController = MultiSelectController(); } class MobileHomeState extends State implements EventListener, LifecycleListener { @@ -434,7 +437,10 @@ class RequestPageState extends State { 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)) ]); }), ), diff --git a/lib/ui/mobile/request/domians.dart b/lib/ui/mobile/request/domians.dart index 0283d80..f17e955 100644 --- a/lib/ui/mobile/request/domians.dart +++ b/lib/ui/mobile/request/domians.dart @@ -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 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( diff --git a/lib/ui/mobile/request/history.dart b/lib/ui/mobile/request/history.dart index 4c11be3..0c10204 100644 --- a/lib/ui/mobile/request/history.dart +++ b/lib/ui/mobile/request/history.dart @@ -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 { - GlobalKey requestStateKey = GlobalKey(); + final GlobalKey requestStateKey = GlobalKey(); ///搜索key final GlobalKey searchStateKey = GlobalKey(); + final MultiSelectController multiSelectController = MultiSelectController(); + var searchEnabled = ValueNotifier(false); AppLocalizations get localizations => AppLocalizations.of(context)!; @@ -424,6 +427,7 @@ class _HistoryRecordState extends State { @override void dispose() { searchEnabled.dispose(); + multiSelectController.clear(); super.dispose(); } @@ -462,6 +466,16 @@ class _HistoryRecordState extends State { 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 { ], )), 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 diff --git a/lib/ui/mobile/request/list.dart b/lib/ui/mobile/request/list.dart index d72d3f5..3f9d0bc 100644 --- a/lib/ui/mobile/request/list.dart +++ b/lib/ui/mobile/request/list.dart @@ -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? 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 createState() { @@ -91,7 +93,8 @@ class RequestListState extends State { 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 { requestSequenceKey.currentState?.sort(sortDesc); domainListKey.currentState?.sort(sortDesc); } - } class DoubleClickHandle { diff --git a/lib/ui/mobile/request/request.dart b/lib/ui/mobile/request/request.dart index e82a50c..4f2c723 100644 --- a/lib/ui/mobile/request/request.dart +++ b/lib/ui/mobile/request/request.dart @@ -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 createState() { @@ -134,10 +141,11 @@ class RequestRowState extends State { 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 { 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 { )); } + 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 { 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 { 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; diff --git a/lib/ui/mobile/request/request_sequence.dart b/lib/ui/mobile/request/request_sequence.dart index 2c4369b..e0db817 100644 --- a/lib/ui/mobile/request/request_sequence.dart +++ b/lib/ui/mobile/request/request_sequence.dart @@ -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)? 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 createState() { @@ -35,6 +50,7 @@ class RequestSequence extends StatefulWidget { class RequestSequenceState extends State with AutomaticKeepAliveClientMixin { ///请求id和对应的row的映射 Map> indexes = HashMap(); + late final MultiSelectListener selectionListener; ///显示的请求列表 最新的在前面 Queue view = Queue(); @@ -48,11 +64,23 @@ class RequestSequenceState extends State 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 with AutomaticKeepAliv @override void dispose() { + widget.selectionController.selectedIds.removeListener(selectionListener); KeywordHighlights.removeListener(highlightListener); super.dispose(); } @@ -103,6 +132,7 @@ class RequestSequenceState extends State with AutomaticKeepAliv } void clean() { + widget.selectionController.clear(); setState(() { view.clear(); indexes.clear(); @@ -119,6 +149,7 @@ class RequestSequenceState extends State with AutomaticKeepAliv indexes.remove(requestId); } }); + selectionController.prune(view.map((request) => request.requestId)); } ///过滤 @@ -129,6 +160,7 @@ class RequestSequenceState extends State 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 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 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 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(); - 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(); + 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 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 _doExport(String fileName, List 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 _repeatRequests(List 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); + } + } + } + } }