From 8cd5b26e2ea5f99b5dc706c206e4bf17bbfaa628 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 5 Jan 2024 07:28:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=9F=E5=90=8D=E8=BF=87=E6=BB=A4=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=B9=E9=87=8F=E5=AF=BC=E5=87=BA&=E7=BC=96?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- README_EN.md | 4 +- ios/Runner/AppDelegate.swift | 7 + lib/network/components/host_filter.dart | 3 - lib/ui/desktop/desktop.dart | 4 +- lib/ui/desktop/toolbar/setting/filter.dart | 444 +++++++++++++----- .../toolbar/setting/request_rewrite.dart | 2 +- lib/ui/mobile/mobile.dart | 4 +- lib/ui/mobile/request/repeat.dart | 2 +- lib/ui/mobile/setting/filter.dart | 401 ++++++++++++---- lib/ui/mobile/setting/request_rewrite.dart | 54 +-- 11 files changed, 673 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index b6acc87..c48c7b3 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ ## 开源免费抓包工具,支持Windows、Mac、Android、IOS、Linux 全平台系统 -您可以使用它来拦截、检查和重写HTTP(S)流量,ProxyPin基于Flutter,UI美观易用。 +您可以使用它来拦截、检查和重写HTTP(S)流量,ProxyPin基于Flutter开发,UI美观易用。 ## 核心特性 * 手机扫码连接: 不用手动配置Wifi代理,包括配置同步。所有终端都可以互相扫码连接转发流量。 -* 域名黑白名单过滤: 只拦截您所需要的流量,不拦截其他流量,避免干扰其他应用。 +* 域名过滤: 只拦截您所需要的流量,不拦截其他流量,避免干扰其他应用。 * 请求重写: 支持重定向,支持替换请求或响应报文,也可以根据增则修改请求或或响应。 * 脚本: 支持编写JavaScriot脚本来处理请求或响应。 * 搜索:根据关键词响应类型多种条件搜索请求 @@ -25,7 +25,7 @@ iOS国内TF下载地址(有1万名额限制,满了会清理不使用的用户) TG: https://t.me/proxypin_tg -接下来会持续完善功能和体验,UI优化。 +**接下来会持续完善功能和体验,UI优化。** image. image diff --git a/README_EN.md b/README_EN.md index de3f257..9e26887 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,7 +2,7 @@ English | [中文](README.md) ## Open source free packet capture tool,Support Windows、Mac、Android、IOS、Linux Full platform system -You can use it to intercept, inspect & rewrite HTTP(S) traffic, ProxyPin is based on Flutter, and the UI is beautiful +You can use it to intercept, inspect & rewrite HTTP(S) traffic, ProxyPin is based on Flutter develop, and the UI is beautiful and easy to use. ## Features * Mobile scan code connection: no need to manually configure WiFi proxy, including configuration synchronization. All terminals can scan codes to connect and forward traffic to each other. @@ -21,7 +21,7 @@ iOS TestFlight(Limited by quota): https://testflight.apple.com/join/gURGH6B4 TG: https://t.me/proxypin_tg -We will continue to improve the features and experience, as well as optimize the UI. +**We will continue to improve the features and experience, as well as optimize the UI.** image. image diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 69eb904..8911b27 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -58,6 +58,13 @@ import NetworkExtension timer?.invalidate(); timer = nil; } + + if (!AudioManager.shared.openBackgroundAudioAutoplay) { + AudioManager.shared.openBackgroundAudioAutoplay = true + self.backgroundUpdateTask = UIApplication.shared.beginBackgroundTask(expirationHandler: { + self.endBackgroundUpdateTask() + }) + } } diff --git a/lib/network/components/host_filter.dart b/lib/network/components/host_filter.dart index 40f5c44..0c4588b 100644 --- a/lib/network/components/host_filter.dart +++ b/lib/network/components/host_filter.dart @@ -80,9 +80,6 @@ class Whites extends HostList {} class Blacks extends HostList { Blacks() { enabled = true; - list.add(RegExp(".*.github.com")); - list.add(RegExp("github.com")); - list.add(RegExp(".*.google.com")); list.add(RegExp(".*.apple.com")); list.add(RegExp(".*.icloud.com")); } diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index a1ace13..68a26f9 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -205,13 +205,15 @@ class _DesktopHomePagePageState extends State implements EventL '3. 抓包详情页面Headers默认展开配置;\n' '4. 请求编辑URL参数支持表单编辑;\n' '5. 增加高级重放;\n' + '6. 域名过滤支持批量导出&编辑;\n' : 'Tips:By default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n' 'Click HTTPS Capture packets(Lock icon),Choose to install the root certificate and follow the prompts to proceed。\n\n' '1. Increase multilingual support;\n' '2. Request Rewrite support file selection;\n' '3. Details page Headers Expanded Config;\n' '4. Request Edit URL parameter support for form editing;\n' - '5. Support advanced replay;\n', + '5. Support advanced replay;\n' + '6. Domain name filtering supports batch export&editing;\n', style: const TextStyle(fontSize: 14))); }); } diff --git a/lib/ui/desktop/toolbar/setting/filter.dart b/lib/ui/desktop/toolbar/setting/filter.dart index 942e25f..4b44978 100644 --- a/lib/ui/desktop/toolbar/setting/filter.dart +++ b/lib/ui/desktop/toolbar/setting/filter.dart @@ -1,7 +1,15 @@ +import 'dart:convert'; + +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:network_proxy/network/bin/configuration.dart'; import 'package:network_proxy/network/components/host_filter.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:network_proxy/network/util/logger.dart'; +import 'package:network_proxy/ui/component/utils.dart'; +import 'package:network_proxy/ui/component/widgets.dart'; /// @author wanghongen /// 2023/10/8 @@ -38,7 +46,7 @@ class _FilterDialogState extends State { ]), content: SizedBox( width: 680, - height: 460, + height: 520, child: Flex( direction: Axis.horizontal, children: [ @@ -87,62 +95,10 @@ class DomainFilter extends StatefulWidget { } class _DomainFilterState extends State { - late DomainList domainList; bool changed = false; AppLocalizations get localizations => AppLocalizations.of(context)!; - @override - Widget build(BuildContext context) { - domainList = DomainList(widget.hostList); - - return Column( - children: [ - ListTile( - title: Text(widget.title), - subtitle: Text(widget.subtitle, style: const TextStyle(fontSize: 12)), - titleAlignment: ListTileTitleAlignment.center, - ), - const SizedBox(height: 10), - ValueListenableBuilder( - valueListenable: widget.hostEnableNotifier, - builder: (_, bool enable, __) { - return SwitchListTile( - title: Text(localizations.enable), - dense: true, - value: widget.hostList.enabled, - onChanged: (value) { - widget.hostList.enabled = value; - changed = true; - widget.hostEnableNotifier.value = !widget.hostEnableNotifier.value; - }); - }), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - FilledButton.icon( - icon: const Icon(Icons.add, size: 14), - onPressed: () { - add(); - }, - label: Text(localizations.add, style: const TextStyle(fontSize: 12))), - const SizedBox(width: 10), - TextButton.icon( - icon: const Icon(Icons.remove, size: 14), - label: Text(localizations.delete, style: const TextStyle(fontSize: 12)), - onPressed: () { - if (domainList.selected().isEmpty) { - return; - } - changed = true; - setState(() { - widget.hostList.removeIndex(domainList.selected()); - }); - }) - ]), - domainList - ], - ); - } - @override void dispose() { if (changed) { @@ -151,96 +107,338 @@ class _DomainFilterState extends State { super.dispose(); } + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text(widget.title), + isThreeLine: true, + subtitle: Text(widget.subtitle, style: const TextStyle(fontSize: 12)), + titleAlignment: ListTileTitleAlignment.center, + ), + Row(children: [ + const SizedBox(width: 8), + Text(localizations.enable), + const SizedBox(width: 10), + SwitchWidget( + scale: 0.8, + value: widget.hostList.enabled, + onChanged: (value) { + widget.hostList.enabled = value; + changed = true; + }), + const Expanded(child: SizedBox()), + FilledButton.icon( + icon: const Icon(Icons.add, size: 14), + onPressed: add, + label: Text(localizations.add, style: const TextStyle(fontSize: 12))), + const SizedBox(width: 10), + FilledButton.icon( + icon: const Icon(Icons.input_rounded, size: 14), + onPressed: import, + label: Text(localizations.import, style: const TextStyle(fontSize: 12))), + const SizedBox(width: 5), + ]), + DomainList(widget.hostList, onChange: () => changed = true) + ], + ); + } + + //导入 + import() async { + XTypeGroup typeGroup = const XTypeGroup(extensions: ['config']); + final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]); + if (file == null) { + return; + } + + try { + List json = jsonDecode(await file.readAsString()); + for (var item in json) { + widget.hostList.add(item); + } + + changed = true; + if (context.mounted) { + FlutterToastr.show(localizations.importSuccess, context); + } + setState(() {}); + } catch (e, t) { + logger.e('导入失败 $file', error: e, stackTrace: t); + if (context.mounted) { + FlutterToastr.show("${localizations.importFailed} $e", context); + } + } + } + void add() { - GlobalKey formKey = GlobalKey(); - String? host; showDialog( context: context, barrierDismissible: false, - builder: (BuildContext context) { - return AlertDialog( - scrollable: true, - content: Padding( - padding: const EdgeInsets.all(8.0), - child: Form( - key: formKey, - child: Column(children: [ - TextFormField( - decoration: const InputDecoration(labelText: 'Host', hintText: '*.example.com'), - onSaved: (val) => host = val) - ]))), - actions: [ - FilledButton( - child: Text(localizations.add), - onPressed: () { - (formKey.currentState as FormState).save(); - if (host != null && host!.isNotEmpty) { - try { - changed = true; - widget.hostList.add(host!.trim()); - setState(() {}); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - Navigator.of(context).pop(); - }), - ElevatedButton( - child: Text(localizations.close), - onPressed: () { - Navigator.of(context).pop(); - }) - ]); + builder: (BuildContext context) => DomainAddDialog(hostList: widget.hostList)).then((value) { + if (value != null) { + setState(() { + changed = true; }); + } + }); + } +} + +class DomainAddDialog extends StatelessWidget { + final HostList hostList; + final int? index; + + const DomainAddDialog({super.key, required this.hostList, this.index}); + + @override + Widget build(BuildContext context) { + AppLocalizations localizations = AppLocalizations.of(context)!; + + GlobalKey formKey = GlobalKey(); + String? host = index == null ? null : hostList.list.elementAt(index!).pattern.replaceAll(".*", "*"); + return AlertDialog( + scrollable: true, + content: Padding( + padding: const EdgeInsets.all(8.0), + child: Form( + key: formKey, + child: Column(children: [ + TextFormField( + initialValue: host, + decoration: const InputDecoration(labelText: 'Host', hintText: '*.example.com'), + validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, + onChanged: (val) => host = val) + ]))), + actions: [ + FilledButton( + child: Text(localizations.add), + onPressed: () { + if (!(formKey.currentState as FormState).validate()) { + return; + } + try { + if (index != null) { + hostList.list[index!] = RegExp(host!.trim().replaceAll("*", ".*")); + } else { + hostList.add(host!.trim()); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + } + Navigator.of(context).pop(host); + }), + ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) + ]); } } ///域名列表 class DomainList extends StatefulWidget { final HostList hostList; + final Function onChange; - DomainList(this.hostList) : super(key: GlobalKey<_DomainListState>()); + const DomainList(this.hostList, {super.key, required this.onChange}); @override State createState() => _DomainListState(); - - List selected() { - var state = (key as GlobalKey<_DomainListState>).currentState; - List list = []; - state?.selected.forEach((key, value) { - if (value == true) { - list.add(key); - } - }); - return list; - } } class _DomainListState extends State { - late Map selected = {}; + Map selected = {}; + AppLocalizations get localizations => AppLocalizations.of(context)!; + bool isPress = false; + bool changed = false; + + onChanged() { + changed = true; + widget.onChange.call(); + } @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.only(top: 10), - height: 300, - child: SingleChildScrollView( - child: DataTable( - border: TableBorder.symmetric(outside: BorderSide(width: 1, color: Theme.of(context).highlightColor)), - columns: [ - DataColumn(label: Text(localizations.domain)), - ], - rows: List.generate( - widget.hostList.list.length, - (index) => DataRow( - cells: [DataCell(Text(widget.hostList.list[index].pattern.replaceAll(".*", "*")))], - selected: selected[index] == true, - onSelectChanged: (value) { - setState(() { - selected[index] = value!; - }); - })), - ))); + return GestureDetector( + onSecondaryTapDown: (details) => showGlobalMenu(details.globalPosition), + onTapDown: (details) { + if (selected.isEmpty) { + return; + } + if (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.metaLeft) || + RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.control)) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Listener( + onPointerUp: (details) => isPress = false, + onPointerDown: (details) => isPress = true, + child: Container( + padding: const EdgeInsets.only(top: 10), + height: 380, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: SingleChildScrollView( + child: Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 15), + const Expanded(child: Text('Host')), + ], + ), + const Divider(thickness: 0.5), + Column(children: rows(widget.hostList.list)) + ]))))); + } + + List rows(List list) { + var primaryColor = Theme.of(context).colorScheme.primary; + + return List.generate(list.length, (index) { + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onSecondaryTapDown: (details) => showMenus(details, index), + onDoubleTap: () => showEdit(index), + onHover: (hover) { + if (isPress && selected[index] != true) { + setState(() { + selected[index] = true; + }); + } + }, + onTap: () { + if (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.metaLeft) || + RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.control)) { + setState(() { + selected[index] = !(selected[index] ?? false); + }); + return; + } + if (selected.isEmpty) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Container( + color: selected[index] == true + ? primaryColor.withOpacity(0.8) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 38, + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + const SizedBox(width: 15), + Expanded( + child: Text(list[index].pattern.replaceAll(".*", "*"), style: const TextStyle(fontSize: 14))), + ], + ))); + }); + } + + //导出 + export(List indexes) async { + if (indexes.isEmpty) return; + + String fileName = 'host-filters.config'; + String? saveLocation = (await getSaveLocation(suggestedName: fileName))?.path; + if (saveLocation == null) { + return; + } + + var list = []; + for (var index in indexes) { + String rule = widget.hostList.list[index].pattern; + list.add(rule); + } + + final XFile xFile = XFile.fromData(utf8.encode(jsonEncode(list)), mimeType: 'json'); + await xFile.saveTo(saveLocation); + if (context.mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + //删除 + Future remove(List indexes) async { + if (indexes.isEmpty) return; + return showConfirmDialog(context, content: localizations.requestRewriteDeleteConfirm(indexes.length), + onConfirm: () async { + widget.hostList.removeIndex(indexes); + onChanged(); + setState(() { + selected.clear(); + }); + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); + } + + showEdit([int? index]) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return DomainAddDialog(hostList: widget.hostList, index: index); + }).then((value) { + if (value != null) { + setState(() { + onChanged(); + }); + } + }); + } + + showGlobalMenu(Offset offset) { + showContextMenu(context, offset, items: [ + PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit()), + PopupMenuItem( + height: 35, + enabled: selected.isNotEmpty, + child: Text(localizations.export), + onTap: () => export(selected.keys.toList())), + const PopupMenuDivider(), + PopupMenuItem( + height: 35, + enabled: selected.isNotEmpty, + child: Text(localizations.deleteSelect), + onTap: () => remove(selected.keys.toList())), + ]); + } + + //点击菜单 + showMenus(TapDownDetails details, int index) { + if (selected.length > 1) { + showGlobalMenu(details.globalPosition); + return; + } + + setState(() { + selected[index] = true; + }); + + showContextMenu(context, details.globalPosition, items: [ + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), + PopupMenuItem(height: 35, onTap: () => export([index]), child: Text(localizations.export)), + const PopupMenuDivider(), + PopupMenuItem( + height: 35, + child: Text(localizations.delete), + onTap: () { + widget.hostList.removeIndex([index]); + onChanged(); + }) + ]).then((value) { + setState(() { + selected.remove(index); + }); + }); } } diff --git a/lib/ui/desktop/toolbar/setting/request_rewrite.dart b/lib/ui/desktop/toolbar/setting/request_rewrite.dart index 150b22e..93b8bba 100644 --- a/lib/ui/desktop/toolbar/setting/request_rewrite.dart +++ b/lib/ui/desktop/toolbar/setting/request_rewrite.dart @@ -184,6 +184,7 @@ class RequestRuleList extends StatefulWidget { class _RequestRuleListState extends State { Map selected = {}; late List rules; + bool isPress = false; AppLocalizations get localizations => AppLocalizations.of(context)!; @@ -193,7 +194,6 @@ class _RequestRuleListState extends State { rules = widget.requestRewrites.rules; } - bool isPress = false; @override Widget build(BuildContext context) { diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 3046cac..4883e5d 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -209,12 +209,14 @@ class MobileHomeState extends State implements EventListener, Li '3. 抓包详情页面Headers默认展开配置;\n' '4. 请求编辑URL参数支持表单编辑;\n' '5. 增加高级重放;\n' + '6. 域名过滤支持批量导出&编辑;\n' : 'Tips:By default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n\n' '1. Increase multilingual support;\n' '2. Request Rewrite support file selection;\n' '3. Details page Headers Expanded Config;\n' '4. Request Edit URL parameter support for form editing;\n' - '5. Support advanced replay;\n'; + '5. Support advanced replay;\n' + '6. Domain name filtering supports batch export&editing;\n'; showAlertDialog(isCN ? '更新内容V1.0.7' : "Update content V1.0.7", content, () { widget.appConfiguration.upgradeNoticeV7 = false; widget.appConfiguration.flushConfig(); diff --git a/lib/ui/mobile/request/repeat.dart b/lib/ui/mobile/request/repeat.dart index 804c1e9..2584310 100644 --- a/lib/ui/mobile/request/repeat.dart +++ b/lib/ui/mobile/request/repeat.dart @@ -85,7 +85,7 @@ class _CustomRepeatState extends State { return Row( children: [ - SizedBox(width: 90, child: Text("$label :")), + SizedBox(width: 95, child: Text("$label :")), Expanded( child: TextFormField( controller: controller, diff --git a/lib/ui/mobile/setting/filter.dart b/lib/ui/mobile/setting/filter.dart index faac158..11354f1 100644 --- a/lib/ui/mobile/setting/filter.dart +++ b/lib/ui/mobile/setting/filter.dart @@ -1,6 +1,15 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:network_proxy/network/bin/configuration.dart'; +import 'package:network_proxy/network/util/logger.dart'; +import 'package:network_proxy/ui/component/utils.dart'; +import 'package:network_proxy/ui/component/widgets.dart'; +import 'package:share_plus/share_plus.dart'; import '../../../network/components/host_filter.dart'; @@ -67,19 +76,23 @@ class DomainFilter extends StatefulWidget { } class _DomainFilterState extends State { - late DomainList domainList; bool changed = false; AppLocalizations get localizations => AppLocalizations.of(context)!; @override - Widget build(BuildContext context) { - domainList = DomainList(widget.hostList); + void dispose() { + if (changed) { + widget.configuration.flushConfig(); + } + super.dispose(); + } - return ListView( + @override + Widget build(BuildContext context) { + return Column( children: [ ListTile(title: Text(widget.title), subtitle: Text(widget.subtitle, style: const TextStyle(fontSize: 12))), - const SizedBox(height: 10), ValueListenableBuilder( valueListenable: widget.hostEnableNotifier, builder: (_, bool enable, __) { @@ -92,118 +105,326 @@ class _DomainFilterState extends State { widget.hostEnableNotifier.value = !widget.hostEnableNotifier.value; }); }), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton.icon(icon: const Icon(Icons.add), onPressed: add, label: Text(localizations.add)), const SizedBox(width: 10), - TextButton.icon( - icon: const Icon(Icons.remove), - label: Text(localizations.delete), - onPressed: () { - if (domainList.selected().isEmpty) { - return; - } - changed = true; - setState(() { - widget.hostList.removeIndex(domainList.selected()); - }); - }) + FilledButton.icon( + icon: const Icon(Icons.input_rounded), onPressed: import, label: Text(localizations.import)), + const SizedBox(width: 5), ]), - domainList + Expanded(child: DomainList(widget.hostList, onChange: () => changed = true)) ], ); } - @override - void dispose() { - if (changed) { - widget.configuration.flushConfig(); + //导入 + import() async { + final XFile? file = await openFile(); + if (file == null) { + return; + } + + try { + List json = jsonDecode(await file.readAsString()); + for (var item in json) { + widget.hostList.add(item); + } + + changed = true; + if (context.mounted) { + FlutterToastr.show(localizations.importSuccess, context); + } + setState(() {}); + } catch (e, t) { + logger.e('导入失败 $file', error: e, stackTrace: t); + if (context.mounted) { + FlutterToastr.show("${localizations.importFailed} $e", context); + } } - super.dispose(); } void add() { - GlobalKey formKey = GlobalKey(); - String? host; - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - scrollable: true, - content: Padding( - padding: const EdgeInsets.all(8.0), - child: Form( - key: formKey, - child: Column(children: [ - TextFormField( - decoration: const InputDecoration(labelText: 'Host', hintText: '*.example.com'), - onSaved: (val) => host = val) - ]))), - actions: [ - FilledButton( - child: Text(localizations.add), - onPressed: () { - (formKey.currentState as FormState).save(); - if (host != null && host!.isNotEmpty) { - try { - changed = true; - widget.hostList.add(host!.trim()); - setState(() {}); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); - } - } - Navigator.of(context).pop(); - }), - ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) - ]); + showDialog(context: context, builder: (BuildContext context) => DomainAddDialog(hostList: widget.hostList)) + .then((value) { + if (value != null) { + setState(() { + changed = true; }); + } + }); + } +} + +class DomainAddDialog extends StatelessWidget { + final HostList hostList; + final int? index; + + const DomainAddDialog({super.key, required this.hostList, this.index}); + + @override + Widget build(BuildContext context) { + AppLocalizations localizations = AppLocalizations.of(context)!; + + GlobalKey formKey = GlobalKey(); + String? host = index == null ? null : hostList.list.elementAt(index!).pattern.replaceAll(".*", "*"); + return AlertDialog( + scrollable: true, + content: Padding( + padding: const EdgeInsets.all(8.0), + child: Form( + key: formKey, + child: Column(children: [ + TextFormField( + initialValue: host, + decoration: const InputDecoration(labelText: 'Host', hintText: '*.example.com'), + validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null, + onChanged: (val) => host = val) + ]))), + actions: [ + FilledButton( + child: Text(localizations.add), + onPressed: () { + if (!(formKey.currentState as FormState).validate()) { + return; + } + try { + if (index != null) { + hostList.list[index!] = RegExp(host!.trim().replaceAll("*", ".*")); + } else { + hostList.add(host!.trim()); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); + } + Navigator.of(context).pop(host); + }), + ElevatedButton(child: Text(localizations.close), onPressed: () => Navigator.of(context).pop()) + ]); } } ///域名列表 class DomainList extends StatefulWidget { final HostList hostList; + final Function onChange; - DomainList(this.hostList) : super(key: GlobalKey<_DomainListState>()); + const DomainList(this.hostList, {super.key, required this.onChange}); @override State createState() => _DomainListState(); - - List selected() { - var state = (key as GlobalKey<_DomainListState>).currentState; - List list = []; - state?.selected.forEach((key, value) { - if (value == true) { - list.add(key); - } - }); - return list; - } } class _DomainListState extends State { - late Map selected = {}; + Set selected = HashSet(); + + AppLocalizations get localizations => AppLocalizations.of(context)!; + bool changed = false; + bool multiple = false; + + onChanged() { + changed = true; + widget.onChange.call(); + } @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.only(top: 10), - child: SingleChildScrollView( - child: DataTable( - border: TableBorder.symmetric(outside: BorderSide(width: 1, color: Theme.of(context).highlightColor)), - columns: const [ - DataColumn(label: Text('域名')), - ], - rows: List.generate( - widget.hostList.list.length, - (index) => DataRow( - cells: [DataCell(Text(widget.hostList.list[index].pattern.replaceAll(".*", "*")))], - selected: selected[index] == true, - onSelectChanged: (value) { - setState(() { - selected[index] = value!; - }); - })), - ))); + return Scaffold( + persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + body: Container( + padding: const EdgeInsets.only(top: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: Scrollbar( + child: ListView(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 15), + const Expanded(child: Text('Host')), + ], + ), + const Divider(thickness: 0.5), + Column(children: rows(widget.hostList.list)) + ])))); + } + + globalMenu() { + return Stack(children: [ + Container( + height: 50, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + color: Colors.white, + backgroundBlendMode: BlendMode.colorBurn)), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: () {}, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + TextButton.icon( + onPressed: () { + export(selected.toList()); + setState(() { + selected.clear(); + multiple = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(localizations.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () => remove(), + icon: const Icon(Icons.delete, size: 18), + label: Text(localizations.delete, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () { + setState(() { + multiple = false; + selected.clear(); + }); + }, + icon: const Icon(Icons.cancel, size: 18), + label: Text(localizations.cancel, style: const TextStyle(fontSize: 14))), + ])))) + ]); + } + + List rows(List list) { + var primaryColor = Theme.of(context).colorScheme.primary; + + return List.generate(list.length, (index) { + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onLongPress: () => showMenus(index), + onDoubleTap: () => showEdit(index), + onTap: () { + if (multiple) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); + return; + } + showEdit(index); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.8) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 38, + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + const SizedBox(width: 15), + Expanded( + child: Text(list[index].pattern.replaceAll(".*", "*"), style: const TextStyle(fontSize: 14))), + ], + ))); + }); + } + + showEdit([int? index]) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return DomainAddDialog(hostList: widget.hostList, index: index); + }).then((value) { + if (value != null) { + setState(() { + onChanged(); + }); + } + }); + } + + //点击菜单 + showMenus(int index) { + setState(() { + selected.add(index); + }); + + showModalBottomSheet( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + context: context, + enableDrag: true, + builder: (ctx) { + return Wrap(alignment: WrapAlignment.center, children: [ + BottomSheetItem( + text: localizations.multiple, + onPressed: () { + setState(() => multiple = true); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: localizations.edit, onPressed: () => showEdit(index)), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(onPressed: () => export([index]), text: localizations.share), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: localizations.delete, + onPressed: () { + widget.hostList.removeIndex([index]); + onChanged(); + }), + Container(color: Theme.of(context).hoverColor, height: 8), + TextButton( + child: Container( + height: 50, + width: double.infinity, + padding: const EdgeInsets.only(top: 10), + child: Text(localizations.cancel, textAlign: TextAlign.center)), + onPressed: () { + Navigator.of(context).pop(); + }), + ]); + }).then((value) { + if (multiple) { + return; + } + setState(() { + selected.remove(index); + }); + }); + } + + //导出 + export(List indexes) async { + if (indexes.isEmpty) return; + + String fileName = 'host-filters.config'; + var list = []; + for (var index in indexes) { + String rule = widget.hostList.list[index].pattern; + list.add(rule); + } + + final XFile file = XFile.fromData(utf8.encode(jsonEncode(list)), mimeType: 'config'); + await Share.shareXFiles([file], subject: fileName); + } + + //删除 + Future remove() async { + if (selected.isEmpty) return; + + return showConfirmDialog(context, content: localizations.requestRewriteDeleteConfirm(selected.length), + onConfirm: () async { + widget.hostList.removeIndex(selected.toList()); + onChanged(); + setState(() { + multiple = false; + selected.clear(); + }); + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); } } diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index 85ee628..2771798 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -242,16 +242,7 @@ class _RequestRuleListState extends State { }); return; } - var rule = widget.requestRewrites.rules[index]; - var rewriteItems = await widget.requestRewrites.getRewriteItems(rule); - if (!mounted) return; - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => RewriteRule(rule: rule, items: rewriteItems))) - .then((value) { - if (value != null) { - setState(() {}); - } - }); + showEdit(index); }, child: Container( color: selected.contains(index) @@ -287,6 +278,20 @@ class _RequestRuleListState extends State { }); } + showEdit(int index) async { + var rule = widget.requestRewrites.rules[index]; + var rewriteItems = await widget.requestRewrites.getRewriteItems(rule); + if (!mounted) return; + + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => RewriteRule(rule: rule, items: rewriteItems))) + .then((value) { + if (value != null) { + setState(() {}); + } + }); + } + //点击菜单 showMenus(int index) { setState(() { @@ -302,36 +307,20 @@ class _RequestRuleListState extends State { BottomSheetItem( text: localizations.multiple, onPressed: () { - setState(() { - multiple = true; - }); + setState(() => multiple = true); }), - const Divider(thickness: 0.5), - BottomSheetItem( - text: localizations.edit, - onPressed: () async { - var rule = widget.requestRewrites.rules[index]; - var rewriteItems = await widget.requestRewrites.getRewriteItems(rule); - if (!mounted) return; - - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => RewriteRule(rule: rule, items: rewriteItems))) - .then((value) { - if (value != null) { - setState(() {}); - } - }); - }), - const Divider(thickness: 0.5), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: localizations.edit, onPressed: () => showEdit(index)), + const Divider(thickness: 0.5, height: 5), BottomSheetItem(text: localizations.share, onPressed: () => export([index])), - const Divider(thickness: 0.5, height: 1), + const Divider(thickness: 0.5, height: 5), BottomSheetItem( text: rules[index].enabled ? localizations.disabled : localizations.enable, onPressed: () { rules[index].enabled = !rules[index].enabled; changed = true; }), - const Divider(thickness: 0.5), + const Divider(thickness: 0.5, height: 5), BottomSheetItem( text: localizations.delete, onPressed: () async { @@ -363,7 +352,6 @@ class _RequestRuleListState extends State { //导出js Future export(List indexes) async { if (indexes.isEmpty) return; - String fileName = 'proxypin-rewrites.config'; var list = [];