Files
proxypin/lib/ui/mobile/setting/request_breakpoint.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)),
]))));
}
}