import 'dart:collection'; import 'dart:convert'; import 'dart:io'; 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 { final RequestBreakpointManager manager; const MobileRequestBreakpointPage({super.key, required this.manager}); @override State createState() => _RequestBreakpointPageState(); } class _RequestBreakpointPageState extends State { AppLocalizations get localizations => AppLocalizations.of(context)!; List rules = []; bool enabled = false; RequestBreakpointManager get manager => widget.manager; bool selectionMode = false; final Set selected = HashSet(); Future _save() async { await manager.save(); } @override void initState() { super.initState(); enabled = manager.enabled; rules = manager.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 ? 230 : 160, 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: [ IconButton( icon: Icon(Icons.add, size: 22, color: Theme.of(context).colorScheme.primary), onPressed: _editRule, tooltip: localizations.add), const SizedBox(width: 5), IconButton( icon: Icon(Icons.input_rounded, size: 22, color: Theme.of(context).colorScheme.primary), onPressed: _import, tooltip: localizations.import), ])), 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); } } Future _import() async { try { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['json'], ); if (result == null || result.files.isEmpty) return; File file = File(result.files.single.path!); String content = await file.readAsString(); List list = jsonDecode(content); var newRules = list.map((e) => RequestBreakpointRule.fromJson(e)).toList(); for (var rule in newRules) { manager.list.add(rule); } await _save(); setState(() { rules = manager.list; }); if (mounted) FlutterToastr.show(localizations.importSuccess, context); } catch (e) { logger.e('Import failed', error: e); if (mounted) FlutterToastr.show(localizations.importFailed, 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)), ])))); } }