mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-03-19 05:19:47 +08:00
497 lines
18 KiB
Dart
497 lines
18 KiB
Dart
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<MobileRequestBreakpointPage> createState() => _RequestBreakpointPageState();
|
|
}
|
|
|
|
class _RequestBreakpointPageState extends State<MobileRequestBreakpointPage> {
|
|
AppLocalizations get localizations => AppLocalizations.of(context)!;
|
|
List<RequestBreakpointRule> rules = [];
|
|
bool enabled = false;
|
|
|
|
RequestBreakpointManager get manager => widget.manager;
|
|
|
|
bool selectionMode = false;
|
|
final Set<int> selected = HashSet<int>();
|
|
|
|
Future<void> _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<void> _export(RequestBreakpointManager? manager, {List<int>? indexes}) async {
|
|
try {
|
|
if (manager == null || manager.list.isEmpty) return;
|
|
final rules = manager.list;
|
|
final keys = (indexes == null || indexes.isEmpty)
|
|
? List<int>.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<void> _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<dynamic> 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<void> _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<void> _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<MobileBreakpointRuleEditor> createState() => _MobileBreakpointRuleEditorState();
|
|
}
|
|
|
|
class _MobileBreakpointRuleEditorState extends State<MobileBreakpointRuleEditor> {
|
|
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;
|
|
}
|
|
|
|
@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)),
|
|
]))));
|
|
}
|
|
}
|