From 1148c837e7febda8852aeae9e9643e6a02c13ef2 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 23 Feb 2026 17:08:12 +0800 Subject: [PATCH] implement request breakpoint management UI and functionality (#669)(#660)(#386) --- ios/Flutter/AppFrameworkInfo.plist | 2 - lib/ui/component/multi_window.dart | 7 + .../desktop/setting/request_breakpoint.dart | 2 +- lib/ui/mobile/debug/breakpoint_executor.dart | 69 +++ lib/ui/mobile/menu/bottom_navigation.dart | 7 + lib/ui/mobile/mobile.dart | 23 + lib/ui/mobile/request/request_editor.dart | 66 ++- .../mobile/request/request_editor_source.dart | 6 + lib/ui/mobile/setting/request_breakpoint.dart | 464 ++++++++++++++++++ lib/ui/mobile/setting/request_crypto.dart | 13 +- 10 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 lib/ui/mobile/debug/breakpoint_executor.dart create mode 100644 lib/ui/mobile/request/request_editor_source.dart create mode 100644 lib/ui/mobile/setting/request_breakpoint.dart diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/lib/ui/component/multi_window.dart b/lib/ui/component/multi_window.dart index 8ce421c..df60ef8 100644 --- a/lib/ui/component/multi_window.dart +++ b/lib/ui/component/multi_window.dart @@ -174,6 +174,8 @@ enum Operation { } class MultiWindow { + static Function(String widgetName, Map? args)? onOpenWindow; + /// 刷新请求重写 static Future invokeRefreshRewrite(Operation operation, {int? index, RequestRewriteRule? rule, List? items, bool? enabled}) async { @@ -188,6 +190,11 @@ class MultiWindow { static Future openWindow(String title, String widgetName, {Size size = const Size(800, 680), Map? args}) async { + if (Platform.isAndroid || Platform.isIOS) { + onOpenWindow?.call(widgetName, args); + return WindowController.fromWindowId(0); // Dummy controller + } + var ratio = 1.0; if (Platform.isWindows) { ratio = WindowManager.instance.getDevicePixelRatio(); diff --git a/lib/ui/desktop/setting/request_breakpoint.dart b/lib/ui/desktop/setting/request_breakpoint.dart index a9b93ff..9ce9f50 100644 --- a/lib/ui/desktop/setting/request_breakpoint.dart +++ b/lib/ui/desktop/setting/request_breakpoint.dart @@ -221,7 +221,7 @@ class _RequestBreakpointPageState extends State { child: Row(children: [ SizedBox( width: 150, - child: Text(rule.name?.isNotEmpty == true ? rule.name! : rule.url, + child: Text(rule.name ?? "", overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), SizedBox( diff --git a/lib/ui/mobile/debug/breakpoint_executor.dart b/lib/ui/mobile/debug/breakpoint_executor.dart new file mode 100644 index 0000000..b5ba289 --- /dev/null +++ b/lib/ui/mobile/debug/breakpoint_executor.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:proxypin/network/bin/server.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/ui/mobile/request/request_editor.dart'; +import 'package:proxypin/ui/mobile/request/request_editor_source.dart'; + +class BreakpointExecutor extends StatefulWidget { + final HttpRequest request; + final HttpResponse? response; + final String requestId; + + // false: intercept request, true: intercept response + final bool isResponse; + + const BreakpointExecutor({ + super.key, + required this.request, + this.response, + required this.requestId, + required this.isResponse, + }); + + @override + State createState() => _BreakpointExecutorState(); +} + +class _BreakpointExecutorState extends State { + late HttpRequest request; + late HttpResponse? response; + + @override + void initState() { + super.initState(); + request = widget.request; + response = widget.response; + } + + @override + Widget build(BuildContext context) { + if (widget.isResponse) { + return _buildResponseBody(); + } + + return MobileRequestEditor( + request: request, + proxyServer: ProxyServer.current, + source: RequestEditorSource.breakpointRequest, + onExecuteRequest: (newRequest) async { + if (Navigator.canPop(context)) { + Navigator.pop(context, newRequest); + } + }, + ); + } + + Widget _buildResponseBody() { + return MobileRequestEditor( + request: request, + response: response, + proxyServer: ProxyServer.current, + source: RequestEditorSource.breakpointResponse, + onExecuteResponse: (newResponse) async { + if (Navigator.canPop(context)) { + Navigator.pop(context, newResponse); + } + }, + ); + } +} diff --git a/lib/ui/mobile/menu/bottom_navigation.dart b/lib/ui/mobile/menu/bottom_navigation.dart index 75261dc..6e16eb6 100644 --- a/lib/ui/mobile/menu/bottom_navigation.dart +++ b/lib/ui/mobile/menu/bottom_navigation.dart @@ -38,6 +38,7 @@ import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/ui/mobile/setting/ssl.dart'; import 'package:proxypin/ui/mobile/widgets/about.dart'; +import 'package:proxypin/ui/mobile/setting/request_breakpoint.dart'; import '../../component/widgets.dart'; import '../setting/proxy.dart'; @@ -158,6 +159,12 @@ class _ConfigPageState extends State { leading: Icon(Icons.javascript_outlined, color: color), trailing: arrow, onTap: () => navigator(context, const MobileScript())), + Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), + ListTile( + title: Text(localizations.breakpoint), + leading: Icon(Icons.bug_report_outlined, color: color), + trailing: arrow, + onTap: () => navigator(context, const MobileRequestBreakpointPage())), ]), const SizedBox(height: 16) ], diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index b1a5818..90b3084 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -15,6 +15,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -50,6 +51,8 @@ import 'package:proxypin/utils/listenable_list.dart'; import 'package:proxypin/utils/navigator.dart'; import '../app_update/app_update_repository.dart'; +import 'package:proxypin/ui/component/multi_window.dart'; +import 'package:proxypin/ui/mobile/debug/breakpoint_executor.dart'; ///移动端首页 ///@author wanghongen @@ -124,6 +127,26 @@ class MobileHomeState extends State implements EventListener, Li } else if (Platform.isAndroid) { AppUpdateRepository.checkUpdate(context); } + + // Handle breakpoint window on mobile + MultiWindow.onOpenWindow = (widgetName, args) async { + if (widgetName == 'BreakpointExecutor' && args != null) { + if (!mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BreakpointExecutor( + requestId: args['requestId'], + request: HttpRequest.fromJson(jsonDecode(jsonEncode(args['request']))), + response: args['response'] == null + ? null + : HttpResponse.fromJson(jsonDecode(jsonEncode(args['response']))), + isResponse: args['type'] == 'response', + ), + ), + ); + } + }; } @override diff --git a/lib/ui/mobile/request/request_editor.dart b/lib/ui/mobile/request/request_editor.dart index 6fdef36..7f43cca 100644 --- a/lib/ui/mobile/request/request_editor.dart +++ b/lib/ui/mobile/request/request_editor.dart @@ -30,14 +30,29 @@ import 'package:proxypin/ui/content/body.dart'; import 'package:proxypin/utils/curl.dart'; import 'package:proxypin/utils/lang.dart'; +import 'package:proxypin/ui/mobile/request/request_editor_source.dart'; + import '../../component/http_method_popup.dart'; + /// @author wanghongen class MobileRequestEditor extends StatefulWidget { final HttpRequest? request; final ProxyServer? proxyServer; + final RequestEditorSource source; + final Function(HttpRequest request)? onExecuteRequest; + final Function(HttpResponse response)? onExecuteResponse; + final HttpResponse? response; - const MobileRequestEditor({super.key, this.request, required this.proxyServer}); + const MobileRequestEditor({ + super.key, + this.request, + this.response, + required this.proxyServer, + this.source = RequestEditorSource.editor, + this.onExecuteRequest, + this.onExecuteResponse, + }); @override State createState() { @@ -79,6 +94,7 @@ class RequestEditorState extends State with SingleTickerPro tabController = TabController(length: tabs.length, vsync: this); request = widget.request; + response = widget.response; if (widget.request == null) { curlParse(); } @@ -134,6 +150,14 @@ class RequestEditorState extends State with SingleTickerPro ]; } + var buttonText = localizations.send; + IconData icon = Icons.send; + if (widget.source == RequestEditorSource.breakpointRequest || + widget.source == RequestEditorSource.breakpointResponse) { + buttonText = "Execute"; + icon = Icons.play_arrow; + } + return Scaffold( appBar: AppBar( title: Text(localizations.httpRequest, style: const TextStyle(fontSize: 16)), @@ -143,7 +167,16 @@ class RequestEditorState extends State with SingleTickerPro onPressed: () => Navigator.pop(context), child: Text(localizations.cancel, style: Theme.of(context).textTheme.bodyMedium)), actions: [ - TextButton.icon(icon: const Icon(Icons.send), label: Text(localizations.send), onPressed: sendRequest) + TextButton.icon( + icon: Icon(icon), + label: Text(buttonText), + onPressed: () { + if (widget.source == RequestEditorSource.editor) { + sendRequest(); + } else { + executeBreakpoint(); + } + }) ], bottom: TabBar(controller: tabController, tabs: tabs)), body: GestureDetector( @@ -156,6 +189,7 @@ class RequestEditorState extends State with SingleTickerPro message: request, key: requestKey, urlQueryNotifier: _queryNotifier, + readOnly: widget.source == RequestEditorSource.breakpointResponse, ), ValueListenableBuilder( valueListenable: responseChange, @@ -176,7 +210,7 @@ class RequestEditorState extends State with SingleTickerPro style: TextStyle( color: response?.status.isSuccessful() == true ? Colors.blue : Colors.red)) ]), - readOnly: true, + readOnly: widget.source == RequestEditorSource.breakpointRequest, message: response); }), ], @@ -215,6 +249,32 @@ class RequestEditorState extends State with SingleTickerPro tabController.animateTo(1); } + + void executeBreakpoint() { + if (widget.source == RequestEditorSource.breakpointRequest) { + var currentState = requestLineKey.currentState!; + var headers = requestKey.currentState?.getHeaders(); + var requestBody = requestKey.currentState?.getBody(); + String url = currentState.requestUrl.text; + + HttpRequest newRequest = request!.copy(uri: url); + newRequest.method = currentState.requestMethod; + newRequest.headers.clear(); + newRequest.headers.addAll(headers); + newRequest.body = requestBody == null ? null : utf8.encode(requestBody); + widget.onExecuteRequest?.call(newRequest); + } else if (widget.source == RequestEditorSource.breakpointResponse) { + var headers = responseKey.currentState?.getHeaders(); + var responseBody = responseKey.currentState?.getBody(); + + if (response == null) return; + HttpResponse newResponse = response!.copy(); + newResponse.headers.clear(); + newResponse.headers.addAll(headers); + newResponse.body = responseBody == null ? null : utf8.encode(responseBody); + widget.onExecuteResponse?.call(newResponse); + } + } } typedef ParamCallback = void Function(String param); diff --git a/lib/ui/mobile/request/request_editor_source.dart b/lib/ui/mobile/request/request_editor_source.dart new file mode 100644 index 0000000..5183288 --- /dev/null +++ b/lib/ui/mobile/request/request_editor_source.dart @@ -0,0 +1,6 @@ +enum RequestEditorSource { + editor, + breakpointRequest, + breakpointResponse, +} + diff --git a/lib/ui/mobile/setting/request_breakpoint.dart b/lib/ui/mobile/setting/request_breakpoint.dart new file mode 100644 index 0000000..7dc0f9c --- /dev/null +++ b/lib/ui/mobile/setting/request_breakpoint.dart @@ -0,0 +1,464 @@ +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:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_breakpoint_manager.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +import '../../component/http_method_popup.dart'; + +class MobileRequestBreakpointPage extends StatefulWidget { + const MobileRequestBreakpointPage({super.key}); + + @override + State createState() => _RequestBreakpointPageState(); +} + +class _RequestBreakpointPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + List rules = []; + bool enabled = false; + RequestBreakpointManager? manager; + + bool selectionMode = false; + final Set selected = HashSet(); + + Future _save() async { + await manager?.save(); + } + + @override + void initState() { + super.initState(); + RequestBreakpointManager.instance.then((value) { + manager = value; + setState(() { + enabled = value.enabled; + rules = value.list; + }); + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool isEN = Localizations.localeOf(context).languageCode == 'en'; + + return Scaffold( + backgroundColor: Theme.of(context).dialogTheme.backgroundColor, + appBar: AppBar( + title: Text(localizations.breakpoint, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true), + body: Center( + child: Container( + padding: const EdgeInsets.only(left: 15, right: 10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + SizedBox( + width: isEN ? 280 : 250, + child: ListTile( + title: Text("${localizations.enable} ${localizations.breakpoint}"), + contentPadding: const EdgeInsets.only(left: 2), + trailing: SwitchWidget( + value: enabled, + scale: 0.8, + onChanged: (val) async { + manager?.enabled = val; + await _save(); + setState(() { + enabled = val; + }); + }))), + const SizedBox(width: 10), + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), label: Text(localizations.add), onPressed: _editRule), + ])), + const SizedBox(width: 15) + ]), + const SizedBox(height: 10), + Expanded(child: _buildList()), + if (selectionMode) _buildSelectionFooter(), + ])))); + } + + Widget _buildList() { + return Container( + padding: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withValues(alpha: 0.2))), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5, bottom: 5), + child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container(width: 65, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 45, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(width: 10), + Expanded(child: Text("URL", textAlign: TextAlign.center)), + SizedBox(width: 100, child: Text(localizations.breakpoint, textAlign: TextAlign.center)), + ]), + ), + const Divider(thickness: 0.5, height: 5), + Expanded( + child: ListView.builder( + itemCount: rules.length, + itemBuilder: (context, index) => _buildRow(index), + ), + ) + ], + ), + ); + } + + Widget _buildRow(int index) { + var primaryColor = Theme.of(context).colorScheme.primary; + var rule = rules[index]; + + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withValues(alpha: 0.3), + onLongPress: () => _showRuleActions(index), + onTap: () { + if (selectionMode) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); + return; + } + _editRule(rule: rule); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withValues(alpha: 0.5) + : index.isEven + ? Colors.grey.withValues(alpha: 0.1) + : null, + height: 32, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 65, + child: Text(rule.name ?? "", + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ), + SizedBox( + width: 45, + child: SwitchWidget( + scale: 0.65, + value: rule.enabled, + onChanged: (val) async { + rule.enabled = val; + await _save(); + })), + const SizedBox(width: 10), + Expanded(child: Text(rule.url, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + SizedBox( + width: 100, + child: Text( + "${rule.interceptRequest ? localizations.request : ""}${rule.interceptRequest && rule.interceptResponse ? "/" : ""}${rule.interceptResponse ? localizations.response : ""}", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis)), + ]), + ), + ); + } + + Future _export(RequestBreakpointManager? manager, {List? indexes}) async { + try { + if (manager == null || manager.list.isEmpty) return; + final rules = manager.list; + final keys = (indexes == null || indexes.isEmpty) + ? List.generate(rules.length, (i) => i) + : (indexes.toList()..sort()); + final data = keys.map((i) => rules[i].toJson()).toList(); + var bytes = utf8.encode(jsonEncode(data)); + final path = await FilePicker.platform.saveFile(fileName: 'request_breakpoints.json', bytes: bytes); + if (path == null) return; + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } catch (e) { + logger.e('导出失败', error: e); + if (mounted) FlutterToastr.show('Export failed: $e', context); + } + } + + Stack _buildSelectionFooter() { + final l10n = localizations; + return Stack(children: [ + Container( + height: 50, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withValues(alpha: 0.2)))), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: () {}, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + TextButton.icon( + onPressed: selected.isEmpty + ? null + : () async { + // export selected only + final m = await RequestBreakpointManager.instance; + await _export(m, indexes: selected.toList()); + setState(() { + selected.clear(); + selectionMode = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(l10n.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: selected.isEmpty ? null : () => _removeSelected(), + icon: const Icon(Icons.delete, size: 18), + label: Text(l10n.delete, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () { + setState(() { + selectionMode = false; + selected.clear(); + }); + }, + icon: const Icon(Icons.cancel, size: 18), + label: Text(l10n.cancel, style: const TextStyle(fontSize: 14))), + ])))) + ]); + } + + void _showRuleActions(int index) { + final l10n = localizations; + setState(() { + selected.add(index); + }); + showModalBottomSheet( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + context: context, + enableDrag: true, + builder: (ctx) { + return Wrap(children: [ + BottomSheetItem( + text: l10n.multiple, + onPressed: () { + setState(() => selectionMode = true); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: l10n.edit, + onPressed: () { + _editRule(rule: rules[index]); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: l10n.export, onPressed: () => _export(manager, indexes: [index])), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: rules[index].enabled ? l10n.disabled : l10n.enable, + onPressed: () { + rules[index].enabled = !rules[index].enabled; + setState(() {}); + _save(); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: l10n.delete, + onPressed: () { + _removeRule(index); + }), + Container(color: Theme.of(ctx).hoverColor, height: 8), + TextButton( + child: Container( + height: 45, + width: double.infinity, + padding: const EdgeInsets.only(top: 10), + child: Text(l10n.cancel, textAlign: TextAlign.center)), + onPressed: () { + Navigator.of(ctx).pop(); + }), + ]); + }).then((value) { + if (selectionMode) { + return; + } + setState(() { + selected.remove(index); + }); + }); + } + + Future _removeRule(int index) async { + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(localizations.deleteHeaderConfirm, style: const TextStyle(fontSize: 18)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), + TextButton( + onPressed: () async { + setState(() { + rules.removeAt(index); + }); + await _save(); + if (context.mounted) Navigator.pop(ctx); + }, + child: Text(localizations.delete)), + ], + ); + }); + } + + Future _removeSelected() async { + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(localizations.deleteHeaderConfirm, style: const TextStyle(fontSize: 18)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), + TextButton( + onPressed: () async { + var list = selected.toList(); + list.sort((a, b) => b.compareTo(a)); + for (var i in list) { + rules.removeAt(i); + } + setState(() { + selected.clear(); + selectionMode = false; + }); + await _save(); + if (context.mounted) Navigator.pop(ctx); + }, + child: Text(localizations.delete)), + ], + ); + }); + } + + void _editRule({RequestBreakpointRule? rule}) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MobileBreakpointRuleEditor(rule: rule), + ), + ).then((value) async { + if (value != null && value is RequestBreakpointRule) { + setState(() { + if (rule == null) { + rules.add(value); + } + }); + await _save(); + } + }); + } +} + +class MobileBreakpointRuleEditor extends StatefulWidget { + final RequestBreakpointRule? rule; + + const MobileBreakpointRuleEditor({super.key, this.rule}); + + @override + State createState() => _MobileBreakpointRuleEditorState(); +} + +class _MobileBreakpointRuleEditorState extends State { + late RequestBreakpointRule rule; + final _formKey = GlobalKey(); + + late TextEditingController nameInput; + late TextEditingController urlInput; + + // Local state for methods to avoid modifying rule in-place before save + HttpMethod? _method; + bool _interceptRequest = true; + bool _interceptResponse = true; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + void initState() { + super.initState(); + rule = widget.rule ?? RequestBreakpointRule(url: ''); + nameInput = TextEditingController(text: rule.name); + urlInput = TextEditingController(text: rule.url); + _method = rule.method; + _interceptRequest = rule.interceptRequest; + _interceptResponse = rule.interceptResponse; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + widget.rule == null + ? "${localizations.add} ${localizations.breakpointRule}" + : "${localizations.edit} ${localizations.breakpointRule}", + style: const TextStyle(fontSize: 16)), + actions: [ + TextButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + rule.name = nameInput.text; + rule.url = urlInput.text; + rule.method = _method; + rule.interceptRequest = _interceptRequest; + rule.interceptResponse = _interceptResponse; + rule.enabled = true; + Navigator.pop(context, rule); + }, + child: Text(localizations.save)) + ]), + body: Padding( + padding: const EdgeInsets.all(15), + child: Form( + key: _formKey, + child: ListView(children: [ + TextFormField( + controller: nameInput, + decoration: InputDecoration(labelText: localizations.name, border: const OutlineInputBorder()), + ), + const SizedBox(height: 15), + TextFormField( + controller: urlInput, + validator: (val) => val?.isNotEmpty == true ? null : localizations.cannotBeEmpty, + decoration: InputDecoration( + labelText: 'URL', + hintText: 'https://www.example.com/api/*', + border: const OutlineInputBorder(), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: MethodPopupMenu(value: _method, onChanged: (val) => setState(() => _method = val)))), + ), + const SizedBox(height: 15), + SwitchListTile( + title: Text(localizations.request), + value: _interceptRequest, + onChanged: (val) => setState(() => _interceptRequest = val)), + SwitchListTile( + title: Text(localizations.response), + value: _interceptResponse, + onChanged: (val) => setState(() => _interceptResponse = val)), + ])))); + } +} diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart index f65148b..6329bf7 100644 --- a/lib/ui/mobile/setting/request_crypto.dart +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -264,11 +264,9 @@ class _MobileRequestCryptoPageState extends State { setState(() => selectionMode = true); }), const Divider(thickness: 0.5, height: 5), - ListTile( - leading: const Icon(Icons.edit_outlined), - title: Text(l10n.edit), - onTap: () { - Navigator.pop(ctx); + BottomSheetItem( + text: l10n.edit, + onPressed: () { _editRule(manager, index); }), const Divider(thickness: 0.5, height: 5), @@ -286,7 +284,6 @@ class _MobileRequestCryptoPageState extends State { BottomSheetItem( text: l10n.delete, onPressed: () { - Navigator.pop(ctx); _removeRule(manager, index); }), Container(color: Theme.of(ctx).hoverColor, height: 8), @@ -367,9 +364,9 @@ class _MobileRequestCryptoPageState extends State { ? List.generate(manager.rules.length, (i) => i) : (indexes.toList()..sort()); final data = keys.map((i) => manager.rules[i].toJson()).toList(); - final path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json'); + var bytes = utf8.encode(jsonEncode(data)); + final path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json', bytes: bytes); if (path == null) return; - await File(path).writeAsString(jsonEncode(data)); if (mounted) FlutterToastr.show(localizations.exportSuccess, context); } catch (e) { logger.e('导出失败', error: e);