Compare commits

..

4 Commits

Author SHA1 Message Date
chen08209
ba91fab2b5 Fix autoRun show issues
Fix Android 10 issues

Optimize ip show
2024-06-23 03:06:28 +08:00
chen08209
18add7fba3 Add intranet IP display
Add connections page

Add search in connections, requests

Add keyword search in connections, requests, logs

Add basic viewing editing capabilities

Optimize update profile
2024-06-22 03:01:07 +08:00
chen08209
0d3034f216 Update version
(cherry picked from commit afa1b4f424)
2024-06-19 13:13:16 +08:00
chen08209
313faa8cf3 Fix the problem of excessive memory usage in traffic usage.
Add lightBlue theme color

Fix start unable to update profile issues
2024-06-19 10:09:57 +08:00
61 changed files with 3415 additions and 974 deletions

View File

@@ -62,7 +62,7 @@ android {
defaultConfig {
applicationId "com.follow.clash"
minSdkVersion 24
minSdkVersion 21
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View File

@@ -23,7 +23,6 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:networkSecurityConfig="@xml/network_security_config"
android:label="FlClash">
<activity
android:name="com.follow.clash.MainActivity"

View File

@@ -64,7 +64,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
toast!!.show()
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"moveTaskToBack" -> {
@@ -151,7 +150,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
val message = call.argument<String>("message")
tip(message)
result.success(true)
}
else -> {

View File

@@ -396,6 +396,8 @@ func applyConfig(isPatch bool) {
if isPatch {
patchConfig(cfg.General)
} else {
executor.Shutdown()
runtime.GC()
hub.UltraApplyConfig(cfg, true)
}
}

View File

@@ -188,6 +188,26 @@ func getTraffic() *C.char {
return C.CString(string(data))
}
//export getTotalTraffic
func getTotalTraffic() *C.char {
up, down := statistic.DefaultManager.Total()
traffic := map[string]int64{
"up": up,
"down": down,
}
data, err := json.Marshal(traffic)
if err != nil {
fmt.Println("Error:", err)
return C.CString("")
}
return C.CString(string(data))
}
//export resetTraffic
func resetTraffic() {
statistic.DefaultManager.ResetStatistic()
}
//export asyncTestDelay
func asyncTestDelay(s *C.char, port C.longlong) {
i := int64(port)

View File

@@ -35,8 +35,6 @@ func startTUN(fd C.int) {
closer, err := t.Start(f, gateway, portal, dns)
applyConfig(true)
if err != nil {
log.Errorln("startTUN error: %v", err)
tempTun.Close()
@@ -56,7 +54,6 @@ func stopTun() {
if tun != nil {
tun.Close()
applyConfig(true)
tun = nil
}
}()

View File

@@ -46,8 +46,8 @@ class ClashCore {
bool init(String homeDir) {
return clashFFI.initClash(
homeDir.toNativeUtf8().cast(),
) ==
homeDir.toNativeUtf8().cast(),
) ==
1;
}
@@ -95,12 +95,15 @@ class ClashCore {
final proxiesRaw = clashFFI.getProxies();
final proxiesRawString = proxiesRaw.cast<Utf8>().toDartString();
return Isolate.run<List<Group>>(() {
final proxies = json.decode(proxiesRawString);
if(proxiesRawString.isEmpty) return [];
final proxies = json.decode(proxiesRawString) as Map;
if(proxies.isEmpty) return [];
final groupNames = [
UsedProxy.GLOBAL.name,
...(proxies[UsedProxy.GLOBAL.name]["all"] as List).where((e) {
final proxy = proxies[e] ?? {};
return GroupTypeExtension.valueList.contains(proxy['type']) && proxy['hidden'] != true;
return GroupTypeExtension.valueList.contains(proxy['type']) &&
proxy['hidden'] != true;
})
];
final groupsRaw = groupNames.map((groupName) {
@@ -108,7 +111,7 @@ class ClashCore {
group["all"] = ((group["all"] ?? []) as List)
.map(
(name) => proxies[name],
)
)
.toList();
return group;
}).toList();
@@ -119,14 +122,14 @@ class ClashCore {
Future<List<ExternalProvider>> getExternalProviders() {
final externalProvidersRaw = clashFFI.getExternalProviders();
final externalProvidersRawString =
externalProvidersRaw.cast<Utf8>().toDartString();
externalProvidersRaw.cast<Utf8>().toDartString();
return Isolate.run<List<ExternalProvider>>(() {
final externalProviders =
(json.decode(externalProvidersRawString) as List<dynamic>)
.map(
(item) => ExternalProvider.fromJson(item),
)
.toList();
(json.decode(externalProvidersRawString) as List<dynamic>)
.map(
(item) => ExternalProvider.fromJson(item),
)
.toList();
return externalProviders;
});
}
@@ -175,9 +178,11 @@ class ClashCore {
);
Future.delayed(httpTimeoutDuration + moreDuration, () {
receiver.close();
completer.complete(
Delay(name: proxyName, value: -1),
);
if(!completer.isCompleted){
completer.complete(
Delay(name: proxyName, value: -1),
);
}
});
return completer.future;
}
@@ -198,6 +203,16 @@ class ClashCore {
return Traffic.fromMap(trafficMap);
}
Traffic getTotalTraffic() {
final trafficRaw = clashFFI.getTotalTraffic();
final trafficMap = json.decode(trafficRaw.cast<Utf8>().toDartString());
return Traffic.fromMap(trafficMap);
}
void resetTraffic(){
clashFFI.resetTraffic();
}
void startLog() {
clashFFI.startLog();
}
@@ -222,19 +237,23 @@ class ClashCore {
clashFFI.setProcessMap(json.encode(processMapItem).toNativeUtf8().cast());
}
DateTime? getRunTime() {
final runTimeString = clashFFI.getRunTime().cast<Utf8>().toDartString();
if (runTimeString.isEmpty) return null;
return DateTime.fromMillisecondsSinceEpoch(int.parse(runTimeString));
}
// DateTime? getRunTime() {
// final runTimeString = clashFFI.getRunTime().cast<Utf8>().toDartString();
// if (runTimeString.isEmpty) return null;
// return DateTime.fromMillisecondsSinceEpoch(int.parse(runTimeString));
// }
List<Connection> getConnections() {
final connectionsDataRaw = clashFFI.getConnections();
final connectionsData =
json.decode(connectionsDataRaw.cast<Utf8>().toDartString()) as Map;
json.decode(connectionsDataRaw.cast<Utf8>().toDartString()) as Map;
final connectionsRaw = connectionsData['connections'] as List? ?? [];
return connectionsRaw.map((e) => Connection.fromJson(e)).toList();
}
closeConnections(String id) {
clashFFI.closeConnection(id.toNativeUtf8().cast());
}
}
final clashCore = ClashCore();

View File

@@ -983,6 +983,24 @@ class ClashFFI {
late final _getTraffic =
_getTrafficPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> getTotalTraffic() {
return _getTotalTraffic();
}
late final _getTotalTrafficPtr =
_lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>(
'getTotalTraffic');
late final _getTotalTraffic =
_getTotalTrafficPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
void resetTraffic() {
return _resetTraffic();
}
late final _resetTrafficPtr =
_lookup<ffi.NativeFunction<ffi.Void Function()>>('resetTraffic');
late final _resetTraffic = _resetTrafficPtr.asFunction<void Function()>();
void asyncTestDelay(
ffi.Pointer<ffi.Char> s,
int port,
@@ -1156,16 +1174,6 @@ class ClashFFI {
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Int)>>('startTUN');
late final _startTUN = _startTUNPtr.asFunction<void Function(int)>();
ffi.Pointer<ffi.Char> getRunTime() {
return _getRunTime();
}
late final _getRunTimePtr =
_lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>(
'getRunTime');
late final _getRunTime =
_getRunTimePtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
void stopTun() {
return _stopTun();
}

View File

@@ -30,12 +30,19 @@ class Navigation {
fragment: ProfilesFragment(),
),
const NavigationItem(
icon: Icon(Icons.ballot),
icon: Icon(Icons.view_timeline),
label: "requests",
fragment: RequestFragment(),
fragment: RequestsFragment(),
description: "requestsDesc",
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
),
const NavigationItem(
icon: Icon(Icons.ballot),
label: "connections",
fragment: ConnectionsFragment(),
description: "connectionsDesc",
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
),
const NavigationItem(
icon: Icon(Icons.swap_vert_circle),
label: "resources",

View File

@@ -8,12 +8,30 @@ import 'constant.dart';
class AppPath {
static AppPath? _instance;
Completer<Directory> applicationSupportDirectoryCompleter = Completer();
Completer<Directory> cacheDir = Completer();
// Future<Directory> _createDesktopCacheDir() async {
// final path = join(dirname(Platform.resolvedExecutable), 'cache');
// final dir = Directory(path);
// if (await dir.exists()) {
// await dir.create(recursive: true);
// }
// return dir;
// }
AppPath._internal() {
getApplicationSupportDirectory().then(
(value) => applicationSupportDirectoryCompleter.complete(value),
);
getApplicationSupportDirectory().then((value) {
cacheDir.complete(value);
});
// if (Platform.isAndroid) {
// getApplicationSupportDirectory().then((value) {
// cacheDir.complete(value);
// });
// } else {
// _createDesktopCacheDir().then((value) {
// cacheDir.complete(value);
// });
// }
}
factory AppPath() {
@@ -22,12 +40,12 @@ class AppPath {
}
Future<String> getHomeDirPath() async {
final directory = await applicationSupportDirectoryCompleter.future;
final directory = await cacheDir.future;
return directory.path;
}
Future<String> getProfilesPath() async {
final directory = await applicationSupportDirectoryCompleter.future;
final directory = await cacheDir.future;
return join(directory.path, profilesDirectoryName);
}

View File

@@ -2,20 +2,13 @@ import 'package:flutter/material.dart';
import 'color.dart';
extension TextStyleExtension on TextStyle {
toLight() {
return copyWith(color: color?.toLight());
}
TextStyle get toLight => copyWith(color: color?.toLight());
toLighter() {
return copyWith(color: color?.toLighter());
}
TextStyle get toLighter => copyWith(color: color?.toLighter());
TextStyle get toSoftBold => copyWith(fontWeight: FontWeight.w500);
toSoftBold() {
return copyWith(fontWeight: FontWeight.w500);
}
TextStyle get toBold => copyWith(fontWeight: FontWeight.bold);
toBold() {
return copyWith(fontWeight: FontWeight.bold);
}
}
TextStyle get toMinus => copyWith(fontSize: fontSize! - 1);
}

View File

@@ -31,6 +31,7 @@ class AppController {
Future<void> updateSystemProxy(bool isStart) async {
if (isStart) {
await globalState.startSystemProxy(
appState: appState,
config: config,
clashConfig: clashConfig,
);
@@ -42,7 +43,9 @@ class AppController {
];
} else {
await globalState.stopSystemProxy();
clashCore.resetTraffic();
appState.traffics = [];
appState.totalTraffic = Traffic();
appState.runTime = null;
}
}
@@ -97,13 +100,9 @@ class AppController {
}
}
Future<void> updateProfile(String id) async {
final profile = config.getCurrentProfileForId(id);
if (profile != null) {
final tempProfile = profile.copyWith();
await tempProfile.update();
config.setProfile(tempProfile);
}
Future<void> updateProfile(Profile profile) async {
await profile.update();
config.setProfile(await profile.update());
}
Future<void> updateClashConfig({bool isPatch = true}) async {
@@ -146,7 +145,7 @@ class AppController {
continue;
}
try {
await updateProfile(profile.id);
updateProfile(profile);
} catch (e) {
appState.addLog(
Log(
@@ -163,7 +162,7 @@ class AppController {
if (profile.type == ProfileType.file) {
continue;
}
await updateProfile(profile.id);
await updateProfile(profile);
}
}
@@ -267,6 +266,7 @@ class AppController {
}
init() async {
updateLogStatus();
if (!config.silentLaunch) {
window?.show();
}
@@ -278,7 +278,7 @@ class AppController {
config: config,
clashConfig: clashConfig,
);
},title: appLocalizations.init);
}, title: appLocalizations.init);
} else {
await globalState.applyProfile(
appState: appState,
@@ -290,14 +290,13 @@ class AppController {
}
afterInit() async {
if (config.autoRun) {
await proxyManager.updateStartTime();
if (proxyManager.isStart) {
await updateSystemProxy(true);
} else {
await proxyManager.updateStartTime();
await updateSystemProxy(proxyManager.isStart);
await updateSystemProxy(config.autoRun);
}
autoUpdateProfiles();
updateLogStatus();
autoCheckUpdate();
}
@@ -366,11 +365,9 @@ class AppController {
if (commonScaffoldState?.mounted != true) return;
final profile = await commonScaffoldState?.loadingRun<Profile>(
() async {
final profile = Profile(
return await Profile.normal(
url: url,
);
await profile.update();
return profile;
).update();
},
title: "${appLocalizations.add}${appLocalizations.profile}",
);
@@ -393,9 +390,7 @@ class AppController {
if (bytes == null) {
return null;
}
final profile = Profile(label: platformFile?.name);
await profile.saveFile(bytes);
return profile;
return await Profile.normal(label: platformFile?.name).saveFile(bytes);
},
title: "${appLocalizations.add}${appLocalizations.profile}",
);

View File

@@ -64,3 +64,8 @@ enum RecoveryOption {
all,
onlyProfiles,
}
enum ChipType {
action,
delete,
}

View File

@@ -35,6 +35,13 @@ class _AccessFragmentState extends State<AccessFragment> {
});
}
@override
void dispose() {
super.dispose();
packagesListenable.dispose();
}
Widget _buildAppProxyModePopup() {
final items = [
CommonPopupMenuItem(

View File

@@ -228,6 +228,13 @@ class _WebDAVFormDialogState extends State<WebDAVFormDialog> {
Navigator.pop(context);
}
@override
void dispose() {
super.dispose();
_obscureController.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(

View File

@@ -1,140 +1,435 @@
// import 'dart:async';
//
// import 'package:fl_clash/clash/core.dart';
// import 'package:fl_clash/models/models.dart';
// import 'package:fl_clash/plugins/app.dart';
// import 'package:fl_clash/state.dart';
// import 'package:fl_clash/widgets/widgets.dart';
// import 'package:flutter/material.dart';
//
// class ConnectionsFragment extends StatefulWidget {
// const ConnectionsFragment({super.key});
//
// @override
// State<ConnectionsFragment> createState() => _ConnectionsFragmentState();
// }
//
// class _ConnectionsFragmentState extends State<ConnectionsFragment> {
// final connectionsNotifier = ValueNotifier<List<Connection>>([]);
// Map<String, String?> idPackageNameMap = {};
//
// Timer? timer;
//
// @override
// void initState() {
// super.initState();
// WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
// _getConnections();
// if (timer != null) {
// timer?.cancel();
// timer = null;
// }
// timer = Timer.periodic(const Duration(seconds: 3), (timer) {
// if (mounted) {
// _getConnections();
// }
// });
// });
// }
//
// _getConnections() {
// connectionsNotifier.value = clashCore
// .getConnections();
// }
//
// @override
// void dispose() {
// super.dispose();
// timer?.cancel();
// timer = null;
// }
//
// Future<ImageProvider?> _getPackageIconWithConnection(
// Connection connection) async {
// final uid = connection.metadata.uid;
// // if(globalState.packageNameMap[uid] == null){
// // globalState.packageNameMap[uid] = await app?.getPackageName(connection.metadata);
// // }
// final packageName = globalState.packageNameMap[uid];
// if(packageName == null) return null;
// return await app?.getPackageIcon(packageName);
// }
//
// @override
// Widget build(BuildContext context) {
// return ValueListenableBuilder<List<Connection>>(
// valueListenable: connectionsNotifier,
// builder: (_, List<Connection> connections, __) {
// if (connections.isEmpty) {
// return const NullStatus(
// label: "未开启代理,或者没有连接数据",
// );
// }
// return ListView.separated(
// physics: const AlwaysScrollableScrollPhysics(),
// itemBuilder: (_, index) {
// final connection = connections[index];
// return ListTile(
// titleAlignment: ListTileTitleAlignment.top,
// leading: Container(
// margin: const EdgeInsets.only(top: 4),
// width: 48,
// height: 48,
// child: FutureBuilder<ImageProvider?>(
// future: _getPackageIconWithConnection(connection),
// builder: (_, snapshot) {
// if (!snapshot.hasData && snapshot.data == null) {
// return Container();
// } else {
// return Image(
// image: snapshot.data!,
// gaplessPlayback: true,
// width: 48,
// height: 48,
// );
// }
// },
// ),
// ),
// contentPadding:
// const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
// title: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(connection.metadata.host.isNotEmpty
// ? connection.metadata.host
// : connection.metadata.destinationIP),
// Padding(
// padding: const EdgeInsets.only(
// top: 12,
// ),
// child: Wrap(
// runSpacing: 8,
// spacing: 8,
// children: [
// for (final chain in connection.chains)
// CommonChip(
// label: chain,
// ),
// ],
// ),
// ),
// ],
// ),
// trailing: IconButton(
// icon: const Icon(Icons.block),
// onPressed: () {},
// ),
// );
// },
// separatorBuilder: (BuildContext context, int index) {
// return const Divider(
// height: 0,
// );
// },
// itemCount: connections.length,
// );
// },
// );
// }
// }
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ConnectionsFragment extends StatefulWidget {
const ConnectionsFragment({super.key});
@override
State<ConnectionsFragment> createState() => _ConnectionsFragmentState();
}
class _ConnectionsFragmentState extends State<ConnectionsFragment> {
final connectionsNotifier =
ValueNotifier<ConnectionsAndKeywords>(const ConnectionsAndKeywords());
final ScrollController _scrollController = ScrollController(
keepScrollOffset: false,
);
Timer? timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
},
);
});
}
_initActions() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
showSearch(
context: context,
delegate: ConnectionsSearchDelegate(
state: connectionsNotifier.value,
),
);
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
)
];
},
);
}
_addKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..add(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..remove(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_handleBlockConnection(String id) {
clashCore.closeConnections(id);
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
}
@override
void dispose() {
super.dispose();
timer?.cancel();
connectionsNotifier.dispose();
_scrollController.dispose();
timer = null;
}
@override
Widget build(BuildContext context) {
return Selector<AppState, bool?>(
selector: (_, appState) =>
appState.currentLabel == 'connections' ||
appState.viewMode == ViewMode.mobile &&
appState.currentLabel == "tools",
builder: (_, isCurrent, child) {
if (isCurrent == null || isCurrent) {
_initActions();
}
return child!;
},
child: ValueListenableBuilder<ConnectionsAndKeywords>(
valueListenable: connectionsNotifier,
builder: (_, state, __) {
var connections = state.filteredConnections;
if (connections.isEmpty) {
return NullStatus(
label: appLocalizations.nullConnectionsDesc,
);
}
connections = connections.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: _scrollController,
itemBuilder: (_, index) {
final connection = connections[index];
return ConnectionItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
onBlock: _handleBlockConnection,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
),
)
],
);
},
),
);
}
}
class ConnectionItem extends StatelessWidget {
final Connection connection;
final Function(String)? onClick;
final Function(String)? onBlock;
const ConnectionItem({
super.key,
required this.connection,
this.onClick,
this.onBlock,
});
Future<ImageProvider?> _getPackageIcon(Connection connection) async {
return await app?.getPackageIcon(connection.metadata.process);
}
String _getRequestText(Metadata metadata) {
var text = "${metadata.network}:://";
final ips = [
metadata.host,
metadata.destinationIP,
].where((ip) => ip.isNotEmpty);
text += ips.join("/");
text += ":${metadata.destinationPort}";
return text;
}
String _getSourceText(Connection connection) {
final metadata = connection.metadata;
if (metadata.process.isEmpty) {
return connection.start.lastUpdateTimeDesc;
}
return "${metadata.process} · ${connection.start.lastUpdateTimeDesc}";
}
@override
Widget build(BuildContext context) {
return ListItem(
tileTitleAlignment: ListTileTitleAlignment.titleHeight,
leading: Platform.isAndroid
? Container(
margin: const EdgeInsets.only(top: 4),
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIcon(connection),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
},
),
)
: null,
title: Text(
_getRequestText(connection.metadata),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 12,
),
Text(
_getSourceText(connection),
),
const SizedBox(
height: 12,
),
Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final chain in connection.chains)
CommonChip(
label: chain,
onPressed: () {
if (onClick == null) return;
onClick!(chain);
},
),
],
),
const SizedBox(
height: 12,
),
],
),
trailing: IconButton(
icon: const Icon(Icons.block),
onPressed: () {
if (onBlock == null) return;
onBlock!(connection.id);
},
),
);
}
}
class ConnectionsSearchDelegate extends SearchDelegate {
ValueNotifier<ConnectionsAndKeywords> connectionsNotifier;
ConnectionsSearchDelegate({
required ConnectionsAndKeywords state,
}) : connectionsNotifier = ValueNotifier<ConnectionsAndKeywords>(state);
get state => connectionsNotifier.value;
List<Connection> get _results {
final lowerQuery = query.toLowerCase().trim();
return connectionsNotifier.value.filteredConnections.where((request) {
final lowerNetwork = request.metadata.network.toLowerCase();
final lowerHost = request.metadata.host.toLowerCase();
final lowerDestinationIP = request.metadata.destinationIP.toLowerCase();
final lowerProcess = request.metadata.process.toLowerCase();
final lowerChains = request.chains.join("").toLowerCase();
return lowerNetwork.contains(lowerQuery) ||
lowerHost.contains(lowerQuery) ||
lowerDestinationIP.contains(lowerQuery) ||
lowerProcess.contains(lowerQuery) ||
lowerChains.contains(lowerQuery);
}).toList();
}
_addKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..add(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = connectionsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(connectionsNotifier.value.keywords)
..remove(keyword);
connectionsNotifier.value = connectionsNotifier.value.copyWith(
keywords: keywords,
);
}
_handleBlockConnection(String id) {
clashCore.closeConnections(id);
connectionsNotifier.value = connectionsNotifier.value
.copyWith(connections: clashCore.getConnections());
}
@override
List<Widget>? buildActions(BuildContext context) {
return [
IconButton(
onPressed: () {
if (query.isEmpty) {
close(context, null);
return;
}
query = '';
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(
onPressed: () {
close(context, null);
},
icon: const Icon(Icons.arrow_back),
);
}
@override
Widget buildResults(BuildContext context) {
return buildSuggestions(context);
}
@override
void dispose() {
connectionsNotifier.dispose();
super.dispose();
}
@override
Widget buildSuggestions(BuildContext context) {
return ValueListenableBuilder(
valueListenable: connectionsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final connection = _results[index];
return ConnectionItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
onBlock: _handleBlockConnection,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
)
],
);
},
);
}
}

