mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-10 00:44:12 +08:00
517 lines
18 KiB
Dart
517 lines
18 KiB
Dart
/*
|
|
* Copyright 2023 Hongen Wang
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:proxypin/l10n/app_localizations.dart';
|
|
import 'package:flutter_toastr/flutter_toastr.dart';
|
|
import 'package:proxypin/network/components/manager/hosts_manager.dart';
|
|
import 'package:proxypin/network/util/logger.dart';
|
|
import 'package:proxypin/ui/component/utils.dart';
|
|
import 'package:proxypin/ui/component/widgets.dart';
|
|
|
|
/// Hosts page
|
|
/// @author wanghongen
|
|
class HostsPage extends StatefulWidget {
|
|
final HostsManager hostsManager;
|
|
|
|
const HostsPage({super.key, required this.hostsManager});
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _HostsPageState();
|
|
}
|
|
|
|
class _HostsPageState extends State<HostsPage> {
|
|
late HostsManager hostsManager = widget.hostsManager;
|
|
Set<HostsItem> selected = {};
|
|
Set<String> offstage = {};
|
|
|
|
bool multiple = false;
|
|
|
|
bool saving = false;
|
|
|
|
AppLocalizations get localizations => AppLocalizations.of(context)!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
}
|
|
|
|
saveConfig() {
|
|
if (saving) return;
|
|
saving = true;
|
|
Future.delayed(const Duration(milliseconds: 3000), () {
|
|
widget.hostsManager.flushConfig();
|
|
saving = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(centerTitle: true, title: Text('Hosts', style: const TextStyle(fontSize: 16))),
|
|
persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()],
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
children: <Widget>[
|
|
Row(children: [
|
|
Container(width: 15),
|
|
Text(localizations.enable),
|
|
const SizedBox(width: 10),
|
|
SwitchWidget(
|
|
scale: 0.8,
|
|
value: widget.hostsManager.enabled,
|
|
onChanged: (value) {
|
|
widget.hostsManager.enabled = value;
|
|
saveConfig();
|
|
}),
|
|
]),
|
|
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.add, size: 18), onPressed: showEdit, label: Text(localizations.newBuilt)),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.folder_outlined, size: 18),
|
|
onPressed: newFolder,
|
|
label: Text(localizations.newFolder)),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.input_rounded, size: 18),
|
|
onPressed: import,
|
|
label: Text(localizations.import)),
|
|
SizedBox(width: 3),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: Column(children: [
|
|
const SizedBox(height: 5),
|
|
Row(children: [
|
|
Container(width: 15),
|
|
SizedBox(width: 50, child: Text(localizations.enable, style: const TextStyle(fontSize: 14))),
|
|
Container(width: 15),
|
|
Expanded(child: Text(localizations.domain, style: TextStyle(fontSize: 14))),
|
|
Container(width: 15),
|
|
Expanded(child: Text(localizations.toAddress, style: const TextStyle(fontSize: 14))),
|
|
]),
|
|
const Divider(thickness: 0.5),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: widget.hostsManager.list.length,
|
|
padding: const EdgeInsets.only(right: 10),
|
|
itemBuilder: (_, index) => row(widget.hostsManager.list[index], index.isEven)))
|
|
])),
|
|
],
|
|
)));
|
|
}
|
|
|
|
Widget row(HostsItem item, bool isEven, {EdgeInsetsGeometry? padding}) {
|
|
var primaryColor = Theme.of(context).colorScheme.primary;
|
|
|
|
return Column(children: [
|
|
GestureDetector(
|
|
onLongPressStart: (details) => showMenus(details, item),
|
|
onTap: () {
|
|
if (multiple) {
|
|
setState(() {
|
|
selected.contains(item) ? selected.remove(item) : selected.add(item);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (item.isFolder) {
|
|
setState(() {
|
|
offstage.contains(item.id) ? offstage.remove(item.id) : offstage.add(item.id);
|
|
});
|
|
return;
|
|
}
|
|
showEdit(item: item);
|
|
},
|
|
child: Container(
|
|
color: selected.contains(item)
|
|
? primaryColor.withOpacity(0.6)
|
|
: isEven
|
|
? Colors.grey.withOpacity(0.1)
|
|
: null,
|
|
height: 42,
|
|
padding: padding ?? const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
children: [
|
|
SwitchWidget(
|
|
scale: 0.6,
|
|
value: item.enabled,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
item.enabled = val;
|
|
saveConfig();
|
|
});
|
|
}),
|
|
Container(width: 15),
|
|
Expanded(
|
|
child: IconText(
|
|
icon: item.isFolder
|
|
? Icon(offstage.contains(item.id) ? Icons.folder : Icons.folder_outlined, size: 18)
|
|
: null,
|
|
text: item.host,
|
|
textStyle: const TextStyle(fontSize: 14))),
|
|
Container(width: 15),
|
|
Expanded(child: Text(item.toAddress ?? '', style: const TextStyle(fontSize: 14)))
|
|
],
|
|
))),
|
|
if (item.isFolder)
|
|
Offstage(
|
|
offstage: offstage.contains(item.id),
|
|
child: Column(
|
|
children: widget.hostsManager
|
|
.getFolderList(item.id)
|
|
.map((e) => row(e, !isEven, padding: EdgeInsets.only(left: 60)))
|
|
.toList()))
|
|
]);
|
|
}
|
|
|
|
newFolder() {
|
|
showEdit(isFolder: true);
|
|
}
|
|
|
|
showEdit({HostsItem? item, HostsItem? parent, bool? isFolder}) {
|
|
isFolder ??= item?.isFolder == true;
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) => isFolder == true
|
|
? FolderDialog(hostsManager: widget.hostsManager, folder: item)
|
|
: HostsEditDialog(item: item, parent: parent)).then((value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
saveConfig();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
globalMenu() {
|
|
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: () {
|
|
export(selected);
|
|
setState(() {
|
|
selected.clear();
|
|
multiple = false;
|
|
});
|
|
},
|
|
icon: const Icon(Icons.share, size: 18),
|
|
label: Text(localizations.export, style: const TextStyle(fontSize: 14))),
|
|
TextButton.icon(
|
|
onPressed: () => removeHosts(selected),
|
|
icon: const Icon(Icons.delete, size: 18),
|
|
label: Text(localizations.delete, style: const TextStyle(fontSize: 14))),
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
multiple = false;
|
|
selected.clear();
|
|
});
|
|
},
|
|
icon: const Icon(Icons.cancel, size: 18),
|
|
label: Text(localizations.cancel, style: const TextStyle(fontSize: 14))),
|
|
]))))
|
|
]);
|
|
}
|
|
|
|
//点击菜单
|
|
showMenus(LongPressStartDetails details, HostsItem item) {
|
|
//长按反馈
|
|
HapticFeedback.lightImpact();
|
|
|
|
setState(() {
|
|
selected.add(item);
|
|
});
|
|
|
|
showContextMenu(context, details.globalPosition, items: [
|
|
if (item.isFolder)
|
|
PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit(parent: item)),
|
|
PopupMenuItem(height: 35, child: Text(localizations.multiple), onTap: () => setState(() => multiple = true)),
|
|
PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(item: item)),
|
|
PopupMenuItem(height: 35, onTap: () => export([item]), child: Text(localizations.export)),
|
|
PopupMenuItem(
|
|
height: 35,
|
|
child: item.enabled ? Text(localizations.disabled) : Text(localizations.enable),
|
|
onTap: () {
|
|
setState(() {
|
|
item.enabled = !item.enabled;
|
|
saveConfig();
|
|
});
|
|
}),
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
height: 35,
|
|
child: Text(localizations.delete),
|
|
onTap: () async {
|
|
setState(() {
|
|
widget.hostsManager.removeHosts([item]);
|
|
});
|
|
})
|
|
]).then((value) {
|
|
setState(() {
|
|
selected.remove(item);
|
|
});
|
|
});
|
|
}
|
|
|
|
//删除
|
|
Future<void> removeHosts(Set<HostsItem> items) async {
|
|
if (items.isEmpty) return;
|
|
return showConfirmDialog(context, onConfirm: () async {
|
|
await widget.hostsManager.removeHosts(items);
|
|
setState(() {
|
|
multiple = false;
|
|
items.clear();
|
|
});
|
|
if (mounted) FlutterToastr.show(localizations.deleteSuccess, context);
|
|
});
|
|
}
|
|
|
|
//导入
|
|
import() async {
|
|
final FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.any);
|
|
var file = result?.files.single;
|
|
if (file == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
List json = jsonDecode(await file.xFile.readAsString());
|
|
Map<String, String> idMap = {};
|
|
|
|
for (var item in json) {
|
|
//生成新的id 保存映射关系
|
|
String newId = HostsItem.generateId();
|
|
idMap[item['id']] = newId;
|
|
item['id'] = newId;
|
|
var hostsItem = HostsItem.fromJson(item);
|
|
|
|
if (hostsItem.parent != null) {
|
|
hostsItem.parent = idMap[hostsItem.parent!];
|
|
}
|
|
|
|
widget.hostsManager.addHosts(hostsItem);
|
|
}
|
|
|
|
saveConfig();
|
|
if (mounted) {
|
|
FlutterToastr.show(localizations.importSuccess, context);
|
|
}
|
|
setState(() {});
|
|
} catch (e, t) {
|
|
logger.e('导入失败 $file', error: e, stackTrace: t);
|
|
if (mounted) {
|
|
FlutterToastr.show("${localizations.importFailed} $e", context);
|
|
}
|
|
}
|
|
}
|
|
|
|
//导出
|
|
export(Iterable<HostsItem> items) async {
|
|
if (items.isEmpty) return;
|
|
|
|
String fileName = 'hosts.json';
|
|
var list = [];
|
|
for (var item in items) {
|
|
var json = item.toJson();
|
|
list.add(json);
|
|
}
|
|
|
|
var path = await FilePicker.platform.saveFile(fileName: fileName, bytes: utf8.encode(jsonEncode(list)));
|
|
if (path == null) {
|
|
return;
|
|
}
|
|
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
|
|
}
|
|
}
|
|
|
|
class FolderDialog extends StatelessWidget {
|
|
final HostsManager hostsManager;
|
|
final HostsItem? folder;
|
|
|
|
const FolderDialog({super.key, required this.hostsManager, this.folder});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
AppLocalizations localizations = AppLocalizations.of(context)!;
|
|
bool enabled = folder?.enabled ?? true;
|
|
String name = folder?.host ?? '';
|
|
|
|
return AlertDialog(
|
|
title: Text(localizations.newFolder, style: const TextStyle(fontSize: 16)),
|
|
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Row(children: [
|
|
SizedBox(width: 55, child: Text(localizations.enable)),
|
|
SwitchWidget(scale: 0.8, value: enabled, onChanged: (value) => enabled = value)
|
|
]),
|
|
SizedBox(height: 10),
|
|
Row(children: [
|
|
SizedBox(width: 55, child: Text(localizations.name)),
|
|
Expanded(
|
|
child: TextFormField(
|
|
minLines: 1,
|
|
maxLines: 3,
|
|
initialValue: name,
|
|
onChanged: (val) => name = val,
|
|
decoration: InputDecoration(border: OutlineInputBorder())))
|
|
])
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)),
|
|
TextButton(
|
|
onPressed: () {
|
|
HostsItem item;
|
|
if (folder == null) {
|
|
item = HostsItem(isFolder: true, host: name, enabled: enabled);
|
|
hostsManager.addHosts(item);
|
|
} else {
|
|
folder!.enabled = enabled;
|
|
folder!.host = name;
|
|
item = folder!;
|
|
}
|
|
Navigator.pop(context, item);
|
|
},
|
|
child: Text(localizations.save)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class HostsEditDialog extends StatefulWidget {
|
|
final HostsItem? item;
|
|
final HostsItem? parent;
|
|
|
|
const HostsEditDialog({super.key, this.item, this.parent});
|
|
|
|
@override
|
|
State<HostsEditDialog> createState() => _HostsEditDialogState();
|
|
}
|
|
|
|
class _HostsEditDialogState extends State<HostsEditDialog> {
|
|
GlobalKey formKey = GlobalKey<FormState>();
|
|
|
|
bool enabled = true;
|
|
TextEditingController hostController = TextEditingController();
|
|
TextEditingController toAddressController = TextEditingController();
|
|
|
|
AppLocalizations get localizations => AppLocalizations.of(context)!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.item != null) {
|
|
enabled = widget.item!.enabled;
|
|
hostController.text = widget.item!.host;
|
|
toAddressController.text = widget.item!.toAddress ?? '';
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
hostController.dispose();
|
|
toAddressController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
contentPadding: const EdgeInsets.only(left: 20, right: 20, top: 10),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(localizations.cancel)),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (!(formKey.currentState as FormState).validate()) {
|
|
FlutterToastr.show(
|
|
"${localizations.domain} ${localizations.toAddress} ${localizations.cannotBeEmpty}", context,
|
|
position: FlutterToastr.center);
|
|
return;
|
|
}
|
|
|
|
HostsItem? hostItem;
|
|
if (widget.item == null) {
|
|
hostItem = HostsItem(
|
|
enabled: enabled,
|
|
parent: widget.parent?.id,
|
|
host: hostController.text,
|
|
toAddress: toAddressController.text);
|
|
HostsManager.instance.then((it) => it.addHosts(hostItem!));
|
|
} else {
|
|
widget.item!.enabled = enabled;
|
|
widget.item!.host = hostController.text;
|
|
widget.item!.toAddress = toAddressController.text;
|
|
hostItem = widget.item;
|
|
}
|
|
|
|
Navigator.pop(context, hostItem);
|
|
},
|
|
child: Text(localizations.save)),
|
|
],
|
|
content: Form(
|
|
key: formKey,
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Row(children: [
|
|
SizedBox(width: 80, child: Text(localizations.enable)),
|
|
Expanded(child: SwitchWidget(scale: 0.8, value: enabled, onChanged: (value) => enabled = value)),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
SizedBox(width: 80, child: Text(localizations.domain)),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: hostController,
|
|
validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null,
|
|
decoration: const InputDecoration(
|
|
hintText: '*.example.com',
|
|
hintStyle: TextStyle(color: Colors.grey),
|
|
errorStyle: TextStyle(height: 0, fontSize: 0),
|
|
border: OutlineInputBorder()))),
|
|
]),
|
|
const SizedBox(height: 10),
|
|
Row(children: [
|
|
SizedBox(width: 80, child: Text(localizations.toAddress)),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: toAddressController,
|
|
validator: (val) => val == null || val.trim().isEmpty ? localizations.cannotBeEmpty : null,
|
|
decoration: const InputDecoration(
|
|
hintText: '202.108.22.5',
|
|
errorStyle: TextStyle(height: 0, fontSize: 0),
|
|
hintStyle: TextStyle(color: Colors.grey),
|
|
border: OutlineInputBorder()))),
|
|
]),
|
|
])));
|
|
}
|
|
}
|