From ae7d821185a2f3cc780456f5210b71554628c6dd Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 9 Jan 2026 04:19:46 +0800 Subject: [PATCH] Refactor localization strings and improve request crypto UI (#500)(#335)(#472) --- lib/l10n/app_localizations_zh.dart | 4 +- lib/l10n/app_zh.arb | 4 +- lib/ui/content/body.dart | 200 ++++-- lib/ui/desktop/desktop.dart | 11 +- lib/ui/mobile/menu/bottom_navigation.dart | 7 + lib/ui/mobile/menu/drawer.dart | 5 + lib/ui/mobile/setting/filter.dart | 2 +- lib/ui/mobile/setting/request_crypto.dart | 786 +++++++++++++++++++++ lib/ui/mobile/setting/request_map.dart | 6 +- lib/ui/mobile/setting/request_rewrite.dart | 2 +- lib/ui/mobile/setting/script.dart | 2 +- 11 files changed, 941 insertions(+), 88 deletions(-) create mode 100644 lib/ui/mobile/setting/request_crypto.dart diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2a3707b..734ce69 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -992,10 +992,10 @@ class AppLocalizationsZh extends AppLocalizations { String get requestMapDescribe => '不请求远程服务,使用本地配置或脚本进行响应'; @override - String get automatic => '自动安装'; + String get automatic => '自动'; @override - String get manual => '手动安装'; + String get manual => '手动'; @override String get certNotInstalled => '证书未安装'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1d952c6..88855fa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -345,8 +345,8 @@ "requestMap": "请求映射", "requestMapDescribe": "不请求远程服务,使用本地配置或脚本进行响应", - "automatic": "自动安装", - "manual": "手动安装", + "automatic": "自动", + "manual": "手动", "certNotInstalled": "证书未安装", "openNewWindow": "新窗口打开", diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index c6d0ce3..b2263bd 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -230,52 +230,49 @@ class HttpBodyState extends State { bool isImage = widget.httpMessage?.contentType == ContentType.image; VisualDensity visualDensity = Platforms.isMobile() ? VisualDensity.compact : VisualDensity.standard; - var list = [ - Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - const SizedBox(width: 18), - InkWell( - key: searchIconKey, - child: Icon(Icons.search, size: 20), - // tooltip: localizations.search, - onTap: () { - if (searchController.isSearchOverlayVisible) { - searchController.removeSearchOverlay(); - } else { - RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; - Offset position = renderBox.localToGlobal(Offset.zero); // 获取搜索图标的位置 - searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); - } - }, - ), - const SizedBox(width: 5), - isImage - ? downloadImageButton() - : IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: Icon(Icons.copy), - tooltip: localizations.copy, - onPressed: () async { - var body = await bodyKey.currentState?.getBody(); - if (body == null) { - return; - } - Clipboard.setData(ClipboardData(text: body)).then((value) { - if (mounted) FlutterToastr.show(localizations.copied, context); - }); - }), - ]; + final isMobile = Platforms.isMobile(); - if (!widget.hideRequestRewrite) { - list.add(IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: const Icon(Icons.edit_document), - tooltip: localizations.requestRewrite, - onPressed: showRequestRewrite)); - } + // Build common actions as widgets so we can either display them inline (desktop) + // or move them into an overflow menu (mobile) to avoid hiding important buttons. + final searchBtn = InkWell( + key: searchIconKey, + child: const Icon(Icons.search, size: 20), + onTap: () { + if (searchController.isSearchOverlayVisible) { + searchController.removeSearchOverlay(); + } else { + RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; + Offset position = renderBox.localToGlobal(Offset.zero); + searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); + } + }, + ); - list.add(IconButton( + final copyBtn = isImage + ? downloadImageButton() + : IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.copy), + tooltip: localizations.copy, + onPressed: () async { + var body = await bodyKey.currentState?.getBody(); + if (body == null) return; + Clipboard.setData(ClipboardData(text: body)).then((_) { + if (mounted) FlutterToastr.show(localizations.copied, context); + }); + }, + ); + + final rewriteBtn = IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.edit_document), + tooltip: localizations.requestRewrite, + onPressed: showRequestRewrite, + ); + + final encodeBtn = IconButton( visualDensity: visualDensity, iconSize: 20, icon: const Icon(Icons.text_format), @@ -285,33 +282,92 @@ class HttpBodyState extends State { if (mounted) { encodeWindow(EncoderType.base64, context, body); } - })); - if (!inNewWindow) { - list.add(IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: const Icon(Icons.open_in_new), - tooltip: localizations.newWindow, - onPressed: () => openNew())); - } + }); + final openNewBtn = IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.open_in_new), + tooltip: localizations.newWindow, + onPressed: () => openNew()); + + Widget? cryptoToggle; if (decoded != null) { - list.add(Row(children: [ - TextButton.icon( - onPressed: () { - setState(() { - showDecoded = !showDecoded; - }); - }, - icon: Icon(showDecoded ? Icons.lock_open : Icons.lock), - label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle)), - ])); + cryptoToggle = TextButton.icon( + onPressed: () { + setState(() { + showDecoded = !showDecoded; + }); + }, + icon: Icon(showDecoded ? Icons.lock_open : Icons.lock, size: 18), + label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle), + ); } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: list), - ); + // Mobile UX: + // - If there is NO crypto result, keep the original (previous) horizontal-scroll title bar. + // - Only when crypto is available, switch to the compact overflow-menu layout to keep + // the crypto toggle visible. + if (isMobile && cryptoToggle != null) { + final overflowItems = >[]; + if (!widget.hideRequestRewrite) { + overflowItems.add(PopupMenuItem(value: 'rewrite', child: Text(localizations.requestRewrite))); + } + overflowItems.add(PopupMenuItem(value: 'encode', child: Text(localizations.encode))); + if (!inNewWindow) { + overflowItems.add(PopupMenuItem(value: 'new_window', child: Text(localizations.newWindow))); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + searchBtn, + const SizedBox(width: 4), + copyBtn, + const SizedBox(width: 4), + Flexible(child: cryptoToggle), + if (overflowItems.isNotEmpty) + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (v) { + if (v == 'rewrite') showRequestRewrite(); + if (v == 'encode') { + bodyKey.currentState?.getBody().then((body) { + if (mounted) encodeWindow(EncoderType.base64, context, body); + }); + } + if (v == 'new_window') openNew(); + }, + itemBuilder: (_) => overflowItems, + ), + ], + ); + } + + // Default (desktop + mobile without crypto): keep the previous full inline actions + // (horizontal scroll when needed). + final list = [ + Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(width: 18), + searchBtn, + const SizedBox(width: 4), + copyBtn, + ]; + + if (!widget.hideRequestRewrite) { + list.add(rewriteBtn); + } + list.add(encodeBtn); + if (!inNewWindow) { + list.add(openNewBtn); + } + if (cryptoToggle != null) { + list.add(cryptoToggle); + } + + return SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: list)); } ///下载图片 @@ -520,28 +576,28 @@ class _BodyState extends State<_Body> { ); } - if (message == null || message?.body == null) { + if (message == null || message.body == null) { return const SizedBox(); } if (type == ViewType.image) { - return Center(child: Image.memory(Uint8List.fromList(message?.body ?? []), fit: BoxFit.scaleDown)); + return Center(child: Image.memory(Uint8List.fromList(message.body ?? []), fit: BoxFit.scaleDown)); } if (type == ViewType.video) { return const Center(child: Text("video not support preview")); } if (type == ViewType.hex) { - return HexViewer(data: Uint8List.fromList(message!.body!), searchController: widget.searchController); + return HexViewer(data: Uint8List.fromList(message.body!), searchController: widget.searchController); } if (type == ViewType.formUrl) { return HighlightTextWidget( - text: Uri.decodeFull(message!.getBodyString()), + text: Uri.decodeFull(message.getBodyString()), searchController: widget.searchController, contextMenuBuilder: contextMenu); } - return futureWidget(message!.decodeBodyString(), initialData: message!.getBodyString(), (body) { + return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) { try { if (type == ViewType.jsonText) { var jsonObject = json.decode(body); diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 2c6fdd3..0eaedf6 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -175,13 +175,10 @@ class _DesktopHomePagePageState extends State implements EventL isCN ? '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n' '点击HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。\n\n' - '1. 工具箱增加 WebSocket 请求测试;\n' - '2. 支持数据上报服务器;\n' - '3. 支持 SSE(event-stream)请求;\n' - '4. 增加保存HTTP请求;\n' - '5. 请求重写支持 请求方法匹配;\n' - '6. Android 系统导航栏颜色适配;\n' - '7. 修复 ios26 分享 bug;\n' + '1. 增加收藏导出和导入;\n' + '2. 增加请求解密,可配置AES自动解密消息体;\n' + '3. HTTP Header 展示增加文本和表格切换;\n' + '4. 增加 Request Param 列表展示;\n' '8. bug修复和改进;\n' : 'Note: HTTPS capture is disabled by default — please install the certificate before enabling HTTPS capture.\n\n' '1. Added WebSocket request testing in the Toolbox.\n' diff --git a/lib/ui/mobile/menu/bottom_navigation.dart b/lib/ui/mobile/menu/bottom_navigation.dart index 2ceee74..c2151ed 100644 --- a/lib/ui/mobile/menu/bottom_navigation.dart +++ b/lib/ui/mobile/menu/bottom_navigation.dart @@ -30,6 +30,7 @@ import 'package:proxypin/ui/mobile/mobile.dart'; import 'package:proxypin/ui/mobile/request/favorite.dart'; import 'package:proxypin/ui/mobile/request/history.dart'; import 'package:proxypin/ui/mobile/setting/request_block.dart'; +import 'package:proxypin/ui/mobile/setting/request_crypto.dart'; 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'; @@ -143,6 +144,12 @@ class _ConfigPageState extends State { trailing: arrow, onTap: () => navigator(context, MobileRequestMapPage())), Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), + ListTile( + title: Text(localizations.requestCrypto), + leading: Icon(Icons.lock_outline, color: color), + trailing: arrow, + onTap: () => navigator(context, const MobileRequestCryptoPage())), + Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), ListTile( title: Text(localizations.script), leading: Icon(Icons.javascript_outlined, color: color), diff --git a/lib/ui/mobile/menu/drawer.dart b/lib/ui/mobile/menu/drawer.dart index cc4098b..f88d3fe 100644 --- a/lib/ui/mobile/menu/drawer.dart +++ b/lib/ui/mobile/menu/drawer.dart @@ -34,6 +34,7 @@ import 'package:proxypin/ui/mobile/setting/app_filter.dart'; import 'package:proxypin/ui/mobile/setting/filter.dart'; import 'package:proxypin/ui/mobile/setting/request_block.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; +import 'package:proxypin/ui/mobile/setting/request_crypto.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'; @@ -133,6 +134,10 @@ class DrawerWidget extends StatelessWidget { title: Text(localizations.requestMap), leading: Icon(Icons.swap_horiz_outlined), onTap: () => navigator(context, MobileRequestMapPage())), + ListTile( + title: Text(localizations.requestCrypto), + leading: const Icon(Icons.lock_outline), + onTap: () => navigator(context, const MobileRequestCryptoPage())), ListTile( title: Text(localizations.script), leading: const Icon(Icons.code), diff --git a/lib/ui/mobile/setting/filter.dart b/lib/ui/mobile/setting/filter.dart index 6346f80..3208094 100644 --- a/lib/ui/mobile/setting/filter.dart +++ b/lib/ui/mobile/setting/filter.dart @@ -247,7 +247,7 @@ class _DomainListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart new file mode 100644 index 0000000..dfa52a5 --- /dev/null +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -0,0 +1,786 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:collection'; +import 'dart:math' as math; + +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_crypto_manager.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +bool _refresh = false; + +Future _refreshConfig({bool force = false}) async { + if (force) { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + return; + } + + if (_refresh) return; + _refresh = true; + Future.delayed(const Duration(milliseconds: 800), () async { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + }); +} + +class MobileRequestCryptoPage extends StatefulWidget { + const MobileRequestCryptoPage({super.key}); + + @override + State createState() => _MobileRequestCryptoPageState(); +} + +class _MobileRequestCryptoPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + + bool enabled = false; + bool selectionMode = false; + final Set selected = HashSet(); + bool changed = false; + + @override + Widget build(BuildContext context) { + final l10n = localizations; + return Scaffold( + appBar: AppBar( + title: Text(l10n.requestCrypto, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true, + ), + persistentFooterButtons: selectionMode ? [_buildSelectionFooter()] : null, + body: Padding( + padding: const EdgeInsets.all(10), + child: futureWidget( + RequestCryptoManager.instance, + loading: true, + (manager) { + enabled = manager.enabled; + + return Column( + children: [ + Row( + children: [ + Text("${l10n.enable} ${l10n.requestCrypto}"), + const SizedBox(width: 8), + SwitchWidget( + value: enabled, + scale: 0.8, + onChanged: (val) { + enabled = val; + manager.enabled = val; + changed = true; + setState(() {}); + _refreshConfig(); + }, + ), + ], + ), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 20), + onPressed: () => _addRule(manager), + label: Text(l10n.add), + ), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 20), + onPressed: () => _import(manager), + label: Text(l10n.import), + ), + ]), + const SizedBox(height: 10), + Expanded(child: _buildRuleList(manager)), + ], + ); + }, + ), + ), + ); + } + + Widget _buildRuleList(RequestCryptoManager manager) { + final l10n = localizations; + final primaryColor = Theme.of(context).colorScheme.primary; + final rules = manager.rules; + + return Scaffold( + body: Container( + padding: const EdgeInsets.only(top: 10, bottom: 30), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: rules.isEmpty + ? const Center(child: Text('-')) + : Scrollbar( + child: ListView(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 70, padding: const EdgeInsets.only(left: 10), child: Text(l10n.name)), + SizedBox(width: 46, child: Text(l10n.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text('URL')), + ], + ), + const Divider(thickness: 0.5), + Column( + children: List.generate(rules.length, (index) { + final rule = rules[index]; + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onLongPress: () => _showRuleActions(manager, index), + onTap: () { + if (selectionMode) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); + return; + } + _editRule(manager, index); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.8) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 45, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 70, + child: Text(rule.name.isEmpty ? '-' : rule.name, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), + SizedBox( + width: 35, + child: SwitchWidget( + scale: 0.65, + value: rule.enabled, + onChanged: (val) { + rule.enabled = val; + changed = true; + setState(() {}); + _refreshConfig(); + })), + const SizedBox(width: 20), + Expanded( + child: Text( + rule.urlPattern.isEmpty ? l10n.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13))), + ]))); + })) + ])), + ), + ); + } + + 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.withOpacity(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 RequestCryptoManager.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))), + ])))) + ]); + } + + Future _addRule(RequestCryptoManager manager) async { + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const MobileCryptoRuleEditPage())) + .then((value) { + if (value != null && mounted) { + setState(() {}); + _refreshConfig(force: true); + } + }); + } + + Future _editRule(RequestCryptoManager manager, int index) async { + final rule = manager.rules[index]; + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => MobileCryptoRuleEditPage(rule: rule))) + .then((value) { + if (value != null && mounted) { + setState(() {}); + _refreshConfig(force: true); + } + }); + } + + void _showRuleActions(RequestCryptoManager manager, 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), + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(l10n.edit), + onTap: () { + Navigator.pop(ctx); + _editRule(manager, 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: manager.rules[index].enabled ? l10n.disabled : l10n.enable, + onPressed: () { + manager.rules[index].enabled = !manager.rules[index].enabled; + changed = true; + setState(() {}); + _refreshConfig(); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: l10n.delete, + onPressed: () { + Navigator.pop(ctx); + _removeRule(manager, 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(RequestCryptoManager manager, int index) async { + await manager.removeRule(index); + if (!mounted) return; + changed = true; + setState(() {}); + _refreshConfig(force: true); + } + + Future _removeSelected() async { + final l10n = localizations; + if (selected.isEmpty) return; + showConfirmDialog(context, content: l10n.confirmContent, onConfirm: () async { + final manager = await RequestCryptoManager.instance; + final indexes = selected.toList()..sort((a, b) => b.compareTo(a)); + for (final idx in indexes) { + await manager.removeRule(idx); + } + if (!mounted) return; + changed = true; + setState(() { + selectionMode = false; + selected.clear(); + }); + _refreshConfig(force: true); + if (mounted) FlutterToastr.show(l10n.deleteSuccess, context); + }); + } + + Future _import(RequestCryptoManager manager) async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']); + final path = result?.files.single.path; + if (path == null) return; + final content = await File(path).readAsString(); + final List list = jsonDecode(content); + for (final item in list) { + await manager.addRule(CryptoRule.fromJson(Map.from(item))); + } + if (!mounted) return; + changed = true; + setState(() {}); + _refreshConfig(force: true); + FlutterToastr.show(localizations.importSuccess, context); + } catch (e) { + logger.e('导入失败', error: e); + if (mounted) FlutterToastr.show('${localizations.importFailed} $e', context); + } + } + + Future _export(RequestCryptoManager manager, {List? indexes}) async { + try { + if (manager.rules.isEmpty) return; + final keys = (indexes == null || indexes.isEmpty) + ? 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'); + if (path == null) return; + await File(path).writeAsString(jsonEncode(data)); + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } catch (e) { + logger.e('导出失败', error: e); + if (mounted) FlutterToastr.show('Export failed: $e', context); + } + } +} + + +/// Mobile editor page for a single crypto rule. +/// +/// This mirrors the mobile rewrite editor pattern: push to a page, edit, and save. +class MobileCryptoRuleEditPage extends StatefulWidget { + final CryptoRule? rule; + + const MobileCryptoRuleEditPage({super.key, this.rule}); + + @override + State createState() => _MobileCryptoRuleEditPageState(); +} + +class _MobileCryptoRuleEditPageState extends State { + AppLocalizations get l10n => AppLocalizations.of(context)!; + + final GlobalKey _formKey = GlobalKey(); + + late CryptoRule _rule; + + late TextEditingController nameController; + late TextEditingController patternController; + late TextEditingController fieldController; + + // key + iv + late TextEditingController keyController; + late TextEditingController ivController; + + bool enabled = true; + String mode = 'CBC'; + String padding = 'PKCS7'; + int length = 256; + + // formats & sources + String keyFormat = 'text'; // text | base64 + String ivSource = 'manual'; // manual | prefix + int ivPrefixLength = 16; + + @override + void initState() { + super.initState(); + + _rule = (widget.rule ?? CryptoRule.newRule()); + + nameController = TextEditingController(text: _rule.name); + patternController = TextEditingController(text: _rule.urlPattern); + fieldController = TextEditingController(text: _rule.field ?? ''); + + enabled = _rule.enabled; + mode = _rule.config.mode; + padding = _rule.config.padding; + length = _rule.config.keyLength; + + // key format handling (only text/base64) + final storedKey = _rule.config.key.trim(); + if (storedKey.startsWith('base64:')) { + keyFormat = 'base64'; + keyController = TextEditingController(text: storedKey.substring(7)); + } else { + keyFormat = 'text'; + keyController = TextEditingController(text: storedKey); + } + + // iv source and value + ivSource = _rule.config.ivSource; + ivPrefixLength = _rule.config.ivPrefixLength; + + final storedIv = _rule.config.iv.trim(); + if (storedIv.startsWith('base64:')) { + ivController = TextEditingController(text: storedIv.substring(7)); + } else { + ivController = TextEditingController(text: storedIv); + } + } + + @override + void dispose() { + nameController.dispose(); + patternController.dispose(); + fieldController.dispose(); + keyController.dispose(); + ivController.dispose(); + super.dispose(); + } + + InputDecoration _decorate(String label, {String? hint}) { + return InputDecoration( + labelText: label, + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.withOpacity(0.8)), + isDense: true, + border: const OutlineInputBorder(), + ); + } + + @override + Widget build(BuildContext context) { + final isCN = Localizations.localeOf(context).languageCode == 'zh'; + + return Scaffold( + appBar: AppBar( + title: Text(widget.rule == null ? l10n.newBuilt : l10n.edit, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + actions: [ + TextButton( + onPressed: _save, + child: Text(l10n.save), + ), + const SizedBox(width: 6), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(12), + children: [ + Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.match, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 10), + TextFormField( + controller: nameController, + decoration: _decorate(l10n.name), + ), + const SizedBox(height: 10), + TextFormField( + controller: patternController, + decoration: _decorate('URL', hint: 'https://www.example.com/api/*'), + validator: (val) => (val == null || val.trim().isEmpty) ? l10n.cannotBeEmpty : null, + ), + const SizedBox(height: 10), + TextFormField( + controller: fieldController, + decoration: _decorate(l10n.cryptoRuleField, hint: isCN ? '为空=整个 body' : 'empty = whole body'), + ), + const SizedBox(height: 6), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(l10n.enable), + value: enabled, + onChanged: (v) => setState(() => enabled = v), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('AES', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _kvDropdown( + label: 'Mode', + child: DropdownButton( + value: mode, + items: const [ + DropdownMenuItem(value: 'ECB', child: Text('ECB')), + DropdownMenuItem(value: 'CBC', child: Text('CBC')), + ], + onChanged: (v) => setState(() => mode = v ?? 'CBC'), + ), + ), + _kvDropdown( + label: 'Padding', + child: DropdownButton( + value: padding, + items: const [ + DropdownMenuItem(value: 'PKCS7', child: Text('PKCS7')), + DropdownMenuItem(value: 'ZeroPadding', child: Text('ZeroPadding')), + ], + onChanged: (v) => setState(() => padding = v ?? 'PKCS7'), + ), + ), + _kvDropdown( + label: 'Key Length', + child: DropdownButton( + value: length, + items: const [ + DropdownMenuItem(value: 128, child: Text('128')), + DropdownMenuItem(value: 192, child: Text('192')), + DropdownMenuItem(value: 256, child: Text('256')), + ], + onChanged: (v) => setState(() => length = v ?? 256), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _chipDropdown( + value: keyFormat, + items: const [ + DropdownMenuItem(value: 'text', child: Text('text')), + DropdownMenuItem(value: 'base64', child: Text('base64')), + ], + onChanged: (v) => setState(() => keyFormat = v ?? 'text'), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: keyController, + decoration: _decorate('Key'), + validator: (val) => (val == null || val.trim().isEmpty) ? l10n.cannotBeEmpty : null, + ), + ), + ], + ), + const SizedBox(height: 10), + if (mode == 'CBC') ...[ + Row( + children: [ + _chipDropdown( + value: ivSource, + items: [ + DropdownMenuItem(value: 'manual', child: Text(l10n.manual)), + DropdownMenuItem(value: 'prefix', child: Text(l10n.cryptoIvPrefixLabel)), + ], + onChanged: (v) => setState(() => ivSource = v ?? 'manual'), + ), + const SizedBox(width: 10), + Expanded( + child: ivSource == 'manual' + ? TextFormField( + controller: ivController, + decoration: _decorate('IV'), + validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) + ? l10n.cannotBeEmpty + : null, + ) + : _ivPrefixLengthEditor(), + ), + ], + ), + if (ivSource == 'prefix') + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + l10n.cryptoIvPrefixTooltip, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _kvDropdown({required String label, required Widget child}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 8), + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButtonHideUnderline(child: child), + ), + ], + ); + } + + Widget _chipDropdown({ + required T value, + required List> items, + required ValueChanged onChanged, + }) { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + items: items, + onChanged: onChanged, + ), + ), + ); + } + + Widget _ivPrefixLengthEditor() { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + icon: const Icon(Icons.remove, size: 16), + onPressed: () => setState(() => ivPrefixLength = math.max(1, ivPrefixLength - 1)), + ), + Text(ivPrefixLength.toString()), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + icon: const Icon(Icons.add, size: 16), + onPressed: () => setState(() => ivPrefixLength = math.min(1024, ivPrefixLength + 1)), + ), + ], + ), + ); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) { + FlutterToastr.show(l10n.cannotBeEmpty, context, position: FlutterToastr.center); + return; + } + + var outKey = keyController.text.trim(); + if (!outKey.startsWith('base64:') && keyFormat == 'base64') { + outKey = 'base64:$outKey'; + } + + String outIv = ''; + if (ivSource == 'manual') { + outIv = ivController.text.trim(); + if (!outIv.startsWith('base64:') && keyFormat == 'base64') { + outIv = 'base64:$outIv'; + } + } + + final updated = _rule.copyWith( + name: nameController.text.trim(), + urlPattern: patternController.text.trim(), + field: fieldController.text.trim(), + enabled: enabled, + config: CryptoKeyConfig( + key: outKey, + iv: outIv, + ivSource: ivSource, + ivPrefixLength: ivPrefixLength, + mode: mode, + padding: padding, + keyLength: length, + ), + ); + + final manager = await RequestCryptoManager.instance; + final idx = manager.rules.indexOf(_rule); + + if (idx >= 0) { + await manager.updateRule(idx, updated); + } else { + await manager.addRule(updated); + } + await manager.flushConfig(); + + if (!mounted) return; + FlutterToastr.show(l10n.saveSuccess, context); + Navigator.of(context).pop(updated); + } +} + diff --git a/lib/ui/mobile/setting/request_map.dart b/lib/ui/mobile/setting/request_map.dart index e175e57..f9c37dd 100644 --- a/lib/ui/mobile/setting/request_map.dart +++ b/lib/ui/mobile/setting/request_map.dart @@ -162,10 +162,12 @@ class _RequestMapListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), child: Scrollbar( child: ListView(children: [ Row( diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index 46d44bf..0b7bb26 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -172,7 +172,7 @@ class _RequestRuleListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10, bottom: 30), decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 148697d..82dc31d 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -652,7 +652,7 @@ class _ScriptListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10, bottom: 30), decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))),