Files
proxypin/lib/ui/desktop/setting/request_breakpoint.dart
2026-03-01 23:57:58 +08:00

580 lines
20 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/ui/component/utils.dart';
import 'package:proxypin/ui/component/widgets.dart';
import '../../component/app_dialog.dart' show CustomToast;
import '../../component/http_method_popup.dart';
// Compat helpers (withValues extension)
import 'package:proxypin/utils/flutter_compat.dart';
class RequestBreakpointPage extends StatefulWidget {
final RequestBreakpointManager manager;
final int? windowId;
const RequestBreakpointPage({super.key, this.windowId, required this.manager});
@override
State<RequestBreakpointPage> createState() => _RequestBreakpointPageState();
}
class _RequestBreakpointPageState extends State<RequestBreakpointPage> {
AppLocalizations get localizations => AppLocalizations.of(context)!;
List<RequestBreakpointRule> rules = [];
bool enabled = false;
RequestBreakpointManager get manager => widget.manager;
Set<int> selected = {};
bool isPressed = false;
Offset? lastPressPosition;
Future<void> _refreshConfig() async {
if (widget.windowId != null) {
await DesktopMultiWindow.invokeMethod(0, "refreshRequestBreakpoint");
}
}
Future<void> _save() async {
await manager.save();
await _refreshConfig();
}
Future<void> _import() async {
String? path;
if (Platform.isMacOS) {
path = await DesktopMultiWindow.invokeMethod(0, "pickFiles", {
"allowedExtensions": ['json']
});
if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show();
} else {
FilePickerResult? result =
await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']);
path = result?.files.single.path;
}
if (path == null) return;
File file = File(path);
try {
String content = await file.readAsString();
List<dynamic> list = jsonDecode(content);
var rules = list.map((e) => RequestBreakpointRule.fromJson(e)).toList();
for (var rule in rules) {
manager.list.add(rule);
}
await _save();
setState(() {
this.rules = manager.list;
});
if (mounted) CustomToast.success(localizations.importSuccess).show(context);
} catch (e) {
if (mounted) CustomToast.error(localizations.importFailed).show(context);
}
}
Future<void> _export(List<RequestBreakpointRule> exportRules) async {
if (exportRules.isEmpty) return;
String? outputFile;
if (Platform.isMacOS) {
outputFile = await DesktopMultiWindow.invokeMethod(0, "saveFile", {"fileName": 'request_breakpoint_rules.json'});
if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show();
} else {
outputFile = await FilePicker.platform.saveFile(fileName: 'request_breakpoint_rules.json');
}
if (outputFile == null) return;
File file = File(outputFile);
try {
var json = exportRules.map((e) => e.toJson()).toList();
await file.writeAsString(jsonEncode(json));
if (mounted) CustomToast.success(localizations.exportSuccess).show(context);
} catch (e) {
if (mounted) CustomToast.error(localizations.exportFailed).show(context);
}
}
@override
void initState() {
super.initState();
enabled = manager.enabled;
rules = manager.list;
HardwareKeyboard.instance.addHandler(onKeyEvent);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(onKeyEvent);
super.dispose();
}
bool onKeyEvent(KeyEvent event) {
if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape) && Navigator.canPop(context)) {
Navigator.maybePop(context);
return true;
}
if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) &&
event.logicalKey == LogicalKeyboardKey.keyW) {
if (Navigator.canPop(context)) {
Navigator.pop(context);
return true;
}
if (widget.windowId != null) {
WindowController.fromWindowId(widget.windowId!).close();
}
return true;
}
return false;
}
@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();
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: 5),
TextButton.icon(
icon: const Icon(Icons.input_rounded, size: 18),
onPressed: _import,
label: Text(localizations.import)),
])),
const SizedBox(width: 15)
]),
const SizedBox(height: 10),
Expanded(child: _buildList())
]))));
}
Widget _buildList() {
return GestureDetector(
onSecondaryTap: () {
if (lastPressPosition == null) {
return;
}
_showMenu(lastPressPosition!);
},
onTapDown: (details) {
if (selected.isEmpty) {
return;
}
if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) {
return;
}
setState(() {
selected.clear();
});
},
child: Listener(
onPointerUp: (event) => isPressed = false,
onPointerDown: (event) {
lastPressPosition = event.localPosition;
if (event.buttons == kPrimaryMouseButton) {
isPressed = true;
}
},
child: Container(
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 5, bottom: 5),
child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [
Container(width: 150, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)),
SizedBox(width: 50, 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.withOpacity(0.3),
onDoubleTap: () => _editRule(rule: rule),
onSecondaryTapDown: (details) => _showMenu(details.globalPosition, index: index),
onHover: (hover) {
if (isPressed && !selected.contains(index)) {
setState(() {
selected.add(index);
});
}
},
onTap: () {
if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) {
setState(() {
selected.contains(index) ? selected.remove(index) : selected.add(index);
});
return;
}
if (selected.isEmpty) {
return;
}
setState(() {
selected.clear();
});
},
child: Container(
color: selected.contains(index)
? primaryColor.withOpacity(0.5)
: index.isEven
? Colors.grey.withOpacity(0.1)
: null,
height: 32,
padding: const EdgeInsets.all(5),
child: Row(children: [
SizedBox(
width: 150,
child: Text(rule.name ?? "",
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
SizedBox(
width: 50,
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)),
]),
),
);
}
void _showMenu(Offset position, {int? index}) {
if (index != null) {
if (!selected.contains(index)) {
setState(() {
selected.clear();
selected.add(index);
});
}
}
showContextMenu(context, position, items: [
PopupMenuItem(
height: 32,
child: Text(localizations.edit),
onTap: () {
if (selected.length == 1) {
_editRule(rule: rules[selected.first]);
}
},
),
PopupMenuItem(
height: 32,
child: Text(localizations.export),
onTap: () async {
if (selected.isEmpty) return;
var list = selected.toList();
List<RequestBreakpointRule> exportRules = [];
for (var i in list) {
exportRules.add(rules[i]);
}
await _export(exportRules);
setState(() {
selected.clear();
});
},
),
PopupMenuItem(
height: 32,
child: Text(localizations.delete),
onTap: () async {
if (selected.isEmpty) return;
var list = selected.toList();
list.sort((a, b) => b.compareTo(a)); // Remove from end to avoid index shift issues
for (var i in list) {
rules.removeAt(i);
}
setState(() {
selected.clear();
});
await _save();
},
),
]);
}
void _editRule({RequestBreakpointRule? rule}) {
showDialog(
context: context,
builder: (context) => InterceptRuleDialog(rule: rule),
).then((value) async {
if (value != null && value is RequestBreakpointRule) {
setState(() {
if (rule == null) {
rules.add(value);
}
});
await _save();
}
});
}
}
class InterceptRuleDialog extends StatefulWidget {
final RequestBreakpointRule? rule;
const InterceptRuleDialog({super.key, this.rule});
@override
State<InterceptRuleDialog> createState() => _InterceptRuleDialogState();
}
class _InterceptRuleDialogState extends State<InterceptRuleDialog> {
late RequestBreakpointRule rule;
final _formKey = GlobalKey<FormState>();
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;
}
InputDecoration decoration(String label, {String? hintText}) {
return InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: label,
hintText: hintText,
isDense: true,
border: const OutlineInputBorder());
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
widget.rule == null
? "${localizations.add} ${localizations.breakpointRule}"
: "${localizations.edit} ${localizations.breakpointRule}",
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
actionsPadding: const EdgeInsets.only(right: 15, bottom: 15),
contentPadding: const EdgeInsets.only(left: 20, right: 20, top: 15, bottom: 15),
content: Container(
constraints: const BoxConstraints(minWidth: 350, maxWidth: 500),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(width: 55, child: Text('${localizations.enable}:')),
SwitchWidget(value: rule.enabled, onChanged: (val) => rule.enabled = val, scale: 0.8)
]),
const SizedBox(height: 5),
textField('${localizations.name}:', nameInput, localizations.pleaseEnter),
const SizedBox(height: 10),
Row(children: [
SizedBox(width: 60, child: Text('URL:')),
Expanded(
child: TextFormField(
controller: urlInput,
style: const TextStyle(fontSize: 14),
validator: (val) => val?.isNotEmpty == true ? null : localizations.cannotBeEmpty,
decoration: InputDecoration(
hintText: 'https://www.example.com/api/*',
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 10),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
isDense: true,
border: const OutlineInputBorder(),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 6, right: 6),
child: MethodPopupMenu(
value: _method,
showSeparator: true,
onChanged: (val) {
setState(() {
_method = val;
});
},
),
),
),
),
),
]),
const SizedBox(height: 10),
Text(localizations.breakpoint, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13)),
const SizedBox(height: 5),
Container(
decoration: BoxDecoration(
// border: Border.all(color: Colors.grey.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(5)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CheckboxListTile(
contentPadding: const EdgeInsets.only(left: 10),
title: Text(localizations.request, style: const TextStyle(fontSize: 14)),
value: _interceptRequest,
controlAffinity: ListTileControlAffinity.leading,
dense: true,
visualDensity: const VisualDensity(vertical: -4),
onChanged: (val) {
setState(() {
_interceptRequest = val!;
});
},
)),
// Container(height: 30, width: 0.5, color: Colors.grey.withValues(alpha: 0.5)),
Expanded(
child: CheckboxListTile(
contentPadding: const EdgeInsets.only(left: 10),
title: Text(localizations.response, style: const TextStyle(fontSize: 14)),
value: _interceptResponse,
controlAffinity: ListTileControlAffinity.leading,
dense: true,
visualDensity: const VisualDensity(vertical: -4),
onChanged: (val) {
setState(() {
_interceptResponse = val!;
});
},
)),
Expanded(child: SizedBox()),
],
),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(localizations.cancel),
),
FilledButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) {
CustomToast.error("URL ${localizations.cannotBeEmpty}").show(context, alignment: Alignment.topCenter);
return;
}
rule.name = nameInput.text;
rule.url = urlInput.text;
rule.method = _method;
rule.interceptRequest = _interceptRequest;
rule.interceptResponse = _interceptResponse;
Navigator.pop(context, rule);
},
child: Text(localizations.save),
),
],
);
}
Widget textField(String label, TextEditingController controller, String hint,
{bool required = false, FormFieldSetter<String>? onSaved}) {
return Row(children: [
SizedBox(width: 60, child: Text(label)),
Expanded(
child: TextFormField(
controller: controller,
style: const TextStyle(fontSize: 14),
validator: (val) => val?.isNotEmpty == true || !required ? null : "",
onSaved: onSaved,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
errorStyle: const TextStyle(height: 0, fontSize: 0),
focusedBorder: focusedBorder(),
isDense: true,
border: const OutlineInputBorder()),
))
]);
}
InputBorder focusedBorder() {
return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2));
}
}