View File

@@ -31,7 +31,7 @@ class CoreInfo extends StatelessWidget {
style: context
.textTheme
.titleMedium
?.toSoftBold(),
?.toSoftBold,
),
),
const SizedBox(
@@ -44,7 +44,7 @@ class CoreInfo extends StatelessWidget {
style: context
.textTheme
.titleLarge
?.toSoftBold(),
?.toSoftBold,
),
),
],

View File

@@ -1,11 +1,11 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/fragments/dashboard/intranet_ip.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/material.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:provider/provider.dart';
import 'network_detection.dart';
import 'core_info.dart';
import 'outbound_mode.dart';
import 'start_button.dart';
import 'network_speed.dart';
@@ -56,7 +56,7 @@ class _DashboardFragmentState extends State<DashboardFragment> {
),
GridItem(
crossAxisCellCount: isDesktop ? 4 : 6,
child: const CoreInfo(),
child: const IntranetIp(),
),
],
);

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
class IntranetIp extends StatefulWidget {
const IntranetIp({super.key});
@override
State<IntranetIp> createState() => _IntranetIpState();
}
class _IntranetIpState extends State<IntranetIp> {
final ipNotifier = ValueNotifier<String>("");
Future<String?> getLocalIpAddress() async {
List<NetworkInterface> interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final address in interface.addresses) {
if (!address.isLoopback) {
return address.address;
}
}
}
return null;
}
@override
void dispose() {
super.dispose();
ipNotifier.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
ipNotifier.value = await getLocalIpAddress() ?? "";
});
}
@override
Widget build(BuildContext context) {
return CommonCard(
info: Info(
label: appLocalizations.intranetIp,
iconData: Icons.devices,
),
child: Container(
padding: const EdgeInsets.all(16).copyWith(top: 0),
height: globalState.appController.measure.titleLargeHeight + 24 - 1,
child: ValueListenableBuilder(
valueListenable: ipNotifier,
builder: (_, value, __) {
return FadeBox(
child: value.isNotEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
flex: 1,
child: TooltipText(
text: Text(
value,
style: context.textTheme.titleLarge?.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
)
: const Padding(
padding: EdgeInsets.all(2),
child: AspectRatio(
aspectRatio: 1,
child: CircularProgressIndicator(),
),
),
);
},
),
),
);
}
}

View File

@@ -19,6 +19,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
final timeoutNotifier = ValueNotifier<bool>(false);
bool? _preIsStart;
CancelToken? cancelToken;
Function? _checkIpDebounce;
_checkIp(
bool isInit,
@@ -44,6 +45,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
}
_checkIpContainer(Widget child) {
_checkIpDebounce = debounce(_checkIp);
return Selector2<AppState, Config, CheckIpSelectorState>(
selector: (_, appState, config) {
return CheckIpSelectorState(
@@ -53,13 +55,22 @@ class _NetworkDetectionState extends State<NetworkDetection> {
);
},
builder: (_, state, __) {
_checkIp(state.isInit, state.isStart);
if (_checkIpDebounce != null) {
_checkIpDebounce!([state.isInit, state.isStart]);
}
return child;
},
child: child,
);
}
@override
void dispose() {
super.dispose();
ipInfoNotifier.dispose();
timeoutNotifier.dispose();
}
@override
Widget build(BuildContext context) {
return _checkIpContainer(
@@ -124,7 +135,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
),
Container(
height:
globalState.appController.measure.titleLargeHeight + 24,
globalState.appController.measure.titleLargeHeight + 24 - 1,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.all(16).copyWith(top: 0),
child: FadeBox(
@@ -139,7 +150,7 @@ class _NetworkDetectionState extends State<NetworkDetection> {
text: Text(
ipInfo.ip,
style: context.textTheme.titleLarge
?.toSoftBold(),
?.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -153,9 +164,9 @@ class _NetworkDetectionState extends State<NetworkDetection> {
if (timeout) {
return Text(
"timeout",
style: context.textTheme.titleMedium
style: context.textTheme.titleLarge
?.copyWith(color: Colors.red)
.toSoftBold(),
.toSoftBold.toMinus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);

View File

@@ -21,26 +21,17 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
.asMap()
.map(
(index, e) => MapEntry(
index,
Point(
(index + initPoints.length).toDouble(),
e.speed.toDouble(),
),
),
)
index,
Point(
(index + initPoints.length).toDouble(),
e.speed.toDouble(),
),
),
)
.values
.toList();
var pointsRaw = [...initPoints, ...trafficPoints];
List<Point> points;
if (pointsRaw.length > 60) {
points = pointsRaw
.getRange(pointsRaw.length - 61, pointsRaw.length - 1)
.toList();
} else {
points = pointsRaw;
}
return points;
return [...initPoints, ...trafficPoints];
}
Traffic _getLastTraffic(List<Traffic> traffics) {
@@ -53,12 +44,11 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
required IconData iconData,
required TrafficValue value,
}) {
final showValue = value.showValue;
final showUnit = "${value.showUnit}/s";
final titleLargeSoftBold =
Theme.of(context).textTheme.titleLarge?.toSoftBold();
final bodyMedium = Theme.of(context).textTheme.bodySmall?.toLight();
Theme.of(context).textTheme.titleLarge?.toSoftBold;
final bodyMedium = Theme.of(context).textTheme.bodySmall?.toLight;
final valueText = Text(
showValue,
style: titleLargeSoftBold,
@@ -85,7 +75,7 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.toSoftBold(),
style: Theme.of(context).textTheme.titleSmall?.toSoftBold,
),
),
],
@@ -121,7 +111,7 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
@override
Widget build(BuildContext context) {
return CommonCard(
info: Info(
info: Info(
label: appLocalizations.networkSpeed,
iconData: Icons.speed,
),
@@ -172,4 +162,4 @@ class _NetworkSpeedState extends State<NetworkSpeed> {
),
);
}
}
}

View File

@@ -67,7 +67,7 @@ class OutboundMode extends StatelessWidget {
.of(context)
.textTheme
.titleMedium
?.toSoftBold(),
?.toSoftBold,
),
),
],

View File

@@ -13,19 +13,17 @@ class StartButton extends StatefulWidget {
class _StartButtonState extends State<StartButton>
with SingleTickerProviderStateMixin {
bool isStart = false;
bool isInit = false;
late AnimationController _controller;
bool isStart = false;
@override
void initState() {
isStart = globalState.appController.appState.isStart;
super.initState();
_controller = AnimationController(
vsync: this,
value: isStart ? 1 : 0,
value: 0,
duration: const Duration(milliseconds: 200),
);
super.initState();
}
@override
@@ -35,9 +33,12 @@ class _StartButtonState extends State<StartButton>
}
handleSwitchStart() {
isStart = !isStart;
updateController();
updateSystemProxy();
final appController = globalState.appController;
if (isStart == appController.appState.isStart) {
isStart = !isStart;
updateController();
appController.updateSystemProxy(isStart);
}
}
updateController() {
@@ -48,11 +49,18 @@ class _StartButtonState extends State<StartButton>
}
}
updateSystemProxy() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final appController = globalState.appController;
await appController.updateSystemProxy(isStart);
});
Widget _updateControllerContainer(Widget child) {
return Selector<AppState, bool>(
selector: (_, appState) => appState.isStart,
builder: (_, isStart, child) {
if(isStart != this.isStart){
this.isStart = isStart;
updateController();
}
return child!;
},
child: child,
);
}
@override
@@ -72,8 +80,7 @@ class _StartButtonState extends State<StartButton>
other.getTimeDifference(
DateTime.now(),
),
style:
Theme.of(context).textTheme.titleMedium?.toSoftBold(),
style: Theme.of(context).textTheme.titleMedium?.toSoftBold,
),
)
.width +
@@ -119,24 +126,14 @@ class _StartButtonState extends State<StartButton>
child: child,
);
},
child: Selector<AppState, bool>(
selector: (_, appState) => appState.runTime != null,
builder: (_, isRun, child) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isStart != isRun) {
isStart = isRun;
updateController();
}
});
return child!;
},
child: Selector<AppState, int?>(
child: _updateControllerContainer(
Selector<AppState, int?>(
selector: (_, appState) => appState.runTime,
builder: (_, int? value, __) {
final text = other.getTimeText(value);
return Text(
text,
style: Theme.of(context).textTheme.titleMedium?.toSoftBold(),
style: Theme.of(context).textTheme.titleMedium?.toSoftBold,
);
},
),

View File

@@ -42,7 +42,7 @@ class TrafficUsage extends StatelessWidget {
),
Text(
trafficValue.showUnit,
style: context.textTheme.labelMedium?.toLight(),
style: context.textTheme.labelMedium?.toLight,
),
],
);
@@ -55,21 +55,11 @@ class TrafficUsage extends StatelessWidget {
label: appLocalizations.trafficUsage,
iconData: Icons.data_saver_off,
),
child: Selector<AppState, List<Traffic>>(
selector: (_, appState) => appState.traffics,
builder: (_, traffics, __) {
final trafficTotal = traffics.isNotEmpty
? traffics.reduce(
(value, element) {
return Traffic(
up: element.up.value + value.up.value,
down: element.down.value + value.down.value,
);
},
)
: Traffic();
final upTrafficValue = trafficTotal.up;
final downTrafficValue = trafficTotal.down;
child: Selector<AppState, Traffic>(
selector: (_, appState) => appState.totalTraffic,
builder: (_, totalTraffic, __) {
final upTotalTrafficValue = totalTraffic.up;
final downTotalTrafficValue = totalTraffic.down;
return Padding(
padding: const EdgeInsets.all(16).copyWith(top: 0),
child: Column(
@@ -80,7 +70,7 @@ class TrafficUsage extends StatelessWidget {
child: getTrafficDataItem(
context,
Icons.arrow_upward,
upTrafficValue,
upTotalTrafficValue,
),
),
const SizedBox(
@@ -91,7 +81,7 @@ class TrafficUsage extends StatelessWidget {
child: getTrafficDataItem(
context,
Icons.arrow_downward,
downTrafficValue,
downTotalTrafficValue,
),
),
],

View File

@@ -1,4 +1,4 @@
export 'proxies.dart';
export 'proxies/proxies.dart';
export 'dashboard/dashboard.dart';
export 'tools.dart';
export 'profiles/profiles.dart';

View File

@@ -17,7 +17,12 @@ class LogsFragment extends StatefulWidget {
}
class _LogsFragmentState extends State<LogsFragment> {
final logsNotifier = ValueNotifier<List<Log>>([]);
final logsNotifier = ValueNotifier<LogsAndKeywords>(const LogsAndKeywords());
final scrollController = ScrollController(
keepScrollOffset: false,
);
List<GlobalObjectKey<_LogItemState>> keys = [];
Timer? timer;
@override
@@ -25,18 +30,18 @@ class _LogsFragmentState extends State<LogsFragment> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
logsNotifier.value = List<Log>.from(appState.logs);
logsNotifier.value = logsNotifier.value.copyWith(logs: appState.logs);
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
final logs = List<Log>.from(appState.logs);
final logs = appState.logs;
if (!const ListEquality<Log>().equals(
logsNotifier.value,
logsNotifier.value.logs,
logs,
)) {
logsNotifier.value = logs;
logsNotifier.value = logsNotifier.value.copyWith(logs: logs);
}
});
});
@@ -46,6 +51,8 @@ class _LogsFragmentState extends State<LogsFragment> {
void dispose() {
super.dispose();
timer?.cancel();
logsNotifier.dispose();
scrollController.dispose();
timer = null;
}
@@ -59,7 +66,7 @@ class _LogsFragmentState extends State<LogsFragment> {
showSearch(
context: context,
delegate: LogsSearchDelegate(
logs: logsNotifier.value.reversed.toList(),
logs: logsNotifier.value,
),
);
},
@@ -72,32 +79,23 @@ class _LogsFragmentState extends State<LogsFragment> {
});
}
_buildList() {
return ValueListenableBuilder<List<Log>>(
valueListenable: logsNotifier,
builder: (_, List<Log> logs, __) {
if (logs.isEmpty) {
return NullStatus(
label: appLocalizations.nullLogsDesc,
);
}
logs = logs.reversed.toList();
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: logs.length,
itemBuilder: (BuildContext context, int index) {
final log = logs[index];
return LogItem(
log: log,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
);
},
_addKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)
..add(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)
..remove(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
@@ -114,21 +112,88 @@ class _LogsFragmentState extends State<LogsFragment> {
}
return child!;
},
child: _buildList(),
child: ValueListenableBuilder<LogsAndKeywords>(
valueListenable: logsNotifier,
builder: (_, state, __) {
var logs = state.filteredLogs;
if (logs.isEmpty) {
return NullStatus(
label: appLocalizations.nullLogsDesc,
);
}
logs = logs.reversed.toList();
keys = logs
.map((log) => GlobalObjectKey<_LogItemState>(log.dateTime))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: scrollController,
itemBuilder: (_, index) {
final log = logs[index];
return LogItem(
key: Key(log.dateTime.toString()),
log: log,
onClick: _addKeyword,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: logs.length,
),
)
],
);
},
),
);
}
}
class LogsSearchDelegate extends SearchDelegate {
List<Log> logs = [];
ValueNotifier<LogsAndKeywords> logsNotifier;
LogsSearchDelegate({
required this.logs,
});
required LogsAndKeywords logs,
}) : logsNotifier = ValueNotifier(logs);
@override
void dispose() {
super.dispose();
logsNotifier.dispose();
}
get state => logsNotifier.value;
List<Log> get _results {
final lowQuery = query.toLowerCase();
return logs
return logsNotifier.value.filteredLogs
.where(
(log) =>
(log.payload?.toLowerCase().contains(lowQuery) ?? false) ||
@@ -171,37 +236,98 @@ class LogsSearchDelegate extends SearchDelegate {
return buildSuggestions(context);
}
_addKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..add(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = logsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(logsNotifier.value.keywords)..remove(keyword);
logsNotifier.value = logsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
Widget buildSuggestions(BuildContext context) {
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _results.length,
itemBuilder: (BuildContext context, int index) {
final log = _results[index];
return LogItem(
key: ValueKey(log.dateTime),
log: log,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
return ValueListenableBuilder(
valueListenable: logsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final log = _results[index];
return LogItem(
key: Key(log.dateTime.toString()),
log: log,
onClick: (value) {
_addKeyword(value);
},
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
)
],
);
},
);
}
}
class LogItem extends StatelessWidget {
class LogItem extends StatefulWidget {
final Log log;
final Function(String)? onClick;
const LogItem({
super.key,
required this.log,
this.onClick,
});
@override
State<LogItem> createState() => _LogItemState();
}
class _LogItemState extends State<LogItem> {
@override
Widget build(BuildContext context) {
final log = widget.log;
return ListTile(
title: SelectableText(log.payload ?? ''),
subtitle: Column(
@@ -223,6 +349,10 @@ class LogItem extends StatelessWidget {
vertical: 8,
),
child: CommonChip(
onPressed: () {
if (widget.onClick == null) return;
widget.onClick!(log.logLevel.name);
},
label: log.logLevel.name,
),
),

View File

@@ -91,6 +91,7 @@ class _URLFormDialogState extends State<URLFormDialog> {
runSpacing: 16,
children: [
TextField(
maxLines: null,
controller: urlController,
decoration: InputDecoration(
border: const OutlineInputBorder(),

View File

@@ -41,19 +41,26 @@ class _EditProfileState extends State<EditProfile> {
_handleConfirm() {
if (!_formKey.currentState!.validate()) return;
final config = widget.context.read<Config>();
final hasUpdate = urlController.text.isNotEmpty && widget.profile.url != urlController.text;
widget.profile.url = urlController.text;
widget.profile.label = labelController.text;
widget.profile.autoUpdate = autoUpdate;
widget.profile.autoUpdateDuration =
Duration(minutes: int.parse(autoUpdateDurationController.text));
config.setProfile(widget.profile);
final profile = widget.profile.copyWith(
url: urlController.text,
label: labelController.text,
autoUpdate: autoUpdate,
autoUpdateDuration: Duration(
minutes: int.parse(
autoUpdateDurationController.text,
),
),
);
final hasUpdate = widget.profile.url != profile.url;
config.setProfile(profile);
if (hasUpdate) {
widget.context.findAncestorStateOfType<CommonScaffoldState>()?.loadingRun(
() => globalState.appController.updateProfile(
widget.profile.id,
),
);
globalState.homeScaffoldKey.currentState?.loadingRun(
() async {
if (hasUpdate) {
await globalState.appController.updateProfile(profile);
}
},
);
}
Navigator.of(context).pop();
}
@@ -83,12 +90,11 @@ class _EditProfileState extends State<EditProfile> {
},
),
),
if (widget.profile.type == ProfileType.url)...[
if (widget.profile.type == ProfileType.url) ...[
ListItem(
title: TextFormField(
controller: urlController,
minLines: 1,
maxLines: 2,
maxLines: null,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: appLocalizations.url,

View File

@@ -1,5 +1,6 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/fragments/profiles/edit_profile.dart';
import 'package:fl_clash/fragments/profiles/view_profile.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
@@ -15,6 +16,7 @@ enum ProfileActions {
edit,
update,
delete,
view,
}
class ProfilesFragment extends StatefulWidget {
@@ -27,6 +29,8 @@ class ProfilesFragment extends StatefulWidget {
class _ProfilesFragmentState extends State<ProfilesFragment> {
final hasPadding = ValueNotifier<bool>(false);
List<GlobalObjectKey<_ProfileItemState>> profileItemKeys = [];
_handleShowAddExtendPage() {
showExtendPage(
globalState.navigatorKey.currentState!.context,
@@ -48,19 +52,22 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
}
}
_updateProfiles() async {
final updateProfiles = profileItemKeys.map<Future>(
(key) async => await key.currentState?.updateProfile(false));
final result = await Future.wait(updateProfiles);
}
_initScaffoldState() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
if (!context.mounted) return;
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
commonScaffoldState.loadingRun<void>(
() async {
await globalState.appController.updateProfiles();
},
);
_updateProfiles();
},
icon: const Icon(Icons.sync),
),
@@ -79,6 +86,12 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
);
}
@override
void dispose() {
super.dispose();
hasPadding.dispose();
}
@override
Widget build(BuildContext context) {
return Selector<AppState, bool>(
@@ -101,6 +114,9 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
label: appLocalizations.nullProfileDesc,
);
}
profileItemKeys = state.profiles
.map((profile) => GlobalObjectKey<_ProfileItemState>(profile.id))
.toList();
final columns = _getColumns(state.viewMode);
final isMobile = state.viewMode == ViewMode.mobile;
return Align(
@@ -132,10 +148,11 @@ class _ProfilesFragmentState extends State<ProfilesFragment> {
crossAxisSpacing: 16,
crossAxisCount: columns,
children: [
for (final profile in state.profiles)
for (int i = 0; i < state.profiles.length; i++)
GridItem(
child: ProfileItem(
profile: profile,
key: profileItemKeys[i],
profile: state.profiles[i],
groupValue: state.currentProfileId,
onChanged:
globalState.appController.changeProfile,
@@ -173,42 +190,51 @@ class ProfileItem extends StatefulWidget {
class _ProfileItemState extends State<ProfileItem> {
final isUpdating = ValueNotifier<bool>(false);
_handleDeleteProfile(String id) async {
globalState.appController.deleteProfile(id);
_handleDeleteProfile() async {
globalState.appController.deleteProfile(widget.profile.id);
}
_handleUpdateProfile(String id) async {
_handleUpdateProfile() async {
await globalState.safeRun<void>(updateProfile);
}
Future updateProfile([isSingle = true]) async {
isUpdating.value = true;
await globalState.safeRun<void>(() async {
await globalState.appController.updateProfile(id);
});
try {
await globalState.appController.updateProfile(widget.profile);
} catch (e) {
isUpdating.value = false;
if (!isSingle) {
return e.toString();
}
}
isUpdating.value = false;
return null;
}
_handleShowEditExtendPage(
Profile profile,
) {
_handleShowEditExtendPage() {
showExtendPage(
context,
body: EditProfile(
profile: profile.copyWith(),
profile: widget.profile,
context: context,
),
title: "${appLocalizations.edit}${appLocalizations.profile}",
);
}
_handleViewProfile() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ViewProfile(
profile: widget.profile,
),
),
);
}
_buildTitle(Profile profile) {
final textTheme = context.textTheme;
final userInfo = profile.userInfo ?? UserInfo();
final use = userInfo.upload + userInfo.download;
final total = userInfo.total;
final useShow = TrafficValue(value: use).show;
final totalShow = TrafficValue(value: total).show;
final progress = total == 0 ? 0.0 : use / total;
final expireShow = userInfo.expire == 0
? "长期有效"
: DateTime.fromMillisecondsSinceEpoch(userInfo.expire * 1000).show;
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
@@ -227,53 +253,141 @@ class _ProfileItemState extends State<ProfileItem> {
),
Text(
profile.lastUpdateDate?.lastUpdateTimeDesc ?? '',
style: textTheme.labelMedium?.toLight(),
style: textTheme.labelMedium?.toLight,
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.symmetric(
vertical: 8,
Builder(builder: (context) {
final userInfo = profile.userInfo ?? const UserInfo();
final use = userInfo.upload + userInfo.download;
final total = userInfo.total;
final useShow = TrafficValue(value: use).show;
final totalShow = TrafficValue(value: total).show;
final progress = total == 0 ? 0.0 : use / total;
final expireShow = userInfo.expire == 0
? appLocalizations.infiniteTime
: DateTime.fromMillisecondsSinceEpoch(userInfo.expire * 1000)
.show;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.symmetric(
vertical: 8,
),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
),
),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
Text(
"$useShow / $totalShow",
style: textTheme.labelMedium?.toLight,
),
),
Text(
"$useShow / $totalShow",
style: textTheme.labelMedium?.toLight(),
),
const SizedBox(
height: 2,
),
Row(
children: [
Text(
"到期时间:",
style: textTheme.labelMedium?.toLighter(),
),
const SizedBox(
width: 4,
),
Text(
expireShow,
style: textTheme.labelMedium?.toLighter(),
),
],
)
],
),
const SizedBox(
height: 2,
),
Row(
children: [
Text(
appLocalizations.expirationTime,
style: textTheme.labelMedium?.toLighter,
),
const SizedBox(
width: 4,
),
Text(
expireShow,
style: textTheme.labelMedium?.toLighter,
),
],
)
],
);
// final child = switch (userInfo != null) {
// true => () {
// final use = userInfo!.upload + userInfo.download;
// final total = userInfo.total;
// final useShow = TrafficValue(value: use).show;
// final totalShow = TrafficValue(value: total).show;
// final progress = total == 0 ? 0.0 : use / total;
// final expireShow = userInfo.expire == 0
// ? appLocalizations.infiniteTime
// : DateTime.fromMillisecondsSinceEpoch(
// userInfo.expire * 1000)
// .show;
// return Column(
// mainAxisSize: MainAxisSize.min,
// crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Container(
// margin: const EdgeInsets.symmetric(
// vertical: 8,
// ),
// child: LinearProgressIndicator(
// minHeight: 6,
// value: progress,
// ),
// ),
// Text(
// "$useShow / $totalShow",
// style: textTheme.labelMedium?.toLight(),
// ),
// const SizedBox(
// height: 2,
// ),
// Row(
// children: [
// Text(
// appLocalizations.expirationTime,
// style: textTheme.labelMedium?.toLighter(),
// ),
// const SizedBox(
// width: 4,
// ),
// Text(
// expireShow,
// style: textTheme.labelMedium?.toLighter(),
// ),
// ],
// )
// ],
// );
// }(),
// false => Column(
// children: [
// Padding(
// padding: const EdgeInsets.only(top: 8),
// child: CommonChip(
// onPressed: _handleViewProfile,
// avatar: const Icon(Icons.remove_red_eye),
// label: appLocalizations.view,
// ),
// ),
// ],
// ),
// };
// final measure = globalState.appController.measure;
// final height = 6 + 8 * 2 + 2 + measure.labelMediumHeight * 2;
// return SizedBox(
// height: height,
// child: child,
// );
}),
],
),
);
}
@override
void dispose() {
isUpdating.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = widget.profile;
@@ -298,40 +412,63 @@ class _ProfileItemState extends State<ProfileItem> {
onChanged: onChanged,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
trailing: CommonPopupMenu<ProfileActions>(
items: [
CommonPopupMenuItem(
action: ProfileActions.edit,
label: appLocalizations.edit,
iconData: Icons.edit,
),
if (profile.type == ProfileType.url)
CommonPopupMenuItem(
action: ProfileActions.update,
label: appLocalizations.update,
iconData: Icons.sync,
),
CommonPopupMenuItem(
action: ProfileActions.delete,
label: appLocalizations.delete,
iconData: Icons.delete,
),
],
onSelected: (ProfileActions? action) async {
switch (action) {
case ProfileActions.edit:
_handleShowEditExtendPage(profile);
break;
case ProfileActions.delete:
_handleDeleteProfile(profile.id);
break;
case ProfileActions.update:
_handleUpdateProfile(profile.id);
break;
case null:
break;
}
},
trailing: SizedBox(
height: 48,
width: 48,
child: ValueListenableBuilder(
valueListenable: isUpdating,
builder: (_, isUpdating, ___) {
return FadeBox(
child: isUpdating
? const Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: CommonPopupMenu<ProfileActions>(
items: [
CommonPopupMenuItem(
action: ProfileActions.edit,
label: appLocalizations.edit,
iconData: Icons.edit,
),
if (profile.type == ProfileType.url)
CommonPopupMenuItem(
action: ProfileActions.update,
label: appLocalizations.update,
iconData: Icons.sync,
),
CommonPopupMenuItem(
action: ProfileActions.delete,
label: appLocalizations.delete,
iconData: Icons.delete,
),
CommonPopupMenuItem(
action: ProfileActions.view,
label: "查看",
iconData: Icons.visibility,
),
],
onSelected: (ProfileActions? action) async {
switch (action) {
case ProfileActions.edit:
_handleShowEditExtendPage();
break;
case ProfileActions.delete:
_handleDeleteProfile();
break;
case ProfileActions.update:
_handleUpdateProfile();
break;
case ProfileActions.view:
_handleViewProfile();
break;
case null:
break;
}
},
));
},
),
),
title: _buildTitle(profile),
tileTitleAlignment: ListTileTitleAlignment.titleHeight,

View File

@@ -0,0 +1,207 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:flutter/material.dart';
import 'package:re_editor/re_editor.dart';
import 'package:re_highlight/languages/yaml.dart';
import 'package:re_highlight/styles/intellij-light.dart';
class ViewProfile extends StatefulWidget {
final Profile profile;
const ViewProfile({
super.key,
required this.profile,
});
@override
State<ViewProfile> createState() => _ViewProfileState();
}
class _ViewProfileState extends State<ViewProfile> {
bool readOnly = true;
CodeLineEditingController? controller;
final contentNotifier = ValueNotifier<String>("");
final key = GlobalKey<CommonScaffoldState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final profilePath = await appPath.getProfilePath(widget.profile.id);
if (profilePath == null) {
return;
}
final file = File(profilePath);
final text = await file.readAsString();
contentNotifier.value = text;
});
}
@override
void dispose() {
super.dispose();
contentNotifier.dispose();
controller?.dispose();
}
Profile get profile => widget.profile;
_handleChangeReadOnly() async {
if (readOnly == true) {
setState(() {
readOnly = false;
});
} else {
final text = controller?.text;
if (text == null || text == contentNotifier.value) {
setState(() {
readOnly = true;
});
return;
}
contentNotifier.value = text;
final newProfile = await key.currentState?.loadingRun<Profile>(() async {
return await profile.saveFileWithString(text);
});
if (newProfile == null) return;
globalState.appController.config.setProfile(newProfile);
setState(() {
readOnly = true;
});
}
}
@override
Widget build(BuildContext context) {
return CommonScaffold(
key: key,
actions: [
IconButton(
onPressed: controller?.undo,
icon: const Icon(Icons.undo),
),
IconButton(
onPressed: controller?.redo,
icon: const Icon(Icons.redo),
),
if (!widget.profile.realAutoUpdate)
IconButton(
onPressed: _handleChangeReadOnly,
icon: readOnly ? const Icon(Icons.edit) : const Icon(Icons.save),
),
const SizedBox(
width: 8,
)
],
body: ValueListenableBuilder(
valueListenable: contentNotifier,
builder: (_, value, __) {
if (value.isEmpty) return Container();
controller = CodeLineEditingController.fromText(value);
return CodeEditor(
autofocus: false,
readOnly: readOnly,
scrollbarBuilder: (context, child, details) {
return Scrollbar(
controller: details.controller,
thickness: 8,
radius: const Radius.circular(2),
interactive: true,
child: child,
);
},
showCursorWhenReadOnly: false,
controller: controller,
toolbarController:
!readOnly ? const ContextMenuControllerImpl() : null,
shortcutsActivatorsBuilder:
const DefaultCodeShortcutsActivatorsBuilder(),
indicatorBuilder:
(context, editingController, chunkController, notifier) {
return Row(
children: [
DefaultCodeLineNumber(
controller: editingController,
notifier: notifier,
),
DefaultCodeChunkIndicator(
width: 20,
controller: chunkController,
notifier: notifier,
)
],
);
},
style: CodeEditorStyle(
fontSize: 14,
codeTheme: CodeHighlightTheme(
languages: {
'yaml': CodeHighlightThemeMode(
mode: langYaml,
)
},
theme: intellijLightTheme,
),
),
);
},
),
title: widget.profile.label ?? widget.profile.id,
);
}
}
class ContextMenuItemWidget extends PopupMenuItem<void> {
ContextMenuItemWidget({
super.key,
required String text,
required VoidCallback super.onTap,
}) : super(child: Text(text));
}
class ContextMenuControllerImpl implements SelectionToolbarController {
const ContextMenuControllerImpl();
@override
void hide(BuildContext context) {}
@override
void show({
required BuildContext context,
required CodeLineEditingController controller,
required TextSelectionToolbarAnchors anchors,
Rect? renderRect,
required LayerLink layerLink,
required ValueNotifier<bool> visibility,
}) {
if (controller.selectedText.isEmpty) {
return;
}
showMenu(
context: context,
position: RelativeRect.fromSize(
(anchors.secondaryAnchor ?? anchors.primaryAnchor) &
const Size(150, double.infinity),
MediaQuery.of(context).size,
),
items: [
ContextMenuItemWidget(
text: appLocalizations.cut,
onTap: controller.cut,
),
ContextMenuItemWidget(
text: appLocalizations.copy,
onTap: controller.copy,
),
ContextMenuItemWidget(
text: appLocalizations.paste,
onTap: controller.paste,
),
],
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/card.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ProxiesExpansionPanelFragment extends StatefulWidget {
const ProxiesExpansionPanelFragment({super.key});
@override
State<ProxiesExpansionPanelFragment> createState() =>
_ProxiesExpansionPanelFragmentState();
}
class _ProxiesExpansionPanelFragmentState
extends State<ProxiesExpansionPanelFragment> {
@override
Widget build(BuildContext context) {
return Selector2<AppState, Config, ProxiesSelectorState>(
selector: (_, appState, config) {
final currentGroups = appState.currentGroups;
final groupNames = currentGroups.map((e) => e.name).toList();
return ProxiesSelectorState(
groupNames: groupNames,
currentGroupName: config.currentGroupName,
);
},
builder: (_, state, __) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: state.groupNames.length,
itemBuilder: (_, index) {
final groupName = state.groupNames[index];
return CommonCard(
child: ExpansionTile(
title: Text(groupName),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(0.0),
side: const BorderSide(color: Colors.transparent),
),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(0.0),
side: const BorderSide(color: Colors.transparent),
),
children: [
Text("1212313"),
],
),
);
},
separatorBuilder: (BuildContext context, int index) {
return const SizedBox(
height: 16,
);
},
);
},
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/fragments/proxies/tabview.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ProxiesFragment extends StatefulWidget {
const ProxiesFragment({super.key});
@override
State<ProxiesFragment> createState() => _ProxiesFragmentState();
}
class _ProxiesFragmentState extends State<ProxiesFragment> {
_initActions() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
final items = [
CommonPopupMenuItem(
action: ProxiesSortType.none,
label: appLocalizations.defaultSort,
iconData: Icons.sort,
),
CommonPopupMenuItem(
action: ProxiesSortType.delay,
label: appLocalizations.delaySort,
iconData: Icons.network_ping),
CommonPopupMenuItem(
action: ProxiesSortType.name,
label: appLocalizations.nameSort,
iconData: Icons.sort_by_alpha),
];
commonScaffoldState?.actions = [
Selector<Config, ProxiesSortType>(
selector: (_, config) => config.proxiesSortType,
builder: (_, proxiesSortType, __) {
return CommonPopupMenu<ProxiesSortType>.radio(
items: items,
onSelected: (value) {
final config = context.read<Config>();
config.proxiesSortType = value;
},
selectedValue: proxiesSortType,
);
},
),
const SizedBox(
width: 8,
)
];
});
}
@override
Widget build(BuildContext context) {
return Selector<AppState, bool>(
selector: (_, appState) => appState.currentLabel == 'proxies',
builder: (_, isCurrent, child) {
if (isCurrent) {
_initActions();
}
return child!;
},
child: const ProxiesTabFragment(),
);
}
}

View File

@@ -1,70 +1,28 @@
import 'package:collection/collection.dart';
import 'package:fl_clash/clash/clash.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../enum/enum.dart';
import '../models/models.dart';
import '../common/common.dart';
import '../widgets/widgets.dart';
class ProxiesFragment extends StatefulWidget {
const ProxiesFragment({super.key});
class ProxiesTabFragment extends StatefulWidget {
const ProxiesTabFragment({super.key});
@override
State<ProxiesFragment> createState() => _ProxiesFragmentState();
State<ProxiesTabFragment> createState() => _ProxiesTabFragmentState();
}
class _ProxiesFragmentState extends State<ProxiesFragment>
with TickerProviderStateMixin {
class _ProxiesTabFragmentState extends State<ProxiesTabFragment> with TickerProviderStateMixin {
TabController? _tabController;
_initActions() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
final items = [
CommonPopupMenuItem(
action: ProxiesSortType.none,
label: appLocalizations.defaultSort,
iconData: Icons.sort,
),
CommonPopupMenuItem(
action: ProxiesSortType.delay,
label: appLocalizations.delaySort,
iconData: Icons.network_ping),
CommonPopupMenuItem(
action: ProxiesSortType.name,
label: appLocalizations.nameSort,
iconData: Icons.sort_by_alpha),
];
commonScaffoldState?.actions = [
Selector<Config, ProxiesSortType>(
selector: (_, config) => config.proxiesSortType,
builder: (_, proxiesSortType, __) {
return CommonPopupMenu<ProxiesSortType>.radio(
items: items,
onSelected: (value) {
final config = context.read<Config>();
config.proxiesSortType = value;
},
selectedValue: proxiesSortType,
);
},
),
const SizedBox(
width: 8,
)
];
});
}
_handleTabControllerChange() {
final indexIsChanging = _tabController?.indexIsChanging ?? false;
if (indexIsChanging) return;
final index = _tabController?.index;
if(index == null) return;
if (index == null) return;
final appController = globalState.appController;
final currentGroups = appController.appState.currentGroups;
if (currentGroups.length > index) {
@@ -72,80 +30,78 @@ class _ProxiesFragmentState extends State<ProxiesFragment>
}
}
@override
void dispose() {
super.dispose();
_tabController?.dispose();
}
@override
Widget build(BuildContext context) {
return Selector<AppState, bool>(
selector: (_, appState) => appState.currentLabel == 'proxies',
builder: (_, isCurrent, child) {
if (isCurrent) {
_initActions();
}
return child!;
return Selector2<AppState, Config, ProxiesSelectorState>(
selector: (_, appState, config) {
final currentGroups = appState.currentGroups;
final groupNames = currentGroups.map((e) => e.name).toList();
return ProxiesSelectorState(
groupNames: groupNames,
currentGroupName: config.currentGroupName,
);
},
child: Selector2<AppState, Config, ProxiesSelectorState>(
selector: (_, appState, config) {
final currentGroups = appState.currentGroups;
final groupNames = currentGroups.map((e) => e.name).toList();
return ProxiesSelectorState(
groupNames: groupNames,
currentGroupName: config.currentGroupName,
);
},
shouldRebuild: (prev, next) {
if (!const ListEquality<String>()
.equals(prev.groupNames, next.groupNames)) {
_tabController?.removeListener(_handleTabControllerChange);
_tabController?.dispose();
_tabController = null;
return true;
}
return false;
},
builder: (_, state, __) {
final index = state.groupNames.indexWhere(
(item) => item == state.currentGroupName,
);
_tabController ??= TabController(
length: state.groupNames.length,
initialIndex: index == -1 ? 0 : index,
vsync: this,
)..addListener(_handleTabControllerChange);
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
shouldRebuild: (prev, next) {
if (!const ListEquality<String>()
.equals(prev.groupNames, next.groupNames)) {
_tabController?.removeListener(_handleTabControllerChange);
_tabController?.dispose();
_tabController = null;
return true;
}
return false;
},
builder: (_, state, __) {
final index = state.groupNames.indexWhere(
(item) => item == state.currentGroupName,
);
_tabController ??= TabController(
length: state.groupNames.length,
initialIndex: index == -1 ? 0 : index,
vsync: this,
)..addListener(_handleTabControllerChange);
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
controller: _tabController,
padding: const EdgeInsets.symmetric(horizontal: 16),
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
overlayColor:
const WidgetStatePropertyAll(Colors.transparent),
tabs: [
for (final groupName in state.groupNames)
Tab(
text: groupName,
),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
padding: const EdgeInsets.symmetric(horizontal: 16),
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
overlayColor: const WidgetStatePropertyAll(Colors.transparent),
tabs: [
children: [
for (final groupName in state.groupNames)
Tab(
text: groupName,
KeepContainer(
key: ObjectKey(groupName),
child: ProxiesTabView(
groupName: groupName,
),
),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
for (final groupName in state.groupNames)
KeepContainer(
key: ObjectKey(groupName),
child: ProxiesTabView(
groupName: groupName,
),
),
],
),
)
],
);
},
),
)
],
);
},
);
}
}
@@ -161,7 +117,7 @@ class ProxiesTabView extends StatelessWidget {
List<Proxy> _sortOfName(List<Proxy> proxies) {
return List.of(proxies)
..sort(
(a, b) => other.sortByChar(a.name, b.name),
(a, b) => other.sortByChar(a.name, b.name),
);
}
@@ -169,7 +125,7 @@ class ProxiesTabView extends StatelessWidget {
final appState = context.read<AppState>();
return proxies = List.of(proxies)
..sort(
(a, b) {
(a, b) {
final aDelay = appState.getDelay(a.name);
final bDelay = appState.getDelay(b.name);
if (aDelay == null && bDelay == null) {
@@ -187,10 +143,10 @@ class ProxiesTabView extends StatelessWidget {
}
_getProxies(
BuildContext context,
List<Proxy> proxies,
ProxiesSortType proxiesSortType,
) {
BuildContext context,
List<Proxy> proxies,
ProxiesSortType proxiesSortType,
) {
if (proxiesSortType == ProxiesSortType.delay) {
return _sortOfDelay(context, proxies);
}
@@ -373,7 +329,7 @@ class ProxyCard extends StatelessWidget {
style: context.textTheme.bodySmall?.copyWith(
overflow: TextOverflow.ellipsis,
color:
context.textTheme.bodySmall?.color?.toLight(),
context.textTheme.bodySmall?.color?.toLight(),
),
),
);

View File

@@ -3,21 +3,24 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class RequestFragment extends StatefulWidget {
const RequestFragment({super.key});
class RequestsFragment extends StatefulWidget {
const RequestsFragment({super.key});
@override
State<RequestFragment> createState() => _RequestFragmentState();
State<RequestsFragment> createState() => _RequestsFragmentState();
}
class _RequestFragmentState extends State<RequestFragment> {
final requestsNotifier = ValueNotifier<List<Connection>>([]);
class _RequestsFragmentState extends State<RequestsFragment> {
final requestsNotifier =
ValueNotifier<ConnectionsAndKeywords>(const ConnectionsAndKeywords());
final ScrollController _scrollController = ScrollController(
keepScrollOffset: false,
);
@@ -29,23 +32,70 @@ class _RequestFragmentState extends State<RequestFragment> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = globalState.appController.appState;
requestsNotifier.value = List<Connection>.from(appState.requests);
requestsNotifier.value =
requestsNotifier.value.copyWith(connections: appState.requests);
if (timer != null) {
timer?.cancel();
timer = null;
}
timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
final requests = List<Connection>.from(appState.requests);
final requests = appState.requests;
if (!const ListEquality<Connection>().equals(
requestsNotifier.value,
requestsNotifier.value.connections,
requests,
)) {
requestsNotifier.value = requests;
requestsNotifier.value =
requestsNotifier.value.copyWith(connections: requests);
}
});
});
}
_initActions() {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
final commonScaffoldState =
context.findAncestorStateOfType<CommonScaffoldState>();
commonScaffoldState?.actions = [
IconButton(
onPressed: () {
showSearch(
context: context,
delegate: RequestsSearchDelegate(
state: requestsNotifier.value,
),
);
},
icon: const Icon(Icons.search),
),
const SizedBox(
width: 8,
)
];
},
);
}
_addKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..add(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..remove(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
void dispose() {
super.dispose();
@@ -56,42 +106,86 @@ class _RequestFragmentState extends State<RequestFragment> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<Connection>>(
valueListenable: requestsNotifier,
builder: (_, List<Connection> connections, __) {
if (connections.isEmpty) {
return NullStatus(
label: appLocalizations.nullRequestsDesc,
);
return Selector<AppState, bool?>(
selector: (_, appState) =>
appState.currentLabel == 'requests' ||
appState.viewMode == ViewMode.mobile &&
appState.currentLabel == "tools",
builder: (_, isCurrent, child) {
if (isCurrent == null || isCurrent) {
_initActions();
}
connections = connections.reversed.toList();
return ListView.separated(
controller: _scrollController,
itemBuilder: (_, index) {
final connection = connections[index];
return RequestItem(
key: Key(connection.id),
connection: connection,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
);
return child!;
},
child: ValueListenableBuilder<ConnectionsAndKeywords>(
valueListenable: requestsNotifier,
builder: (_, state, __) {
var connections = state.filteredConnections;
if (connections.isEmpty) {
return NullStatus(
label: appLocalizations.nullRequestsDesc,
);
}
connections = connections.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
controller: _scrollController,
itemBuilder: (_, index) {
final connection = connections[index];
return RequestItem(
key: Key(connection.id),
connection: connection,
onClick: _addKeyword,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: connections.length,
),
)
],
);
},
),
);
}
}
class RequestItem extends StatelessWidget {
final Connection connection;
final Function(String)? onClick;
const RequestItem({
super.key,
required this.connection,
this.onClick,
});
Future<ImageProvider?> _getPackageIcon(Connection connection) async {
@@ -119,8 +213,8 @@ class RequestItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
titleAlignment: ListTileTitleAlignment.top,
return ListItem(
tileTitleAlignment: ListTileTitleAlignment.titleHeight,
leading: Platform.isAndroid
? Container(
margin: const EdgeInsets.only(top: 4),
@@ -165,6 +259,10 @@ class RequestItem extends StatelessWidget {
for (final chain in connection.chains)
CommonChip(
label: chain,
onPressed: () {
if (onClick == null) return;
onClick!(chain);
},
),
],
),
@@ -176,3 +274,144 @@ class RequestItem extends StatelessWidget {
);
}
}
class RequestsSearchDelegate extends SearchDelegate {
ValueNotifier<ConnectionsAndKeywords> requestsNotifier;
RequestsSearchDelegate({
required ConnectionsAndKeywords state,
}) : requestsNotifier = ValueNotifier<ConnectionsAndKeywords>(state);
get state => requestsNotifier.value;
List<Connection> get _results {
final lowerQuery = query.toLowerCase().trim();
return requestsNotifier.value.filteredConnections.where((request) {
final lowerNetwork = request.metadata.network.toLowerCase();
final lowerHost = request.metadata.host.toLowerCase();
final lowerDestinationIP = request.metadata.destinationIP.toLowerCase();
final lowerProcess = request.metadata.process.toLowerCase();
final lowerChains = request.chains.join("").toLowerCase();
return lowerNetwork.contains(lowerQuery) ||
lowerHost.contains(lowerQuery) ||
lowerDestinationIP.contains(lowerQuery) ||
lowerProcess.contains(lowerQuery) ||
lowerChains.contains(lowerQuery);
}).toList();
}
_addKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..add(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
_deleteKeyword(String keyword) {
final isContains = requestsNotifier.value.keywords.contains(keyword);
if (!isContains) return;
final keywords = List<String>.from(requestsNotifier.value.keywords)
..remove(keyword);
requestsNotifier.value = requestsNotifier.value.copyWith(
keywords: keywords,
);
}
@override
List<Widget>? buildActions(BuildContext context) {
return [
IconButton(
onPressed: () {
if (query.isEmpty) {
close(context, null);
return;
}
query = '';
},
icon: const Icon(Icons.clear),
),
const SizedBox(
width: 8,
)
];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(
onPressed: () {
close(context, null);
},
icon: const Icon(Icons.arrow_back),
);
}
@override
Widget buildResults(BuildContext context) {
return buildSuggestions(context);
}
@override
void dispose() {
requestsNotifier.dispose();
super.dispose();
}
@override
Widget buildSuggestions(BuildContext context) {
return ValueListenableBuilder(
valueListenable: requestsNotifier,
builder: (_, __, ___) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.keywords.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (final keyword in state.keywords)
CommonChip(
label: keyword,
type: ChipType.delete,
onPressed: () {
_deleteKeyword(keyword);
},
),
],
),
),
Expanded(
child: ListView.separated(
itemBuilder: (_, index) {
final connection = _results[index];
return RequestItem(
key: Key(connection.id),
connection: connection,
onClick: (value) {
_addKeyword(value);
},
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
height: 0,
);
},
itemCount: _results.length,
),
)
],
);
},
);
}
}

View File

@@ -78,9 +78,9 @@ class _ResourcesState extends State<Resources> {
const Padding(
padding: EdgeInsets.only(left: 12,right: 4),
child: VerticalDivider(
endIndent: 2,
endIndent: 6,
width: 4,
indent: 2,
indent: 6,
),
),
externalProvider.vehicleType == "HTTP"

View File

@@ -111,9 +111,10 @@ class ThemeFragment extends StatelessWidget {
null,
defaultPrimaryColor,
Colors.pinkAccent,
Colors.lightBlue,
Colors.greenAccent,
Colors.yellowAccent,
Colors.purple
Colors.purple,
];
return Column(
children: [

View File

@@ -179,8 +179,18 @@
"geodataLoaderDesc": "Enabling will use the Geo low memory loader",
"requests": "Requests",
"requestsDesc": "View recently requested data",
"nullRequestsDesc": "No proxy or no request",
"findProcessMode": "Find process",
"findProcessModeDesc": "There is a risk of flashback after opening",
"init": "Init"
"init": "Init",
"infiniteTime": "Long term effective",
"expirationTime": "Expiration time",
"connections": "Connections",
"connectionsDesc": "View current connection",
"nullRequestsDesc": "No requests",
"nullConnectionsDesc": "No connections",
"intranetIp": "Intranet IP",
"view": "View",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste"
}

View File

@@ -179,8 +179,18 @@
"geodataLoaderDesc": "开启将使用Geo低内存加载器",
"requests": "请求",
"requestsDesc": "查看最近请求数据",
"nullRequestsDesc": "未开启代理或者没有请求",
"findProcessMode": "查找进程",
"findProcessModeDesc": "开启后存在闪退风险",
"init": "初始化"
"init": "初始化",
"infiniteTime": "长期有效",
"expirationTime": "到期时间",
"connections": "连接",
"connectionsDesc": "查看当前连接",
"nullRequestsDesc": "暂无请求",
"nullConnectionsDesc": "暂无连接",
"intranetIp": "内网 IP",
"view": "查看",
"cut": "剪切",
"copy": "复制",
"paste": "粘贴"
}

View File

@@ -92,11 +92,16 @@ class MessageLookup extends MessageLookupByLibrary {
"compatibleDesc": MessageLookupByLibrary.simpleMessage(
"Opening it will lose part of its application ability and gain the support of full amount of Clash."),
"confirm": MessageLookupByLibrary.simpleMessage("Confirm"),
"connections": MessageLookupByLibrary.simpleMessage("Connections"),
"connectionsDesc":
MessageLookupByLibrary.simpleMessage("View current connection"),
"connectivity": MessageLookupByLibrary.simpleMessage("Connectivity"),
"copy": MessageLookupByLibrary.simpleMessage("Copy"),
"core": MessageLookupByLibrary.simpleMessage("Core"),
"coreInfo": MessageLookupByLibrary.simpleMessage("Core info"),
"country": MessageLookupByLibrary.simpleMessage("Country"),
"create": MessageLookupByLibrary.simpleMessage("Create"),
"cut": MessageLookupByLibrary.simpleMessage("Cut"),
"dark": MessageLookupByLibrary.simpleMessage("Dark"),
"dashboard": MessageLookupByLibrary.simpleMessage("Dashboard"),
"days": MessageLookupByLibrary.simpleMessage("Days"),
@@ -117,6 +122,8 @@ class MessageLookup extends MessageLookupByLibrary {
"edit": MessageLookupByLibrary.simpleMessage("Edit"),
"en": MessageLookupByLibrary.simpleMessage("English"),
"exit": MessageLookupByLibrary.simpleMessage("Exit"),
"expirationTime":
MessageLookupByLibrary.simpleMessage("Expiration time"),
"externalController":
MessageLookupByLibrary.simpleMessage("ExternalController"),
"externalControllerDesc": MessageLookupByLibrary.simpleMessage(
@@ -142,7 +149,10 @@ class MessageLookup extends MessageLookupByLibrary {
"hours": MessageLookupByLibrary.simpleMessage("Hours"),
"importFromURL":
MessageLookupByLibrary.simpleMessage("Import from URL"),
"infiniteTime":
MessageLookupByLibrary.simpleMessage("Long term effective"),
"init": MessageLookupByLibrary.simpleMessage("Init"),
"intranetIp": MessageLookupByLibrary.simpleMessage("Intranet IP"),
"ipCheckTimeout":
MessageLookupByLibrary.simpleMessage("Ip check timeout"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage(
@@ -175,13 +185,14 @@ class MessageLookup extends MessageLookupByLibrary {
"Please create a profile or add a valid profile"),
"notSelectedTip": MessageLookupByLibrary.simpleMessage(
"The current proxy group cannot be selected."),
"nullConnectionsDesc":
MessageLookupByLibrary.simpleMessage("No connections"),
"nullCoreInfoDesc":
MessageLookupByLibrary.simpleMessage("Unable to obtain core info"),
"nullLogsDesc": MessageLookupByLibrary.simpleMessage("No logs"),
"nullProfileDesc": MessageLookupByLibrary.simpleMessage(
"No profile, Please add a profile"),
"nullRequestsDesc":
MessageLookupByLibrary.simpleMessage("No proxy or no request"),
"nullRequestsDesc": MessageLookupByLibrary.simpleMessage("No requests"),
"other": MessageLookupByLibrary.simpleMessage("Other"),
"outboundMode": MessageLookupByLibrary.simpleMessage("Outbound mode"),
"override": MessageLookupByLibrary.simpleMessage("Override"),
@@ -190,6 +201,7 @@ class MessageLookup extends MessageLookupByLibrary {
"password": MessageLookupByLibrary.simpleMessage("Password"),
"passwordTip":
MessageLookupByLibrary.simpleMessage("Password cannot be empty"),
"paste": MessageLookupByLibrary.simpleMessage("Paste"),
"pleaseBindWebDAV":
MessageLookupByLibrary.simpleMessage("Please bind WebDAV"),
"pleaseUploadFile":
@@ -282,6 +294,7 @@ class MessageLookup extends MessageLookupByLibrary {
"url": MessageLookupByLibrary.simpleMessage("URL"),
"urlDesc":
MessageLookupByLibrary.simpleMessage("Obtain profile through URL"),
"view": MessageLookupByLibrary.simpleMessage("View"),
"webDAVConfiguration":
MessageLookupByLibrary.simpleMessage("WebDAV configuration"),
"whitelistMode": MessageLookupByLibrary.simpleMessage("Whitelist mode"),

View File

@@ -75,11 +75,15 @@ class MessageLookup extends MessageLookupByLibrary {
"compatibleDesc":
MessageLookupByLibrary.simpleMessage("开启将失去部分应用能力获得全量的Clash的支持"),
"confirm": MessageLookupByLibrary.simpleMessage("确定"),
"connections": MessageLookupByLibrary.simpleMessage("连接"),
"connectionsDesc": MessageLookupByLibrary.simpleMessage("查看当前连接"),
"connectivity": MessageLookupByLibrary.simpleMessage("连通性:"),
"copy": MessageLookupByLibrary.simpleMessage("复制"),
"core": MessageLookupByLibrary.simpleMessage("内核"),
"coreInfo": MessageLookupByLibrary.simpleMessage("内核信息"),
"country": MessageLookupByLibrary.simpleMessage("区域"),
"create": MessageLookupByLibrary.simpleMessage("创建"),
"cut": MessageLookupByLibrary.simpleMessage("剪切"),
"dark": MessageLookupByLibrary.simpleMessage("深色"),
"dashboard": MessageLookupByLibrary.simpleMessage("仪表盘"),
"days": MessageLookupByLibrary.simpleMessage(""),
@@ -97,6 +101,7 @@ class MessageLookup extends MessageLookupByLibrary {
"edit": MessageLookupByLibrary.simpleMessage("编辑"),
"en": MessageLookupByLibrary.simpleMessage("英语"),
"exit": MessageLookupByLibrary.simpleMessage("退出"),
"expirationTime": MessageLookupByLibrary.simpleMessage("到期时间"),
"externalController": MessageLookupByLibrary.simpleMessage("外部控制器"),
"externalControllerDesc":
MessageLookupByLibrary.simpleMessage("开启后将可以通过9090端口控制clash内核"),
@@ -116,7 +121,9 @@ class MessageLookup extends MessageLookupByLibrary {
"goDownload": MessageLookupByLibrary.simpleMessage("前往下载"),
"hours": MessageLookupByLibrary.simpleMessage("小时"),
"importFromURL": MessageLookupByLibrary.simpleMessage("从URL导入"),
"infiniteTime": MessageLookupByLibrary.simpleMessage("长期有效"),
"init": MessageLookupByLibrary.simpleMessage("初始化"),
"intranetIp": MessageLookupByLibrary.simpleMessage("内网 IP"),
"ipCheckTimeout": MessageLookupByLibrary.simpleMessage("Ip检测超时"),
"ipv6Desc": MessageLookupByLibrary.simpleMessage("开启后将可以接收ipv6流量"),
"just": MessageLookupByLibrary.simpleMessage("刚刚"),
@@ -143,17 +150,19 @@ class MessageLookup extends MessageLookupByLibrary {
"noProxyDesc":
MessageLookupByLibrary.simpleMessage("请创建配置文件或者添加有效配置文件"),
"notSelectedTip": MessageLookupByLibrary.simpleMessage("当前代理组无法选中"),
"nullConnectionsDesc": MessageLookupByLibrary.simpleMessage("暂无连接"),
"nullCoreInfoDesc": MessageLookupByLibrary.simpleMessage("无法获取内核信息"),
"nullLogsDesc": MessageLookupByLibrary.simpleMessage("暂无日志"),
"nullProfileDesc":
MessageLookupByLibrary.simpleMessage("没有配置文件,请先添加配置文件"),
"nullRequestsDesc": MessageLookupByLibrary.simpleMessage("未开启代理或者没有请求"),
"nullRequestsDesc": MessageLookupByLibrary.simpleMessage("暂无请求"),
"other": MessageLookupByLibrary.simpleMessage("其他"),
"outboundMode": MessageLookupByLibrary.simpleMessage("出站模式"),
"override": MessageLookupByLibrary.simpleMessage("覆写"),
"overrideDesc": MessageLookupByLibrary.simpleMessage("覆写代理相关配置"),
"password": MessageLookupByLibrary.simpleMessage("密码"),
"passwordTip": MessageLookupByLibrary.simpleMessage("密码不能为空"),
"paste": MessageLookupByLibrary.simpleMessage("粘贴"),
"pleaseBindWebDAV": MessageLookupByLibrary.simpleMessage("请绑定WebDAV"),
"pleaseUploadFile": MessageLookupByLibrary.simpleMessage("请上传文件"),
"pleaseUploadValidQrcode":
@@ -227,6 +236,7 @@ class MessageLookup extends MessageLookupByLibrary {
"upload": MessageLookupByLibrary.simpleMessage("上传"),
"url": MessageLookupByLibrary.simpleMessage("URL"),
"urlDesc": MessageLookupByLibrary.simpleMessage("直接上传配置文件"),
"view": MessageLookupByLibrary.simpleMessage("查看"),
"webDAVConfiguration": MessageLookupByLibrary.simpleMessage("WebDAV配置"),
"whitelistMode": MessageLookupByLibrary.simpleMessage("白名单模式"),
"years": MessageLookupByLibrary.simpleMessage(""),

View File

@@ -1850,16 +1850,6 @@ class AppLocalizations {
);
}
/// `No proxy or no request`
String get nullRequestsDesc {
return Intl.message(
'No proxy or no request',
name: 'nullRequestsDesc',
desc: '',
args: [],
);
}
/// `Find process`
String get findProcessMode {
return Intl.message(
@@ -1889,6 +1879,116 @@ class AppLocalizations {
args: [],
);
}
/// `Long term effective`
String get infiniteTime {
return Intl.message(
'Long term effective',
name: 'infiniteTime',
desc: '',
args: [],
);
}
/// `Expiration time`
String get expirationTime {
return Intl.message(
'Expiration time',
name: 'expirationTime',
desc: '',
args: [],
);
}
/// `Connections`
String get connections {
return Intl.message(
'Connections',
name: 'connections',
desc: '',
args: [],
);
}
/// `View current connection`
String get connectionsDesc {
return Intl.message(
'View current connection',
name: 'connectionsDesc',
desc: '',
args: [],
);
}
/// `No requests`
String get nullRequestsDesc {
return Intl.message(
'No requests',
name: 'nullRequestsDesc',
desc: '',
args: [],
);
}
/// `No connections`
String get nullConnectionsDesc {
return Intl.message(
'No connections',
name: 'nullConnectionsDesc',
desc: '',
args: [],
);
}
/// `Intranet IP`
String get intranetIp {
return Intl.message(
'Intranet IP',
name: 'intranetIp',
desc: '',
args: [],
);
}
/// `View`
String get view {
return Intl.message(
'View',
name: 'view',
desc: '',
args: [],
);
}
/// `Cut`
String get cut {
return Intl.message(
'Cut',
name: 'cut',
desc: '',
args: [],
);
}
/// `Copy`
String get copy {
return Intl.message(
'Copy',
name: 'copy',
desc: '',
args: [],
);
}
/// `Paste`
String get paste {
return Intl.message(
'Paste',
name: 'paste',
desc: '',
args: [],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<AppLocalizations> {

View File

@@ -61,7 +61,7 @@ Future<void> vpnService() async {
clashConfig: clashConfig,
);
await globalState.applyProfile(
globalState.applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
@@ -75,6 +75,7 @@ Future<void> vpnService() async {
handleStart() async {
await app?.tip(appLocalizations.startVpn);
await globalState.startSystemProxy(
appState: appState,
config: config,
clashConfig: clashConfig,
);

View File

@@ -22,6 +22,7 @@ class AppState with ChangeNotifier {
bool _isInit;
VersionInfo? _versionInfo;
List<Traffic> _traffics;
Traffic _totalTraffic;
List<Log> _logs;
String _currentLabel;
SystemColorSchemes _systemColorSchemes;
@@ -48,6 +49,7 @@ class AppState with ChangeNotifier {
_sortNum = 0,
_requests = [],
_mode = mode,
_totalTraffic = Traffic(),
_delayMap = {},
_groups = [],
_isCompatible = isCompatible,
@@ -157,11 +159,24 @@ class AppState with ChangeNotifier {
}
}
addTraffic(Traffic value) {
_traffics = List.from(_traffics)..add(value);
addTraffic(Traffic traffic) {
_traffics = List.from(_traffics)..add(traffic);
const maxLength = 60;
if (_traffics.length > maxLength) {
_traffics = _traffics.sublist(_traffics.length - maxLength);
}
notifyListeners();
}
Traffic get totalTraffic => _totalTraffic;
set totalTraffic(Traffic value) {
if (_totalTraffic != value) {
_totalTraffic = value;
notifyListeners();
}
}
List<Connection> get requests => _requests;
set requests(List<Connection> value) {
@@ -172,7 +187,7 @@ class AppState with ChangeNotifier {
}
addRequest(Connection value) {
_requests.add(value);
_requests = List.from(_requests)..add(value);
final maxLength = Platform.isAndroid ? 1000 : 60;
if (_requests.length > maxLength) {
_requests = _requests.sublist(_requests.length - maxLength);
@@ -190,11 +205,10 @@ class AppState with ChangeNotifier {
}
addLog(Log log) {
_logs.add(log);
if (!Platform.isAndroid) {
if (_logs.length > 60) {
_logs = _logs.sublist(_logs.length - 60);
}
_logs = List.from(_logs)..add(log);
final maxLength = Platform.isAndroid ? 1000 : 60;
if (_logs.length > maxLength) {
_logs = _logs.sublist(_logs.length - maxLength);
}
notifyListeners();
}

View File

@@ -143,18 +143,21 @@ class Config extends ChangeNotifier {
}
Profile? get currentProfile {
try {
return profiles.firstWhere((element) => element.id == _currentProfileId);
} catch (_) {
return null;
}
final index =
profiles.indexWhere((profile) => profile.id == _currentProfileId);
return index == -1 ? null : profiles[index];
}
String? get currentGroupName => currentProfile?.currentGroupName;
updateCurrentGroupName(String groupName) {
if (currentProfile?.currentGroupName != groupName) {
currentProfile?.currentGroupName = groupName;
if (currentProfile != null &&
currentProfile!.currentGroupName != groupName) {
_setProfile(
currentProfile!.copyWith(
currentGroupName: groupName,
),
);
notifyListeners();
}
}
@@ -164,9 +167,16 @@ class Config extends ChangeNotifier {
}
updateCurrentSelectedMap(String groupName, String proxyName) {
if (currentProfile?.selectedMap[groupName] != proxyName) {
currentProfile?.selectedMap = Map.from(currentProfile?.selectedMap ?? {})
..[groupName] = proxyName;
if (currentProfile != null &&
currentProfile!.selectedMap[groupName] != proxyName) {
final SelectedMap selectedMap = Map.from(
currentProfile?.selectedMap ?? {},
)..[groupName] = proxyName;
_setProfile(
currentProfile!.copyWith(
selectedMap: selectedMap,
),
);
notifyListeners();
}
}

View File

@@ -23,7 +23,7 @@ class Metadata with _$Metadata {
}
@freezed
class Connection with _$Connection{
class Connection with _$Connection {
const factory Connection({
required String id,
num? upload,
@@ -36,3 +36,19 @@ class Connection with _$Connection{
factory Connection.fromJson(Map<String, Object?> json) =>
_$ConnectionFromJson(json);
}
@freezed
class ConnectionsAndKeywords with _$ConnectionsAndKeywords {
const factory ConnectionsAndKeywords({
@Default([]) List<Connection> connections,
@Default([]) List<String> keywords,
}) = _ConnectionsAndKeywords;
factory ConnectionsAndKeywords.fromJson(Map<String, Object?> json) =>
_$ConnectionsAndKeywordsFromJson(json);
}
extension ConnectionsAndKeywordsExt on ConnectionsAndKeywords{
List<Connection> get filteredConnections => connections.where((connection)=> Set.from(connection.chains).containsAll(keywords)).toList();
}

View File

@@ -589,3 +589,184 @@ abstract class _Connection implements Connection {
_$$ConnectionImplCopyWith<_$ConnectionImpl> get copyWith =>
throw _privateConstructorUsedError;
}
ConnectionsAndKeywords _$ConnectionsAndKeywordsFromJson(
Map<String, dynamic> json) {
return _ConnectionsAndKeywords.fromJson(json);
}
/// @nodoc
mixin _$ConnectionsAndKeywords {
List<Connection> get connections => throw _privateConstructorUsedError;
List<String> get keywords => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$ConnectionsAndKeywordsCopyWith<ConnectionsAndKeywords> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ConnectionsAndKeywordsCopyWith<$Res> {
factory $ConnectionsAndKeywordsCopyWith(ConnectionsAndKeywords value,
$Res Function(ConnectionsAndKeywords) then) =
_$ConnectionsAndKeywordsCopyWithImpl<$Res, ConnectionsAndKeywords>;
@useResult
$Res call({List<Connection> connections, List<String> keywords});
}
/// @nodoc
class _$ConnectionsAndKeywordsCopyWithImpl<$Res,
$Val extends ConnectionsAndKeywords>
implements $ConnectionsAndKeywordsCopyWith<$Res> {
_$ConnectionsAndKeywordsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connections = null,
Object? keywords = null,
}) {
return _then(_value.copyWith(
connections: null == connections
? _value.connections
: connections // ignore: cast_nullable_to_non_nullable
as List<Connection>,
keywords: null == keywords
? _value.keywords
: keywords // ignore: cast_nullable_to_non_nullable
as List<String>,
) as $Val);
}
}
/// @nodoc
abstract class _$$ConnectionsAndKeywordsImplCopyWith<$Res>
implements $ConnectionsAndKeywordsCopyWith<$Res> {
factory _$$ConnectionsAndKeywordsImplCopyWith(
_$ConnectionsAndKeywordsImpl value,
$Res Function(_$ConnectionsAndKeywordsImpl) then) =
__$$ConnectionsAndKeywordsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({List<Connection> connections, List<String> keywords});
}
/// @nodoc
class __$$ConnectionsAndKeywordsImplCopyWithImpl<$Res>
extends _$ConnectionsAndKeywordsCopyWithImpl<$Res,
_$ConnectionsAndKeywordsImpl>
implements _$$ConnectionsAndKeywordsImplCopyWith<$Res> {
__$$ConnectionsAndKeywordsImplCopyWithImpl(
_$ConnectionsAndKeywordsImpl _value,
$Res Function(_$ConnectionsAndKeywordsImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? connections = null,
Object? keywords = null,
}) {
return _then(_$ConnectionsAndKeywordsImpl(
connections: null == connections
? _value._connections
: connections // ignore: cast_nullable_to_non_nullable
as List<Connection>,
keywords: null == keywords
? _value._keywords
: keywords // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ConnectionsAndKeywordsImpl implements _ConnectionsAndKeywords {
const _$ConnectionsAndKeywordsImpl(
{final List<Connection> connections = const [],
final List<String> keywords = const []})
: _connections = connections,
_keywords = keywords;
factory _$ConnectionsAndKeywordsImpl.fromJson(Map<String, dynamic> json) =>
_$$ConnectionsAndKeywordsImplFromJson(json);
final List<Connection> _connections;
@override
@JsonKey()
List<Connection> get connections {
if (_connections is EqualUnmodifiableListView) return _connections;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_connections);
}
final List<String> _keywords;
@override
@JsonKey()
List<String> get keywords {
if (_keywords is EqualUnmodifiableListView) return _keywords;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_keywords);
}
@override
String toString() {
return 'ConnectionsAndKeywords(connections: $connections, keywords: $keywords)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ConnectionsAndKeywordsImpl &&
const DeepCollectionEquality()
.equals(other._connections, _connections) &&
const DeepCollectionEquality().equals(other._keywords, _keywords));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_connections),
const DeepCollectionEquality().hash(_keywords));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$ConnectionsAndKeywordsImplCopyWith<_$ConnectionsAndKeywordsImpl>
get copyWith => __$$ConnectionsAndKeywordsImplCopyWithImpl<
_$ConnectionsAndKeywordsImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ConnectionsAndKeywordsImplToJson(
this,
);
}
}
abstract class _ConnectionsAndKeywords implements ConnectionsAndKeywords {
const factory _ConnectionsAndKeywords(
{final List<Connection> connections,
final List<String> keywords}) = _$ConnectionsAndKeywordsImpl;
factory _ConnectionsAndKeywords.fromJson(Map<String, dynamic> json) =
_$ConnectionsAndKeywordsImpl.fromJson;
@override
List<Connection> get connections;
@override
List<String> get keywords;
@override
@JsonKey(ignore: true)
_$$ConnectionsAndKeywordsImplCopyWith<_$ConnectionsAndKeywordsImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@@ -52,3 +52,23 @@ Map<String, dynamic> _$$ConnectionImplToJson(_$ConnectionImpl instance) =>
'metadata': instance.metadata,
'chains': instance.chains,
};
_$ConnectionsAndKeywordsImpl _$$ConnectionsAndKeywordsImplFromJson(
Map<String, dynamic> json) =>
_$ConnectionsAndKeywordsImpl(
connections: (json['connections'] as List<dynamic>?)
?.map((e) => Connection.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
keywords: (json['keywords'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
);
Map<String, dynamic> _$$ConnectionsAndKeywordsImplToJson(
_$ConnectionsAndKeywordsImpl instance) =>
<String, dynamic>{
'connections': instance.connections,
'keywords': instance.keywords,
};

View File

@@ -0,0 +1,189 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of '../log.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
LogsAndKeywords _$LogsAndKeywordsFromJson(Map<String, dynamic> json) {
return _LogsAndKeywords.fromJson(json);
}
/// @nodoc
mixin _$LogsAndKeywords {
List<Log> get logs => throw _privateConstructorUsedError;
List<String> get keywords => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$LogsAndKeywordsCopyWith<LogsAndKeywords> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $LogsAndKeywordsCopyWith<$Res> {
factory $LogsAndKeywordsCopyWith(
LogsAndKeywords value, $Res Function(LogsAndKeywords) then) =
_$LogsAndKeywordsCopyWithImpl<$Res, LogsAndKeywords>;
@useResult
$Res call({List<Log> logs, List<String> keywords});
}
/// @nodoc
class _$LogsAndKeywordsCopyWithImpl<$Res, $Val extends LogsAndKeywords>
implements $LogsAndKeywordsCopyWith<$Res> {
_$LogsAndKeywordsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? logs = null,
Object? keywords = null,
}) {
return _then(_value.copyWith(
logs: null == logs
? _value.logs
: logs // ignore: cast_nullable_to_non_nullable
as List<Log>,
keywords: null == keywords
? _value.keywords
: keywords // ignore: cast_nullable_to_non_nullable
as List<String>,
) as $Val);
}
}
/// @nodoc
abstract class _$$LogsAndKeywordsImplCopyWith<$Res>
implements $LogsAndKeywordsCopyWith<$Res> {
factory _$$LogsAndKeywordsImplCopyWith(_$LogsAndKeywordsImpl value,
$Res Function(_$LogsAndKeywordsImpl) then) =
__$$LogsAndKeywordsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({List<Log> logs, List<String> keywords});
}
/// @nodoc
class __$$LogsAndKeywordsImplCopyWithImpl<$Res>
extends _$LogsAndKeywordsCopyWithImpl<$Res, _$LogsAndKeywordsImpl>
implements _$$LogsAndKeywordsImplCopyWith<$Res> {
__$$LogsAndKeywordsImplCopyWithImpl(
_$LogsAndKeywordsImpl _value, $Res Function(_$LogsAndKeywordsImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? logs = null,
Object? keywords = null,
}) {
return _then(_$LogsAndKeywordsImpl(
logs: null == logs
? _value._logs
: logs // ignore: cast_nullable_to_non_nullable
as List<Log>,
keywords: null == keywords
? _value._keywords
: keywords // ignore: cast_nullable_to_non_nullable
as List<String>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$LogsAndKeywordsImpl implements _LogsAndKeywords {
const _$LogsAndKeywordsImpl(
{final List<Log> logs = const [], final List<String> keywords = const []})
: _logs = logs,
_keywords = keywords;
factory _$LogsAndKeywordsImpl.fromJson(Map<String, dynamic> json) =>
_$$LogsAndKeywordsImplFromJson(json);
final List<Log> _logs;
@override
@JsonKey()
List<Log> get logs {
if (_logs is EqualUnmodifiableListView) return _logs;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_logs);
}
final List<String> _keywords;
@override
@JsonKey()
List<String> get keywords {
if (_keywords is EqualUnmodifiableListView) return _keywords;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_keywords);
}
@override
String toString() {
return 'LogsAndKeywords(logs: $logs, keywords: $keywords)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$LogsAndKeywordsImpl &&
const DeepCollectionEquality().equals(other._logs, _logs) &&
const DeepCollectionEquality().equals(other._keywords, _keywords));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_logs),
const DeepCollectionEquality().hash(_keywords));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$LogsAndKeywordsImplCopyWith<_$LogsAndKeywordsImpl> get copyWith =>
__$$LogsAndKeywordsImplCopyWithImpl<_$LogsAndKeywordsImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$LogsAndKeywordsImplToJson(
this,
);
}
}
abstract class _LogsAndKeywords implements LogsAndKeywords {
const factory _LogsAndKeywords(
{final List<Log> logs,
final List<String> keywords}) = _$LogsAndKeywordsImpl;
factory _LogsAndKeywords.fromJson(Map<String, dynamic> json) =
_$LogsAndKeywordsImpl.fromJson;
@override
List<Log> get logs;
@override
List<String> get keywords;
@override
@JsonKey(ignore: true)
_$$LogsAndKeywordsImplCopyWith<_$LogsAndKeywordsImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -23,3 +23,23 @@ const _$LogLevelEnumMap = {
LogLevel.error: 'error',
LogLevel.silent: 'silent',
};
_$LogsAndKeywordsImpl _$$LogsAndKeywordsImplFromJson(
Map<String, dynamic> json) =>
_$LogsAndKeywordsImpl(
logs: (json['logs'] as List<dynamic>?)
?.map((e) => Log.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
keywords: (json['keywords'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
);
Map<String, dynamic> _$$LogsAndKeywordsImplToJson(
_$LogsAndKeywordsImpl instance) =>
<String, dynamic>{
'logs': instance.logs,
'keywords': instance.keywords,
};

View File

@@ -0,0 +1,546 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of '../profile.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
UserInfo _$UserInfoFromJson(Map<String, dynamic> json) {
return _UserInfo.fromJson(json);
}
/// @nodoc
mixin _$UserInfo {
int get upload => throw _privateConstructorUsedError;
int get download => throw _privateConstructorUsedError;
int get total => throw _privateConstructorUsedError;
int get expire => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$UserInfoCopyWith<UserInfo> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UserInfoCopyWith<$Res> {
factory $UserInfoCopyWith(UserInfo value, $Res Function(UserInfo) then) =
_$UserInfoCopyWithImpl<$Res, UserInfo>;
@useResult
$Res call({int upload, int download, int total, int expire});
}
/// @nodoc
class _$UserInfoCopyWithImpl<$Res, $Val extends UserInfo>
implements $UserInfoCopyWith<$Res> {
_$UserInfoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? upload = null,
Object? download = null,
Object? total = null,
Object? expire = null,
}) {
return _then(_value.copyWith(
upload: null == upload
? _value.upload
: upload // ignore: cast_nullable_to_non_nullable
as int,
download: null == download
? _value.download
: download // ignore: cast_nullable_to_non_nullable
as int,
total: null == total
? _value.total
: total // ignore: cast_nullable_to_non_nullable
as int,
expire: null == expire
? _value.expire
: expire // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$UserInfoImplCopyWith<$Res>
implements $UserInfoCopyWith<$Res> {
factory _$$UserInfoImplCopyWith(
_$UserInfoImpl value, $Res Function(_$UserInfoImpl) then) =
__$$UserInfoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({int upload, int download, int total, int expire});
}
/// @nodoc
class __$$UserInfoImplCopyWithImpl<$Res>
extends _$UserInfoCopyWithImpl<$Res, _$UserInfoImpl>
implements _$$UserInfoImplCopyWith<$Res> {
__$$UserInfoImplCopyWithImpl(
_$UserInfoImpl _value, $Res Function(_$UserInfoImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? upload = null,
Object? download = null,
Object? total = null,
Object? expire = null,
}) {
return _then(_$UserInfoImpl(
upload: null == upload
? _value.upload
: upload // ignore: cast_nullable_to_non_nullable
as int,
download: null == download
? _value.download
: download // ignore: cast_nullable_to_non_nullable
as int,
total: null == total
? _value.total
: total // ignore: cast_nullable_to_non_nullable
as int,
expire: null == expire
? _value.expire
: expire // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$UserInfoImpl implements _UserInfo {
const _$UserInfoImpl(
{this.upload = 0, this.download = 0, this.total = 0, this.expire = 0});
factory _$UserInfoImpl.fromJson(Map<String, dynamic> json) =>
_$$UserInfoImplFromJson(json);
@override
@JsonKey()
final int upload;
@override
@JsonKey()
final int download;
@override
@JsonKey()
final int total;
@override
@JsonKey()
final int expire;
@override
String toString() {
return 'UserInfo(upload: $upload, download: $download, total: $total, expire: $expire)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserInfoImpl &&
(identical(other.upload, upload) || other.upload == upload) &&
(identical(other.download, download) ||
other.download == download) &&
(identical(other.total, total) || other.total == total) &&
(identical(other.expire, expire) || other.expire == expire));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, upload, download, total, expire);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$UserInfoImplCopyWith<_$UserInfoImpl> get copyWith =>
__$$UserInfoImplCopyWithImpl<_$UserInfoImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UserInfoImplToJson(
this,
);
}
}
abstract class _UserInfo implements UserInfo {
const factory _UserInfo(
{final int upload,
final int download,
final int total,
final int expire}) = _$UserInfoImpl;
factory _UserInfo.fromJson(Map<String, dynamic> json) =
_$UserInfoImpl.fromJson;
@override
int get upload;
@override
int get download;
@override
int get total;
@override
int get expire;
@override
@JsonKey(ignore: true)
_$$UserInfoImplCopyWith<_$UserInfoImpl> get copyWith =>
throw _privateConstructorUsedError;
}
Profile _$ProfileFromJson(Map<String, dynamic> json) {
return _Profile.fromJson(json);
}
/// @nodoc
mixin _$Profile {
String get id => throw _privateConstructorUsedError;
String? get label => throw _privateConstructorUsedError;
String? get currentGroupName => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
DateTime? get lastUpdateDate => throw _privateConstructorUsedError;
Duration get autoUpdateDuration => throw _privateConstructorUsedError;
UserInfo? get userInfo => throw _privateConstructorUsedError;
bool get autoUpdate => throw _privateConstructorUsedError;
Map<String, String> get selectedMap => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$ProfileCopyWith<Profile> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ProfileCopyWith<$Res> {
factory $ProfileCopyWith(Profile value, $Res Function(Profile) then) =
_$ProfileCopyWithImpl<$Res, Profile>;
@useResult
$Res call(
{String id,
String? label,
String? currentGroupName,
String url,
DateTime? lastUpdateDate,
Duration autoUpdateDuration,
UserInfo? userInfo,
bool autoUpdate,
Map<String, String> selectedMap});
$UserInfoCopyWith<$Res>? get userInfo;
}
/// @nodoc
class _$ProfileCopyWithImpl<$Res, $Val extends Profile>
implements $ProfileCopyWith<$Res> {
_$ProfileCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? label = freezed,
Object? currentGroupName = freezed,
Object? url = null,
Object? lastUpdateDate = freezed,
Object? autoUpdateDuration = null,
Object? userInfo = freezed,
Object? autoUpdate = null,
Object? selectedMap = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
label: freezed == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String?,
currentGroupName: freezed == currentGroupName
? _value.currentGroupName
: currentGroupName // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
lastUpdateDate: freezed == lastUpdateDate
? _value.lastUpdateDate
: lastUpdateDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
autoUpdateDuration: null == autoUpdateDuration
? _value.autoUpdateDuration
: autoUpdateDuration // ignore: cast_nullable_to_non_nullable
as Duration,
userInfo: freezed == userInfo
? _value.userInfo
: userInfo // ignore: cast_nullable_to_non_nullable
as UserInfo?,
autoUpdate: null == autoUpdate
? _value.autoUpdate
: autoUpdate // ignore: cast_nullable_to_non_nullable
as bool,
selectedMap: null == selectedMap
? _value.selectedMap
: selectedMap // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
) as $Val);
}
@override
@pragma('vm:prefer-inline')
$UserInfoCopyWith<$Res>? get userInfo {
if (_value.userInfo == null) {
return null;
}
return $UserInfoCopyWith<$Res>(_value.userInfo!, (value) {
return _then(_value.copyWith(userInfo: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$ProfileImplCopyWith<$Res> implements $ProfileCopyWith<$Res> {
factory _$$ProfileImplCopyWith(
_$ProfileImpl value, $Res Function(_$ProfileImpl) then) =
__$$ProfileImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String? label,
String? currentGroupName,
String url,
DateTime? lastUpdateDate,
Duration autoUpdateDuration,
UserInfo? userInfo,
bool autoUpdate,
Map<String, String> selectedMap});
@override
$UserInfoCopyWith<$Res>? get userInfo;
}
/// @nodoc
class __$$ProfileImplCopyWithImpl<$Res>
extends _$ProfileCopyWithImpl<$Res, _$ProfileImpl>
implements _$$ProfileImplCopyWith<$Res> {
__$$ProfileImplCopyWithImpl(
_$ProfileImpl _value, $Res Function(_$ProfileImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? label = freezed,
Object? currentGroupName = freezed,
Object? url = null,
Object? lastUpdateDate = freezed,
Object? autoUpdateDuration = null,
Object? userInfo = freezed,
Object? autoUpdate = null,
Object? selectedMap = null,
}) {
return _then(_$ProfileImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
label: freezed == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String?,
currentGroupName: freezed == currentGroupName
? _value.currentGroupName
: currentGroupName // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
lastUpdateDate: freezed == lastUpdateDate
? _value.lastUpdateDate
: lastUpdateDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
autoUpdateDuration: null == autoUpdateDuration
? _value.autoUpdateDuration
: autoUpdateDuration // ignore: cast_nullable_to_non_nullable
as Duration,
userInfo: freezed == userInfo
? _value.userInfo
: userInfo // ignore: cast_nullable_to_non_nullable
as UserInfo?,
autoUpdate: null == autoUpdate
? _value.autoUpdate
: autoUpdate // ignore: cast_nullable_to_non_nullable
as bool,
selectedMap: null == selectedMap
? _value._selectedMap
: selectedMap // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ProfileImpl implements _Profile {
const _$ProfileImpl(
{required this.id,
this.label,
this.currentGroupName,
this.url = "",
this.lastUpdateDate,
required this.autoUpdateDuration,
this.userInfo,
this.autoUpdate = true,
final Map<String, String> selectedMap = const {}})
: _selectedMap = selectedMap;
factory _$ProfileImpl.fromJson(Map<String, dynamic> json) =>
_$$ProfileImplFromJson(json);
@override
final String id;
@override
final String? label;
@override
final String? currentGroupName;
@override
@JsonKey()
final String url;
@override
final DateTime? lastUpdateDate;
@override
final Duration autoUpdateDuration;
@override
final UserInfo? userInfo;
@override
@JsonKey()
final bool autoUpdate;
final Map<String, String> _selectedMap;
@override
@JsonKey()
Map<String, String> get selectedMap {
if (_selectedMap is EqualUnmodifiableMapView) return _selectedMap;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_selectedMap);
}
@override
String toString() {
return 'Profile(id: $id, label: $label, currentGroupName: $currentGroupName, url: $url, lastUpdateDate: $lastUpdateDate, autoUpdateDuration: $autoUpdateDuration, userInfo: $userInfo, autoUpdate: $autoUpdate, selectedMap: $selectedMap)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ProfileImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.currentGroupName, currentGroupName) ||
other.currentGroupName == currentGroupName) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.lastUpdateDate, lastUpdateDate) ||
other.lastUpdateDate == lastUpdateDate) &&
(identical(other.autoUpdateDuration, autoUpdateDuration) ||
other.autoUpdateDuration == autoUpdateDuration) &&
(identical(other.userInfo, userInfo) ||
other.userInfo == userInfo) &&
(identical(other.autoUpdate, autoUpdate) ||
other.autoUpdate == autoUpdate) &&
const DeepCollectionEquality()
.equals(other._selectedMap, _selectedMap));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
id,
label,
currentGroupName,
url,
lastUpdateDate,
autoUpdateDuration,
userInfo,
autoUpdate,
const DeepCollectionEquality().hash(_selectedMap));
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$ProfileImplCopyWith<_$ProfileImpl> get copyWith =>
__$$ProfileImplCopyWithImpl<_$ProfileImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ProfileImplToJson(
this,
);
}
}
abstract class _Profile implements Profile {
const factory _Profile(
{required final String id,
final String? label,
final String? currentGroupName,
final String url,
final DateTime? lastUpdateDate,
required final Duration autoUpdateDuration,
final UserInfo? userInfo,
final bool autoUpdate,
final Map<String, String> selectedMap}) = _$ProfileImpl;
factory _Profile.fromJson(Map<String, dynamic> json) = _$ProfileImpl.fromJson;
@override
String get id;
@override
String? get label;
@override
String? get currentGroupName;
@override
String get url;
@override
DateTime? get lastUpdateDate;
@override
Duration get autoUpdateDuration;
@override
UserInfo? get userInfo;
@override
bool get autoUpdate;
@override
Map<String, String> get selectedMap;
@override
@JsonKey(ignore: true)
_$$ProfileImplCopyWith<_$ProfileImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -6,41 +6,45 @@ part of '../profile.dart';
// JsonSerializableGenerator
// **************************************************************************
UserInfo _$UserInfoFromJson(Map<String, dynamic> json) => UserInfo(
upload: (json['upload'] as num?)?.toInt(),
download: (json['download'] as num?)?.toInt(),
total: (json['total'] as num?)?.toInt(),
expire: (json['expire'] as num?)?.toInt(),
_$UserInfoImpl _$$UserInfoImplFromJson(Map<String, dynamic> json) =>
_$UserInfoImpl(
upload: (json['upload'] as num?)?.toInt() ?? 0,
download: (json['download'] as num?)?.toInt() ?? 0,
total: (json['total'] as num?)?.toInt() ?? 0,
expire: (json['expire'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$UserInfoToJson(UserInfo instance) => <String, dynamic>{
Map<String, dynamic> _$$UserInfoImplToJson(_$UserInfoImpl instance) =>
<String, dynamic>{
'upload': instance.upload,
'download': instance.download,
'total': instance.total,
'expire': instance.expire,
};
Profile _$ProfileFromJson(Map<String, dynamic> json) => Profile(
id: json['id'] as String?,
_$ProfileImpl _$$ProfileImplFromJson(Map<String, dynamic> json) =>
_$ProfileImpl(
id: json['id'] as String,
label: json['label'] as String?,
url: json['url'] as String?,
currentGroupName: json['currentGroupName'] as String?,
userInfo: json['userInfo'] == null
? null
: UserInfo.fromJson(json['userInfo'] as Map<String, dynamic>),
url: json['url'] as String? ?? "",
lastUpdateDate: json['lastUpdateDate'] == null
? null
: DateTime.parse(json['lastUpdateDate'] as String),
selectedMap: (json['selectedMap'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
autoUpdateDuration: json['autoUpdateDuration'] == null
autoUpdateDuration:
Duration(microseconds: (json['autoUpdateDuration'] as num).toInt()),
userInfo: json['userInfo'] == null
? null
: Duration(microseconds: (json['autoUpdateDuration'] as num).toInt()),
: UserInfo.fromJson(json['userInfo'] as Map<String, dynamic>),
autoUpdate: json['autoUpdate'] as bool? ?? true,
selectedMap: (json['selectedMap'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
const {},
);
Map<String, dynamic> _$ProfileToJson(Profile instance) => <String, dynamic>{
Map<String, dynamic> _$$ProfileImplToJson(_$ProfileImpl instance) =>
<String, dynamic>{
'id': instance.id,
'label': instance.label,
'currentGroupName': instance.currentGroupName,

View File

@@ -1,8 +1,12 @@
// ignore_for_file: invalid_annotation_target
import 'package:fl_clash/enum/enum.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generated/log.g.dart';
part 'generated/log.freezed.dart';
@JsonSerializable()
class Log {
@JsonKey(name: "LogLevel")
@@ -31,3 +35,22 @@ class Log {
return 'Log{logLevel: $logLevel, payload: $payload, dateTime: $dateTime}';
}
}
@freezed
class LogsAndKeywords with _$LogsAndKeywords {
const factory LogsAndKeywords({
@Default([]) List<Log> logs,
@Default([]) List<String> keywords,
}) = _LogsAndKeywords;
factory LogsAndKeywords.fromJson(Map<String, Object?> json) =>
_$LogsAndKeywordsFromJson(json);
}
extension LogsAndKeywordsExt on LogsAndKeywords {
List<Log> get filteredLogs => logs
.where(
(log) => {log.logLevel.name}.containsAll(keywords),
)
.toList();
}

View File

@@ -5,39 +5,28 @@ import 'dart:typed_data';
import 'package:fl_clash/clash/core.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/common/common.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generated/profile.g.dart';
part 'generated/profile.freezed.dart';
typedef SelectedMap = Map<String, String>;
@JsonSerializable()
class UserInfo {
int upload;
int download;
int total;
int expire;
@freezed
class UserInfo with _$UserInfo {
const factory UserInfo({
@Default(0) int upload,
@Default(0) int download,
@Default(0) int total,
@Default(0) int expire,
}) = _UserInfo;
UserInfo({
int? upload,
int? download,
int? total,
int? expire,
}) : upload = upload ?? 0,
download = download ?? 0,
total = total ?? 0,
expire = expire ?? 0;
Map<String, dynamic> toJson() {
return _$UserInfoToJson(this);
}
factory UserInfo.fromJson(Map<String, dynamic> json) {
return _$UserInfoFromJson(json);
}
factory UserInfo.fromJson(Map<String, Object?> json) =>
_$UserInfoFromJson(json);
factory UserInfo.formHString(String? info) {
if (info == null) return UserInfo();
if (info == null) return const UserInfo();
final list = info.split(";");
Map<String, int?> map = {};
for (final i in list) {
@@ -45,73 +34,76 @@ class UserInfo {
map[keyValue[0]] = int.tryParse(keyValue[1]);
}
return UserInfo(
upload: map["upload"],
download: map["download"],
total: map["total"],
expire: map["expire"],
upload: map["upload"] ?? 0,
download: map["download"] ?? 0,
total: map["total"] ?? 0,
expire: map["expire"] ?? 0,
);
}
@override
String toString() {
return 'UserInfo{upload: $upload, download: $download, total: $total, expire: $expire}';
}
}
@JsonSerializable()
class Profile {
String id;
String? label;
String? currentGroupName;
String? url;
DateTime? lastUpdateDate;
Duration autoUpdateDuration;
UserInfo? userInfo;
bool autoUpdate;
SelectedMap selectedMap;
@freezed
class Profile with _$Profile {
const factory Profile({
required String id,
String? label,
String? currentGroupName,
@Default("") String url,
DateTime? lastUpdateDate,
required Duration autoUpdateDuration,
UserInfo? userInfo,
@Default(true) bool autoUpdate,
@Default({}) SelectedMap selectedMap,
}) = _Profile;
Profile({
String? id,
this.label,
this.url,
this.currentGroupName,
this.userInfo,
this.lastUpdateDate,
SelectedMap? selectedMap,
Duration? autoUpdateDuration,
this.autoUpdate = true,
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
autoUpdateDuration = autoUpdateDuration ?? defaultUpdateDuration,
selectedMap = selectedMap ?? {};
factory Profile.fromJson(Map<String, Object?> json) =>
_$ProfileFromJson(json);
factory Profile.normal({
String? label,
String url = '',
}) {
return Profile(
label: label,
url: url,
id: DateTime.now().millisecondsSinceEpoch.toString(),
autoUpdateDuration: defaultUpdateDuration,
);
}
}
extension ProfileExtension on Profile {
ProfileType get type =>
url == null || url?.isEmpty == true ? ProfileType.file : ProfileType.url;
url.isEmpty == true ? ProfileType.file : ProfileType.url;
bool get realAutoUpdate =>
url.isEmpty == true ? false : autoUpdate;
Future<void> checkAndUpdate() async {
final isExists = await check();
if (!isExists) {
if (url != null) {
return await update();
if (url.isNotEmpty) {
await update();
}
}
}
Future<void> update() async {
final response = await request.getFileResponseForUrl(url!);
final disposition = response.headers.value("content-disposition");
label ??= other.getFileNameForDisposition(disposition) ?? id;
final userinfo = response.headers.value('subscription-userinfo');
userInfo = UserInfo.formHString(userinfo);
await saveFile(response.data);
lastUpdateDate = DateTime.now();
}
Future<bool> check() async {
final profilePath = await appPath.getProfilePath(id);
return await File(profilePath!).exists();
}
Future<void> saveFile(Uint8List bytes) async {
Future<Profile> update() async {
final response = await request.getFileResponseForUrl(url);
final disposition = response.headers.value("content-disposition");
final userinfo = response.headers.value('subscription-userinfo');
return await copyWith(
label: label ?? other.getFileNameForDisposition(disposition) ?? id,
userInfo: UserInfo.formHString(userinfo),
).saveFile(response.data);
}
Future<Profile> saveFile(Uint8List bytes) async {
final message = await clashCore.validateConfig(utf8.decode(bytes));
if (message.isNotEmpty) {
throw message;
@@ -123,66 +115,21 @@ class Profile {
await file.create(recursive: true);
}
await file.writeAsBytes(bytes);
lastUpdateDate = DateTime.now();
return copyWith(lastUpdateDate: DateTime.now());
}
Map<String, dynamic> toJson() {
return _$ProfileToJson(this);
}
factory Profile.fromJson(Map<String, dynamic> json) {
return _$ProfileFromJson(json);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Profile &&
runtimeType == other.runtimeType &&
id == other.id &&
label == other.label &&
currentGroupName == other.currentGroupName &&
url == other.url &&
lastUpdateDate == other.lastUpdateDate &&
autoUpdateDuration == other.autoUpdateDuration &&
userInfo == other.userInfo &&
autoUpdate == other.autoUpdate &&
selectedMap == other.selectedMap;
@override
int get hashCode =>
id.hashCode ^
label.hashCode ^
currentGroupName.hashCode ^
url.hashCode ^
lastUpdateDate.hashCode ^
autoUpdateDuration.hashCode ^
userInfo.hashCode ^
autoUpdate.hashCode ^
selectedMap.hashCode;
Profile copyWith({
String? label,
String? url,
UserInfo? userInfo,
String? currentGroupName,
String? proxyName,
DateTime? lastUpdateDate,
Duration? autoUpdateDuration,
bool? autoUpdate,
SelectedMap? selectedMap,
}) {
return Profile(
id: id,
label: label ?? this.label,
url: url ?? this.url,
currentGroupName: currentGroupName ?? this.currentGroupName,
userInfo: userInfo ?? this.userInfo,
selectedMap: selectedMap ?? this.selectedMap,
lastUpdateDate: lastUpdateDate ?? this.lastUpdateDate,
autoUpdateDuration: autoUpdateDuration ?? this.autoUpdateDuration,
autoUpdate: autoUpdate ?? this.autoUpdate,
);
Future<Profile> saveFileWithString(String value) async {
final message = await clashCore.validateConfig(value);
if (message.isNotEmpty) {
throw message;
}
final path = await appPath.getProfilePath(id);
final file = File(path!);
final isExists = await file.exists();
if (!isExists) {
await file.create(recursive: true);
}
await file.writeAsString(value);
return copyWith(lastUpdateDate: DateTime.now());
}
}

View File

@@ -57,6 +57,7 @@ class GlobalState {
}
Future<void> startSystemProxy({
required AppState appState,
required Config config,
required ClashConfig clashConfig,
}) async {
@@ -73,6 +74,11 @@ class GlobalState {
args: args,
);
startListenUpdate();
applyProfile(
appState: appState,
config: config,
clashConfig: clashConfig,
);
}
Future<void> stopSystemProxy() async {
@@ -195,6 +201,7 @@ class GlobalState {
final traffic = clashCore.getTraffic();
if (appState != null) {
appState.addTraffic(traffic);
appState.totalTraffic = clashCore.getTotalTraffic();
}
if (Platform.isAndroid) {
final currentProfile = config.currentProfile;

View File

@@ -1,21 +1,36 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/material.dart';
class CommonChip extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final ChipType type;
final Widget? avatar;
const CommonChip({
super.key,
required this.label,
this.onPressed,
this.avatar,
this.type = ChipType.action,
});
@override
Widget build(BuildContext context) {
return Chip(
if(type == ChipType.delete){
return Chip(
avatar: avatar,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onDeleted: onPressed ?? () {},
side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.2)),
labelStyle: Theme.of(context).textTheme.bodyMedium,
label: Text(label),
);
}
return ActionChip(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(
vertical: 0,
horizontal: 4,
),
avatar: avatar,
onPressed: onPressed ?? () {},
side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.2)),
labelStyle: Theme.of(context).textTheme.bodyMedium,
label: Text(label),

View File

@@ -11,7 +11,7 @@ class NullStatus extends StatelessWidget {
return Center(
child: Text(
label,
style: Theme.of(context).textTheme.titleMedium?.toBold(),
style: Theme.of(context).textTheme.titleMedium?.toBold,
),
);
}

View File

@@ -53,6 +53,12 @@ class _CommonPopupMenuState<T> extends State<CommonPopupMenu<T>> {
widget.onSelected(value);
}
@override
void dispose() {
groupValue.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopupMenuButton<T>(

View File

@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
class CommonScaffold extends StatefulWidget {
final Widget body;
final Widget? bottomNavigationBar;
final Widget? floatingActionButton;
final String title;
final Widget? leading;
final List<Widget>? actions;
@@ -19,6 +20,7 @@ class CommonScaffold extends StatefulWidget {
this.leading,
required this.title,
this.actions,
this.floatingActionButton,
this.automaticallyImplyLeading = true,
});
@@ -86,6 +88,13 @@ class CommonScaffoldState extends State<CommonScaffold> {
}
}
@override
void dispose() {
_actions.dispose();
_floatingActionButton.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant CommonScaffold oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -116,12 +125,13 @@ class CommonScaffoldState extends State<CommonScaffold> {
Widget build(BuildContext context) {
return _platformContainer(
child: Scaffold(
floatingActionButton: ValueListenableBuilder(
valueListenable: _floatingActionButton,
builder: (_, floatingActionButton, __) {
return floatingActionButton ?? Container();
},
),
floatingActionButton: widget.floatingActionButton ??
ValueListenableBuilder(
valueListenable: _floatingActionButton,
builder: (_, floatingActionButton, __) {
return floatingActionButton ?? Container();
},
),
resizeToAvoidBottomInset: true,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
name: fl_clash
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
publish_to: 'none'
version: 0.8.24
version: 0.8.27
environment:
sdk: '>=3.1.0 <4.0.0'
@@ -17,7 +17,7 @@ dependencies:
provider: ^6.0.5
window_manager: ^0.3.8
ffi: ^2.1.0
dynamic_color: ^1.6.0
dynamic_color: ^1.7.0
proxy:
path: plugins/proxy
launch_at_startup: ^0.2.2
@@ -39,6 +39,8 @@ dependencies:
webdav_client: ^1.2.2
dio: ^5.4.3+1
country_flags: ^2.2.0
re_editor: ^0.3.0
re_highlight: ^0.0.3
dev_dependencies:
flutter_test:
sdk: flutter