feat(profiles): manage external resources from the profile details

Changelog: Profile-detail resources and include-all in custom groups
This commit is contained in:
chen08209
2026-10-16 20:56:11 +08:00
parent 2ea52fdfdc
commit 0bac50029a
53 changed files with 1716 additions and 2332 deletions
+3 -1
View File
@@ -922,5 +922,7 @@
"done": "Done",
"dashboardAddableSection": "Available to add",
"customImportDroppedSubRules": "{count, plural, =1{1 SUB-RULE rule was dropped, since custom profiles do not support sub-rules} other{{label} SUB-RULE rules were dropped, since custom profiles do not support sub-rules}}",
"customImportDroppedIncludeAll": "Dropped include-all from the proxy groups {groups}; add the proxies or proxy providers they use"
"autoInclude": "Auto-include",
"allReferencedProxies": "All referenced proxies",
"allReferencedProxyProviders": "All referenced proxy providers"
}
+3 -1
View File
@@ -922,5 +922,7 @@
"done": "完了",
"dashboardAddableSection": "追加できるカード",
"customImportDroppedSubRules": "{count, plural, other{カスタム設定はサブルールに対応していないため、SUB-RULE ルール {label} 件を破棄しました}}",
"customImportDroppedIncludeAll": "プロキシグループ {groups} の include-all を破棄しました。これらのグループで使うプロキシまたはプロキシプロバイダーを追加してください"
"autoInclude": "自動で含める",
"allReferencedProxies": "参照されているすべてのプロキシ",
"allReferencedProxyProviders": "参照されているすべてのプロキシプロバイダー"
}
+3 -1
View File
@@ -922,5 +922,7 @@
"done": "Готово",
"dashboardAddableSection": "Можно добавить",
"customImportDroppedSubRules": "{count, plural, one{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правило SUB-RULE} few{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правила SUB-RULE} many{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правил SUB-RULE} other{Пользовательские профили не поддерживают подправила, поэтому отброшено {label} правила SUB-RULE}}",
"customImportDroppedIncludeAll": "У групп прокси {groups} отброшен include-all; добавьте прокси или провайдеры прокси, которые они используют"
"autoInclude": "Автоматическое включение",
"allReferencedProxies": "Все используемые прокси",
"allReferencedProxyProviders": "Все используемые провайдеры прокси"
}
+3 -1
View File
@@ -922,5 +922,7 @@
"done": "完成",
"dashboardAddableSection": "可添加",
"customImportDroppedSubRules": "{count, plural, other{自定义配置不支持子规则,已丢弃 {label} 条 SUB-RULE 规则}}",
"customImportDroppedIncludeAll": "已丢弃策略组 {groups} 的 include-all,请为这些策略组添加代理或代理集"
"autoInclude": "自动包含",
"allReferencedProxies": "全部被引用的代理",
"allReferencedProxyProviders": "全部被引用的代理集"
}
+4
View File
@@ -17,6 +17,10 @@ abstract final class TextInputLimits {
static const portRange = 11;
static const number = 10;
static const interval = number;
/// mihomo multiplies these hours into a Duration, which overflows past
/// about 2.5 million and panics its update ticker.
static const geoUpdateInterval = 6;
static const search = 256;
static const rule = 1024;
static const filter = 1024;
+1 -1
View File
@@ -45,7 +45,7 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
return stmt.map((item) => item.toProxyGroup());
}
Selectable<int> count(int profileId) {
Selectable<int> profileGroupsCount(int profileId) {
final stmt = proxyGroups.select();
stmt.where((row) => row.profileId.equals(profileId));
return stmt.count;
+38 -39
View File
@@ -88,7 +88,7 @@ enum EditorSchema {
ntp(_ntp, 'ntp'),
sniffer(_sniffer, 'sniffer'),
proxy(_proxy),
proxyGroup(_proxyGroup, 'proxy-groups'),
proxyGroup(_customProxyGroup, 'proxy-groups'),
rules(_rules),
shareLinks(_str),
provider(_providerFile);
@@ -1282,51 +1282,50 @@ const _healthCheck = YamlSchema.map({
'expected-status': _str,
});
const _groupFilters = {
'include-all': _bool,
'include-all-proxies': _bool,
'include-all-providers': _bool,
const _proxyGroupFields = {
'name': _str,
'type': YamlSchema.scalar(['select', 'url-test', 'fallback', 'load-balance']),
'proxies': YamlSchema.list(_policy),
'use': YamlSchema.list(YamlSchema.of(YamlScalar.proxyProvider)),
'use-position': YamlSchema.dict(_str),
'url': YamlSchema.scalar(['https://www.gstatic.com/generate_204']),
'interval': _str,
'lazy': _bool,
'timeout': _str,
'max-failed-times': _str,
'empty-fallback': _policy,
'expected-status': _str,
'filter': _str,
'exclude-filter': _str,
'exclude-type': _str,
'disable-udp': _bool,
'hidden': _bool,
'icon': _str,
};
const _proxyGroup = YamlSchema.map(
{
'name': _str,
'type': YamlSchema.scalar([
'select',
'url-test',
'fallback',
'load-balance',
const _proxyGroupVariants = {
'select': {'default-selected': _policy},
'url-test': {'tolerance': _str},
'load-balance': {
'strategy': YamlSchema.scalar([
'consistent-hashing',
'round-robin',
'sticky-sessions',
]),
'proxies': YamlSchema.list(_policy),
'use': YamlSchema.list(YamlSchema.of(YamlScalar.proxyProvider)),
'use-position': YamlSchema.dict(_str),
'url': YamlSchema.scalar(['https://www.gstatic.com/generate_204']),
'interval': _str,
'lazy': _bool,
'timeout': _str,
'max-failed-times': _str,
'empty-fallback': _policy,
'expected-status': _str,
..._groupFilters,
'disable-udp': _bool,
'hidden': _bool,
'icon': _str,
},
variants: {
'select': {'default-selected': _policy},
'url-test': {'tolerance': _str},
'load-balance': {
'strategy': YamlSchema.scalar([
'consistent-hashing',
'round-robin',
'sticky-sessions',
]),
'hash-key': YamlSchema.scalar(['in-user']),
},
'hash-key': YamlSchema.scalar(['in-user']),
},
};
const _proxyGroup = YamlSchema.map({
..._proxyGroupFields,
'include-all': _bool,
'include-all-proxies': _bool,
'include-all-providers': _bool,
}, variants: _proxyGroupVariants);
const _customProxyGroup = YamlSchema.map(
_proxyGroupFields,
variants: _proxyGroupVariants,
);
const _providerType = YamlSchema.scalar(['http', 'file', 'inline']);
+6 -5
View File
@@ -1,7 +1,8 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'form_row.dart';
@@ -153,13 +154,13 @@ class _Banner extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
GlyphIcon(glyph, size: 20.ap, color: foregroundColor),
GlyphIcon(glyph, size: 20.ap(context), color: foregroundColor),
Expanded(
child: Text(
message,
style: context.textTheme.bodyMedium?.copyWith(
color: foregroundColor,
),
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: foregroundColor),
),
),
],
+1 -2
View File
@@ -315,8 +315,7 @@ class _RuleFormState extends State<RuleForm> {
return;
}
Navigator.of(context).pop(
Rule(
id: widget.rule?.id ?? snowflake.id,
(widget.rule ?? Rule(id: snowflake.id)).copyWith(
ruleAction: _ruleAction,
content: _contentController.text,
ruleTarget: _ruleTarget,
+25 -5
View File
@@ -7717,13 +7717,33 @@ class AppLocalizations {
);
}
/// `Dropped include-all from the proxy groups {groups}; add the proxies or proxy providers they use`
String customImportDroppedIncludeAll(Object groups) {
/// `Auto-include`
String get autoInclude {
return Intl.message(
'Dropped include-all from the proxy groups $groups; add the proxies or proxy providers they use',
name: 'customImportDroppedIncludeAll',
'Auto-include',
name: 'autoInclude',
desc: '',
args: [groups],
args: [],
);
}
/// `All referenced proxies`
String get allReferencedProxies {
return Intl.message(
'All referenced proxies',
name: 'allReferencedProxies',
desc: '',
args: [],
);
}
/// `All referenced proxy providers`
String get allReferencedProxyProviders {
return Intl.message(
'All referenced proxy providers',
name: 'allReferencedProxyProviders',
desc: '',
args: [],
);
}
}
+10 -24
View File
@@ -61,6 +61,10 @@ const privateRouteAddress = [
'ff00::/8',
];
Object? _readIncludeAll(Map<dynamic, dynamic> json, String key) {
return json['include-all'] == true ? true : json[key];
}
@freezed
abstract class ProxyGroup with _$ProxyGroup {
const factory ProxyGroup({
@@ -86,9 +90,10 @@ abstract class ProxyGroup with _$ProxyGroup {
@JsonKey(name: 'hash-key') String? hashKey,
@JsonKey(name: 'default-selected') String? defaultSelected,
@JsonKey(name: 'empty-fallback') String? emptyFallback,
@JsonKey(name: 'include-all') bool? includeAll,
@JsonKey(name: 'include-all-proxies') bool? includeAllProxies,
@JsonKey(name: 'include-all-providers') bool? includeAllProviders,
@JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll)
bool? includeAllProxies,
@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll)
bool? includeAllProviders,
bool? hidden,
String? icon,
String? order,
@@ -123,10 +128,8 @@ extension ProxyGroupExt on ProxyGroup {
if (excludeFilter?.isNotEmpty == true) 'exclude-filter': excludeFilter,
if (excludeType?.isNotEmpty == true) 'exclude-type': excludeType,
if (expectedStatus?.isNotEmpty == true) 'expected-status': expectedStatus,
if (includeAll != null) 'include-all': includeAll,
if (includeAllProxies != null) 'include-all-proxies': includeAllProxies,
if (includeAllProviders != null)
'include-all-providers': includeAllProviders,
if (includeAllProxies == true) 'include-all-proxies': true,
if (includeAllProviders == true) 'include-all-providers': true,
if (hidden != null) 'hidden': hidden,
if (icon?.isNotEmpty == true) 'icon': icon,
if (type == GroupType.URLTest && tolerance != null)
@@ -851,22 +854,6 @@ extension CustomProfileDataExt on CustomProfileData {
namedProxies.contains(name);
}
@freezed
abstract class RuleProvider with _$RuleProvider {
const factory RuleProvider({required String name}) = _RuleProvider;
factory RuleProvider.fromJson(Map<String, Object?> json) =>
_$RuleProviderFromJson(json);
}
@freezed
abstract class ProxyProvider with _$ProxyProvider {
const factory ProxyProvider({required String name}) = _ProxyProvider;
factory ProxyProvider.fromJson(Map<String, Object?> json) =>
_$ProxyProviderFromJson(json);
}
@freezed
abstract class Sniffer with _$Sniffer {
const factory Sniffer({
@@ -1869,7 +1856,6 @@ abstract class ClashConfig with _$ClashConfig {
@JsonKey(name: 'sub-rules', fromJson: _genList)
@Default([])
List<String> subRules,
@Default({}) Map<String, String> proxyTypeMap,
}) = _ClashConfig;
factory ClashConfig.fromJson(Map<String, Object?> json) =>
+42 -594
View File
@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProxyGroup {
int? get profileId;@JsonKey(fromJson: Snowflake.buildId) int get id; String get name; GroupType get type; List<String>? get proxies; List<String>? get use;@JsonKey(name: 'use-position') Map<String, int>? get usePosition; int? get interval; bool? get lazy;@JsonKey(name: 'disable-udp') bool? get disableUDP; String? get url; int? get timeout;@JsonKey(name: 'max-failed-times') int? get maxFailedTimes; String? get filter;@JsonKey(name: 'exclude-filter') String? get excludeFilter;@JsonKey(name: 'exclude-type') String? get excludeType;@JsonKey(name: 'expected-status') String? get expectedStatus; int? get tolerance; LoadBalanceStrategy? get strategy;@JsonKey(name: 'hash-key') String? get hashKey;@JsonKey(name: 'default-selected') String? get defaultSelected;@JsonKey(name: 'empty-fallback') String? get emptyFallback;@JsonKey(name: 'include-all') bool? get includeAll;@JsonKey(name: 'include-all-proxies') bool? get includeAllProxies;@JsonKey(name: 'include-all-providers') bool? get includeAllProviders; bool? get hidden; String? get icon; String? get order;/// Where the members list shows the filters among the proxies, which no
int? get profileId;@JsonKey(fromJson: Snowflake.buildId) int get id; String get name; GroupType get type; List<String>? get proxies; List<String>? get use;@JsonKey(name: 'use-position') Map<String, int>? get usePosition; int? get interval; bool? get lazy;@JsonKey(name: 'disable-udp') bool? get disableUDP; String? get url; int? get timeout;@JsonKey(name: 'max-failed-times') int? get maxFailedTimes; String? get filter;@JsonKey(name: 'exclude-filter') String? get excludeFilter;@JsonKey(name: 'exclude-type') String? get excludeType;@JsonKey(name: 'expected-status') String? get expectedStatus; int? get tolerance; LoadBalanceStrategy? get strategy;@JsonKey(name: 'hash-key') String? get hashKey;@JsonKey(name: 'default-selected') String? get defaultSelected;@JsonKey(name: 'empty-fallback') String? get emptyFallback;@JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? get includeAllProxies;@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? get includeAllProviders; bool? get hidden; String? get icon; String? get order;/// Where the members list shows the filters among the proxies, which no
/// config carries, as `ProxyGroupMembersSliver` writes it.
List<String>? get memberLayout;
/// Create a copy of ProxyGroup
@@ -32,20 +32,20 @@ $ProxyGroupCopyWith<ProxyGroup> get copyWith => _$ProxyGroupCopyWithImpl<ProxyGr
@override
bool operator ==(Object other) {
final _this = this as ProxyGroup;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProxyGroup&&(identical(other.profileId, _this.profileId) || other.profileId == _this.profileId)&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.name, _this.name) || other.name == _this.name)&&(identical(other.type, _this.type) || other.type == _this.type)&&const DeepCollectionEquality().equals(other.proxies, _this.proxies)&&const DeepCollectionEquality().equals(other.use, _this.use)&&const DeepCollectionEquality().equals(other.usePosition, _this.usePosition)&&(identical(other.interval, _this.interval) || other.interval == _this.interval)&&(identical(other.lazy, _this.lazy) || other.lazy == _this.lazy)&&(identical(other.disableUDP, _this.disableUDP) || other.disableUDP == _this.disableUDP)&&(identical(other.url, _this.url) || other.url == _this.url)&&(identical(other.timeout, _this.timeout) || other.timeout == _this.timeout)&&(identical(other.maxFailedTimes, _this.maxFailedTimes) || other.maxFailedTimes == _this.maxFailedTimes)&&(identical(other.filter, _this.filter) || other.filter == _this.filter)&&(identical(other.excludeFilter, _this.excludeFilter) || other.excludeFilter == _this.excludeFilter)&&(identical(other.excludeType, _this.excludeType) || other.excludeType == _this.excludeType)&&(identical(other.expectedStatus, _this.expectedStatus) || other.expectedStatus == _this.expectedStatus)&&(identical(other.tolerance, _this.tolerance) || other.tolerance == _this.tolerance)&&(identical(other.strategy, _this.strategy) || other.strategy == _this.strategy)&&(identical(other.hashKey, _this.hashKey) || other.hashKey == _this.hashKey)&&(identical(other.defaultSelected, _this.defaultSelected) || other.defaultSelected == _this.defaultSelected)&&(identical(other.emptyFallback, _this.emptyFallback) || other.emptyFallback == _this.emptyFallback)&&(identical(other.includeAll, _this.includeAll) || other.includeAll == _this.includeAll)&&(identical(other.includeAllProxies, _this.includeAllProxies) || other.includeAllProxies == _this.includeAllProxies)&&(identical(other.includeAllProviders, _this.includeAllProviders) || other.includeAllProviders == _this.includeAllProviders)&&(identical(other.hidden, _this.hidden) || other.hidden == _this.hidden)&&(identical(other.icon, _this.icon) || other.icon == _this.icon)&&(identical(other.order, _this.order) || other.order == _this.order)&&const DeepCollectionEquality().equals(other.memberLayout, _this.memberLayout));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProxyGroup&&(identical(other.profileId, _this.profileId) || other.profileId == _this.profileId)&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.name, _this.name) || other.name == _this.name)&&(identical(other.type, _this.type) || other.type == _this.type)&&const DeepCollectionEquality().equals(other.proxies, _this.proxies)&&const DeepCollectionEquality().equals(other.use, _this.use)&&const DeepCollectionEquality().equals(other.usePosition, _this.usePosition)&&(identical(other.interval, _this.interval) || other.interval == _this.interval)&&(identical(other.lazy, _this.lazy) || other.lazy == _this.lazy)&&(identical(other.disableUDP, _this.disableUDP) || other.disableUDP == _this.disableUDP)&&(identical(other.url, _this.url) || other.url == _this.url)&&(identical(other.timeout, _this.timeout) || other.timeout == _this.timeout)&&(identical(other.maxFailedTimes, _this.maxFailedTimes) || other.maxFailedTimes == _this.maxFailedTimes)&&(identical(other.filter, _this.filter) || other.filter == _this.filter)&&(identical(other.excludeFilter, _this.excludeFilter) || other.excludeFilter == _this.excludeFilter)&&(identical(other.excludeType, _this.excludeType) || other.excludeType == _this.excludeType)&&(identical(other.expectedStatus, _this.expectedStatus) || other.expectedStatus == _this.expectedStatus)&&(identical(other.tolerance, _this.tolerance) || other.tolerance == _this.tolerance)&&(identical(other.strategy, _this.strategy) || other.strategy == _this.strategy)&&(identical(other.hashKey, _this.hashKey) || other.hashKey == _this.hashKey)&&(identical(other.defaultSelected, _this.defaultSelected) || other.defaultSelected == _this.defaultSelected)&&(identical(other.emptyFallback, _this.emptyFallback) || other.emptyFallback == _this.emptyFallback)&&(identical(other.includeAllProxies, _this.includeAllProxies) || other.includeAllProxies == _this.includeAllProxies)&&(identical(other.includeAllProviders, _this.includeAllProviders) || other.includeAllProviders == _this.includeAllProviders)&&(identical(other.hidden, _this.hidden) || other.hidden == _this.hidden)&&(identical(other.icon, _this.icon) || other.icon == _this.icon)&&(identical(other.order, _this.order) || other.order == _this.order)&&const DeepCollectionEquality().equals(other.memberLayout, _this.memberLayout));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ProxyGroup;
return Object.hashAll([runtimeType,_this.profileId,_this.id,_this.name,_this.type,const DeepCollectionEquality().hash(_this.proxies),const DeepCollectionEquality().hash(_this.use),const DeepCollectionEquality().hash(_this.usePosition),_this.interval,_this.lazy,_this.disableUDP,_this.url,_this.timeout,_this.maxFailedTimes,_this.filter,_this.excludeFilter,_this.excludeType,_this.expectedStatus,_this.tolerance,_this.strategy,_this.hashKey,_this.defaultSelected,_this.emptyFallback,_this.includeAll,_this.includeAllProxies,_this.includeAllProviders,_this.hidden,_this.icon,_this.order,const DeepCollectionEquality().hash(_this.memberLayout)]);
return Object.hashAll([runtimeType,_this.profileId,_this.id,_this.name,_this.type,const DeepCollectionEquality().hash(_this.proxies),const DeepCollectionEquality().hash(_this.use),const DeepCollectionEquality().hash(_this.usePosition),_this.interval,_this.lazy,_this.disableUDP,_this.url,_this.timeout,_this.maxFailedTimes,_this.filter,_this.excludeFilter,_this.excludeType,_this.expectedStatus,_this.tolerance,_this.strategy,_this.hashKey,_this.defaultSelected,_this.emptyFallback,_this.includeAllProxies,_this.includeAllProviders,_this.hidden,_this.icon,_this.order,const DeepCollectionEquality().hash(_this.memberLayout)]);
}
@override
String toString() {
final _this = this as ProxyGroup;
return 'ProxyGroup(profileId: ${_this.profileId}, id: ${_this.id}, name: ${_this.name}, type: ${_this.type}, proxies: ${_this.proxies}, use: ${_this.use}, usePosition: ${_this.usePosition}, interval: ${_this.interval}, lazy: ${_this.lazy}, disableUDP: ${_this.disableUDP}, url: ${_this.url}, timeout: ${_this.timeout}, maxFailedTimes: ${_this.maxFailedTimes}, filter: ${_this.filter}, excludeFilter: ${_this.excludeFilter}, excludeType: ${_this.excludeType}, expectedStatus: ${_this.expectedStatus}, tolerance: ${_this.tolerance}, strategy: ${_this.strategy}, hashKey: ${_this.hashKey}, defaultSelected: ${_this.defaultSelected}, emptyFallback: ${_this.emptyFallback}, includeAll: ${_this.includeAll}, includeAllProxies: ${_this.includeAllProxies}, includeAllProviders: ${_this.includeAllProviders}, hidden: ${_this.hidden}, icon: ${_this.icon}, order: ${_this.order}, memberLayout: ${_this.memberLayout})';
return 'ProxyGroup(profileId: ${_this.profileId}, id: ${_this.id}, name: ${_this.name}, type: ${_this.type}, proxies: ${_this.proxies}, use: ${_this.use}, usePosition: ${_this.usePosition}, interval: ${_this.interval}, lazy: ${_this.lazy}, disableUDP: ${_this.disableUDP}, url: ${_this.url}, timeout: ${_this.timeout}, maxFailedTimes: ${_this.maxFailedTimes}, filter: ${_this.filter}, excludeFilter: ${_this.excludeFilter}, excludeType: ${_this.excludeType}, expectedStatus: ${_this.expectedStatus}, tolerance: ${_this.tolerance}, strategy: ${_this.strategy}, hashKey: ${_this.hashKey}, defaultSelected: ${_this.defaultSelected}, emptyFallback: ${_this.emptyFallback}, includeAllProxies: ${_this.includeAllProxies}, includeAllProviders: ${_this.includeAllProviders}, hidden: ${_this.hidden}, icon: ${_this.icon}, order: ${_this.order}, memberLayout: ${_this.memberLayout})';
}
@@ -56,7 +56,7 @@ abstract mixin class $ProxyGroupCopyWith<$Res> {
factory $ProxyGroupCopyWith(ProxyGroup value, $Res Function(ProxyGroup) _then) = _$ProxyGroupCopyWithImpl;
@useResult
$Res call({
int? profileId,@JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use,@JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy,@JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout,@JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter,@JsonKey(name: 'exclude-filter') String? excludeFilter,@JsonKey(name: 'exclude-type') String? excludeType,@JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy,@JsonKey(name: 'hash-key') String? hashKey,@JsonKey(name: 'default-selected') String? defaultSelected,@JsonKey(name: 'empty-fallback') String? emptyFallback,@JsonKey(name: 'include-all') bool? includeAll,@JsonKey(name: 'include-all-proxies') bool? includeAllProxies,@JsonKey(name: 'include-all-providers') bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout
int? profileId,@JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use,@JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy,@JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout,@JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter,@JsonKey(name: 'exclude-filter') String? excludeFilter,@JsonKey(name: 'exclude-type') String? excludeType,@JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy,@JsonKey(name: 'hash-key') String? hashKey,@JsonKey(name: 'default-selected') String? defaultSelected,@JsonKey(name: 'empty-fallback') String? emptyFallback,@JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? includeAllProxies,@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout
});
@@ -73,7 +73,7 @@ class _$ProxyGroupCopyWithImpl<$Res>
/// Create a copy of ProxyGroup
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? profileId = freezed,Object? id = null,Object? name = null,Object? type = null,Object? proxies = freezed,Object? use = freezed,Object? usePosition = freezed,Object? interval = freezed,Object? lazy = freezed,Object? disableUDP = freezed,Object? url = freezed,Object? timeout = freezed,Object? maxFailedTimes = freezed,Object? filter = freezed,Object? excludeFilter = freezed,Object? excludeType = freezed,Object? expectedStatus = freezed,Object? tolerance = freezed,Object? strategy = freezed,Object? hashKey = freezed,Object? defaultSelected = freezed,Object? emptyFallback = freezed,Object? includeAll = freezed,Object? includeAllProxies = freezed,Object? includeAllProviders = freezed,Object? hidden = freezed,Object? icon = freezed,Object? order = freezed,Object? memberLayout = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? profileId = freezed,Object? id = null,Object? name = null,Object? type = null,Object? proxies = freezed,Object? use = freezed,Object? usePosition = freezed,Object? interval = freezed,Object? lazy = freezed,Object? disableUDP = freezed,Object? url = freezed,Object? timeout = freezed,Object? maxFailedTimes = freezed,Object? filter = freezed,Object? excludeFilter = freezed,Object? excludeType = freezed,Object? expectedStatus = freezed,Object? tolerance = freezed,Object? strategy = freezed,Object? hashKey = freezed,Object? defaultSelected = freezed,Object? emptyFallback = freezed,Object? includeAllProxies = freezed,Object? includeAllProviders = freezed,Object? hidden = freezed,Object? icon = freezed,Object? order = freezed,Object? memberLayout = freezed,}) {
return _then(ProxyGroup(
profileId: freezed == profileId ? _self.profileId : profileId // ignore: cast_nullable_to_non_nullable
as int?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
@@ -97,8 +97,7 @@ as int?,strategy: freezed == strategy ? _self.strategy : strategy // ignore: cas
as LoadBalanceStrategy?,hashKey: freezed == hashKey ? _self.hashKey : hashKey // ignore: cast_nullable_to_non_nullable
as String?,defaultSelected: freezed == defaultSelected ? _self.defaultSelected : defaultSelected // ignore: cast_nullable_to_non_nullable
as String?,emptyFallback: freezed == emptyFallback ? _self.emptyFallback : emptyFallback // ignore: cast_nullable_to_non_nullable
as String?,includeAll: freezed == includeAll ? _self.includeAll : includeAll // ignore: cast_nullable_to_non_nullable
as bool?,includeAllProxies: freezed == includeAllProxies ? _self.includeAllProxies : includeAllProxies // ignore: cast_nullable_to_non_nullable
as String?,includeAllProxies: freezed == includeAllProxies ? _self.includeAllProxies : includeAllProxies // ignore: cast_nullable_to_non_nullable
as bool?,includeAllProviders: freezed == includeAllProviders ? _self.includeAllProviders : includeAllProviders // ignore: cast_nullable_to_non_nullable
as bool?,hidden: freezed == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable
as bool?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
@@ -189,10 +188,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all') bool? includeAll, @JsonKey(name: 'include-all-proxies') bool? includeAllProxies, @JsonKey(name: 'include-all-providers') bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? includeAllProxies, @JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProxyGroup() when $default != null:
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAll,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
return orElse();
}
@@ -210,10 +209,10 @@ return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_th
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all') bool? includeAll, @JsonKey(name: 'include-all-proxies') bool? includeAllProxies, @JsonKey(name: 'include-all-providers') bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? includeAllProxies, @JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout) $default,) {final _that = this;
switch (_that) {
case _ProxyGroup():
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAll,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
throw StateError('Unexpected subclass');
}
@@ -230,10 +229,10 @@ return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_th
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all') bool? includeAll, @JsonKey(name: 'include-all-proxies') bool? includeAllProxies, @JsonKey(name: 'include-all-providers') bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? profileId, @JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy, @JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout, @JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter, @JsonKey(name: 'exclude-filter') String? excludeFilter, @JsonKey(name: 'exclude-type') String? excludeType, @JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy, @JsonKey(name: 'hash-key') String? hashKey, @JsonKey(name: 'default-selected') String? defaultSelected, @JsonKey(name: 'empty-fallback') String? emptyFallback, @JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? includeAllProxies, @JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout)? $default,) {final _that = this;
switch (_that) {
case _ProxyGroup() when $default != null:
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAll,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_that.use,_that.usePosition,_that.interval,_that.lazy,_that.disableUDP,_that.url,_that.timeout,_that.maxFailedTimes,_that.filter,_that.excludeFilter,_that.excludeType,_that.expectedStatus,_that.tolerance,_that.strategy,_that.hashKey,_that.defaultSelected,_that.emptyFallback,_that.includeAllProxies,_that.includeAllProviders,_that.hidden,_that.icon,_that.order,_that.memberLayout);case _:
return null;
}
@@ -245,7 +244,7 @@ return $default(_that.profileId,_that.id,_that.name,_that.type,_that.proxies,_th
@JsonSerializable()
class _ProxyGroup implements ProxyGroup {
const _ProxyGroup({this.profileId, @JsonKey(fromJson: Snowflake.buildId) required this.id, required this.name, required this.type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, this.interval, this.lazy, @JsonKey(name: 'disable-udp') this.disableUDP, this.url, this.timeout, @JsonKey(name: 'max-failed-times') this.maxFailedTimes, this.filter, @JsonKey(name: 'exclude-filter') this.excludeFilter, @JsonKey(name: 'exclude-type') this.excludeType, @JsonKey(name: 'expected-status') this.expectedStatus, this.tolerance, this.strategy, @JsonKey(name: 'hash-key') this.hashKey, @JsonKey(name: 'default-selected') this.defaultSelected, @JsonKey(name: 'empty-fallback') this.emptyFallback, @JsonKey(name: 'include-all') this.includeAll, @JsonKey(name: 'include-all-proxies') this.includeAllProxies, @JsonKey(name: 'include-all-providers') this.includeAllProviders, this.hidden, this.icon, this.order, List<String>? memberLayout}): _proxies = proxies,_use = use,_usePosition = usePosition,_memberLayout = memberLayout;
const _ProxyGroup({this.profileId, @JsonKey(fromJson: Snowflake.buildId) required this.id, required this.name, required this.type, List<String>? proxies, List<String>? use, @JsonKey(name: 'use-position') Map<String, int>? usePosition, this.interval, this.lazy, @JsonKey(name: 'disable-udp') this.disableUDP, this.url, this.timeout, @JsonKey(name: 'max-failed-times') this.maxFailedTimes, this.filter, @JsonKey(name: 'exclude-filter') this.excludeFilter, @JsonKey(name: 'exclude-type') this.excludeType, @JsonKey(name: 'expected-status') this.expectedStatus, this.tolerance, this.strategy, @JsonKey(name: 'hash-key') this.hashKey, @JsonKey(name: 'default-selected') this.defaultSelected, @JsonKey(name: 'empty-fallback') this.emptyFallback, @JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) this.includeAllProxies, @JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) this.includeAllProviders, this.hidden, this.icon, this.order, List<String>? memberLayout}): _proxies = proxies,_use = use,_usePosition = usePosition,_memberLayout = memberLayout;
factory _ProxyGroup.fromJson(Map<String, dynamic> json) => _$ProxyGroupFromJson(json);
@override final int? profileId;
@@ -294,9 +293,8 @@ class _ProxyGroup implements ProxyGroup {
@override@JsonKey(name: 'hash-key') final String? hashKey;
@override@JsonKey(name: 'default-selected') final String? defaultSelected;
@override@JsonKey(name: 'empty-fallback') final String? emptyFallback;
@override@JsonKey(name: 'include-all') final bool? includeAll;
@override@JsonKey(name: 'include-all-proxies') final bool? includeAllProxies;
@override@JsonKey(name: 'include-all-providers') final bool? includeAllProviders;
@override@JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) final bool? includeAllProxies;
@override@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) final bool? includeAllProviders;
@override final bool? hidden;
@override final String? icon;
@override final String? order;
@@ -327,18 +325,18 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProxyGroup&&(identical(other.profileId, profileId) || other.profileId == profileId)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.proxies, _proxies)&&const DeepCollectionEquality().equals(other.use, _use)&&const DeepCollectionEquality().equals(other.usePosition, _usePosition)&&(identical(other.interval, interval) || other.interval == interval)&&(identical(other.lazy, lazy) || other.lazy == lazy)&&(identical(other.disableUDP, disableUDP) || other.disableUDP == disableUDP)&&(identical(other.url, url) || other.url == url)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.maxFailedTimes, maxFailedTimes) || other.maxFailedTimes == maxFailedTimes)&&(identical(other.filter, filter) || other.filter == filter)&&(identical(other.excludeFilter, excludeFilter) || other.excludeFilter == excludeFilter)&&(identical(other.excludeType, excludeType) || other.excludeType == excludeType)&&(identical(other.expectedStatus, expectedStatus) || other.expectedStatus == expectedStatus)&&(identical(other.tolerance, tolerance) || other.tolerance == tolerance)&&(identical(other.strategy, strategy) || other.strategy == strategy)&&(identical(other.hashKey, hashKey) || other.hashKey == hashKey)&&(identical(other.defaultSelected, defaultSelected) || other.defaultSelected == defaultSelected)&&(identical(other.emptyFallback, emptyFallback) || other.emptyFallback == emptyFallback)&&(identical(other.includeAll, includeAll) || other.includeAll == includeAll)&&(identical(other.includeAllProxies, includeAllProxies) || other.includeAllProxies == includeAllProxies)&&(identical(other.includeAllProviders, includeAllProviders) || other.includeAllProviders == includeAllProviders)&&(identical(other.hidden, hidden) || other.hidden == hidden)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.order, order) || other.order == order)&&const DeepCollectionEquality().equals(other.memberLayout, _memberLayout));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProxyGroup&&(identical(other.profileId, profileId) || other.profileId == profileId)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.proxies, _proxies)&&const DeepCollectionEquality().equals(other.use, _use)&&const DeepCollectionEquality().equals(other.usePosition, _usePosition)&&(identical(other.interval, interval) || other.interval == interval)&&(identical(other.lazy, lazy) || other.lazy == lazy)&&(identical(other.disableUDP, disableUDP) || other.disableUDP == disableUDP)&&(identical(other.url, url) || other.url == url)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.maxFailedTimes, maxFailedTimes) || other.maxFailedTimes == maxFailedTimes)&&(identical(other.filter, filter) || other.filter == filter)&&(identical(other.excludeFilter, excludeFilter) || other.excludeFilter == excludeFilter)&&(identical(other.excludeType, excludeType) || other.excludeType == excludeType)&&(identical(other.expectedStatus, expectedStatus) || other.expectedStatus == expectedStatus)&&(identical(other.tolerance, tolerance) || other.tolerance == tolerance)&&(identical(other.strategy, strategy) || other.strategy == strategy)&&(identical(other.hashKey, hashKey) || other.hashKey == hashKey)&&(identical(other.defaultSelected, defaultSelected) || other.defaultSelected == defaultSelected)&&(identical(other.emptyFallback, emptyFallback) || other.emptyFallback == emptyFallback)&&(identical(other.includeAllProxies, includeAllProxies) || other.includeAllProxies == includeAllProxies)&&(identical(other.includeAllProviders, includeAllProviders) || other.includeAllProviders == includeAllProviders)&&(identical(other.hidden, hidden) || other.hidden == hidden)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.order, order) || other.order == order)&&const DeepCollectionEquality().equals(other.memberLayout, _memberLayout));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hashAll([runtimeType,profileId,id,name,type,const DeepCollectionEquality().hash(_proxies),const DeepCollectionEquality().hash(_use),const DeepCollectionEquality().hash(_usePosition),interval,lazy,disableUDP,url,timeout,maxFailedTimes,filter,excludeFilter,excludeType,expectedStatus,tolerance,strategy,hashKey,defaultSelected,emptyFallback,includeAll,includeAllProxies,includeAllProviders,hidden,icon,order,const DeepCollectionEquality().hash(_memberLayout)]);
return Object.hashAll([runtimeType,profileId,id,name,type,const DeepCollectionEquality().hash(_proxies),const DeepCollectionEquality().hash(_use),const DeepCollectionEquality().hash(_usePosition),interval,lazy,disableUDP,url,timeout,maxFailedTimes,filter,excludeFilter,excludeType,expectedStatus,tolerance,strategy,hashKey,defaultSelected,emptyFallback,includeAllProxies,includeAllProviders,hidden,icon,order,const DeepCollectionEquality().hash(_memberLayout)]);
}
@override
String toString() {
return 'ProxyGroup(profileId: $profileId, id: $id, name: $name, type: $type, proxies: $proxies, use: $use, usePosition: $usePosition, interval: $interval, lazy: $lazy, disableUDP: $disableUDP, url: $url, timeout: $timeout, maxFailedTimes: $maxFailedTimes, filter: $filter, excludeFilter: $excludeFilter, excludeType: $excludeType, expectedStatus: $expectedStatus, tolerance: $tolerance, strategy: $strategy, hashKey: $hashKey, defaultSelected: $defaultSelected, emptyFallback: $emptyFallback, includeAll: $includeAll, includeAllProxies: $includeAllProxies, includeAllProviders: $includeAllProviders, hidden: $hidden, icon: $icon, order: $order, memberLayout: $memberLayout)';
return 'ProxyGroup(profileId: $profileId, id: $id, name: $name, type: $type, proxies: $proxies, use: $use, usePosition: $usePosition, interval: $interval, lazy: $lazy, disableUDP: $disableUDP, url: $url, timeout: $timeout, maxFailedTimes: $maxFailedTimes, filter: $filter, excludeFilter: $excludeFilter, excludeType: $excludeType, expectedStatus: $expectedStatus, tolerance: $tolerance, strategy: $strategy, hashKey: $hashKey, defaultSelected: $defaultSelected, emptyFallback: $emptyFallback, includeAllProxies: $includeAllProxies, includeAllProviders: $includeAllProviders, hidden: $hidden, icon: $icon, order: $order, memberLayout: $memberLayout)';
}
@@ -349,7 +347,7 @@ abstract mixin class _$ProxyGroupCopyWith<$Res> implements $ProxyGroupCopyWith<$
factory _$ProxyGroupCopyWith(_ProxyGroup value, $Res Function(_ProxyGroup) _then) = __$ProxyGroupCopyWithImpl;
@override @useResult
$Res call({
int? profileId,@JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use,@JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy,@JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout,@JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter,@JsonKey(name: 'exclude-filter') String? excludeFilter,@JsonKey(name: 'exclude-type') String? excludeType,@JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy,@JsonKey(name: 'hash-key') String? hashKey,@JsonKey(name: 'default-selected') String? defaultSelected,@JsonKey(name: 'empty-fallback') String? emptyFallback,@JsonKey(name: 'include-all') bool? includeAll,@JsonKey(name: 'include-all-proxies') bool? includeAllProxies,@JsonKey(name: 'include-all-providers') bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout
int? profileId,@JsonKey(fromJson: Snowflake.buildId) int id, String name, GroupType type, List<String>? proxies, List<String>? use,@JsonKey(name: 'use-position') Map<String, int>? usePosition, int? interval, bool? lazy,@JsonKey(name: 'disable-udp') bool? disableUDP, String? url, int? timeout,@JsonKey(name: 'max-failed-times') int? maxFailedTimes, String? filter,@JsonKey(name: 'exclude-filter') String? excludeFilter,@JsonKey(name: 'exclude-type') String? excludeType,@JsonKey(name: 'expected-status') String? expectedStatus, int? tolerance, LoadBalanceStrategy? strategy,@JsonKey(name: 'hash-key') String? hashKey,@JsonKey(name: 'default-selected') String? defaultSelected,@JsonKey(name: 'empty-fallback') String? emptyFallback,@JsonKey(name: 'include-all-proxies', readValue: _readIncludeAll) bool? includeAllProxies,@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll) bool? includeAllProviders, bool? hidden, String? icon, String? order, List<String>? memberLayout
});
@@ -366,7 +364,7 @@ class __$ProxyGroupCopyWithImpl<$Res>
/// Create a copy of ProxyGroup
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? profileId = freezed,Object? id = null,Object? name = null,Object? type = null,Object? proxies = freezed,Object? use = freezed,Object? usePosition = freezed,Object? interval = freezed,Object? lazy = freezed,Object? disableUDP = freezed,Object? url = freezed,Object? timeout = freezed,Object? maxFailedTimes = freezed,Object? filter = freezed,Object? excludeFilter = freezed,Object? excludeType = freezed,Object? expectedStatus = freezed,Object? tolerance = freezed,Object? strategy = freezed,Object? hashKey = freezed,Object? defaultSelected = freezed,Object? emptyFallback = freezed,Object? includeAll = freezed,Object? includeAllProxies = freezed,Object? includeAllProviders = freezed,Object? hidden = freezed,Object? icon = freezed,Object? order = freezed,Object? memberLayout = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? profileId = freezed,Object? id = null,Object? name = null,Object? type = null,Object? proxies = freezed,Object? use = freezed,Object? usePosition = freezed,Object? interval = freezed,Object? lazy = freezed,Object? disableUDP = freezed,Object? url = freezed,Object? timeout = freezed,Object? maxFailedTimes = freezed,Object? filter = freezed,Object? excludeFilter = freezed,Object? excludeType = freezed,Object? expectedStatus = freezed,Object? tolerance = freezed,Object? strategy = freezed,Object? hashKey = freezed,Object? defaultSelected = freezed,Object? emptyFallback = freezed,Object? includeAllProxies = freezed,Object? includeAllProviders = freezed,Object? hidden = freezed,Object? icon = freezed,Object? order = freezed,Object? memberLayout = freezed,}) {
return _then(_ProxyGroup(
profileId: freezed == profileId ? _self.profileId : profileId // ignore: cast_nullable_to_non_nullable
as int?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
@@ -390,8 +388,7 @@ as int?,strategy: freezed == strategy ? _self.strategy : strategy // ignore: cas
as LoadBalanceStrategy?,hashKey: freezed == hashKey ? _self.hashKey : hashKey // ignore: cast_nullable_to_non_nullable
as String?,defaultSelected: freezed == defaultSelected ? _self.defaultSelected : defaultSelected // ignore: cast_nullable_to_non_nullable
as String?,emptyFallback: freezed == emptyFallback ? _self.emptyFallback : emptyFallback // ignore: cast_nullable_to_non_nullable
as String?,includeAll: freezed == includeAll ? _self.includeAll : includeAll // ignore: cast_nullable_to_non_nullable
as bool?,includeAllProxies: freezed == includeAllProxies ? _self.includeAllProxies : includeAllProxies // ignore: cast_nullable_to_non_nullable
as String?,includeAllProxies: freezed == includeAllProxies ? _self.includeAllProxies : includeAllProxies // ignore: cast_nullable_to_non_nullable
as bool?,includeAllProviders: freezed == includeAllProviders ? _self.includeAllProviders : includeAllProviders // ignore: cast_nullable_to_non_nullable
as bool?,hidden: freezed == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable
as bool?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
@@ -3151,546 +3148,6 @@ as Map<String, String>,
}
/// @nodoc
mixin _$RuleProvider {
String get name;
/// Create a copy of RuleProvider
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RuleProviderCopyWith<RuleProvider> get copyWith => _$RuleProviderCopyWithImpl<RuleProvider>(this as RuleProvider, _$identity);
/// Serializes this RuleProvider to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as RuleProvider;
return identical(this, other) || (other.runtimeType == runtimeType&&other is RuleProvider&&(identical(other.name, _this.name) || other.name == _this.name));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as RuleProvider;
return Object.hash(runtimeType,_this.name);
}
@override
String toString() {
final _this = this as RuleProvider;
return 'RuleProvider(name: ${_this.name})';
}
}
/// @nodoc
abstract mixin class $RuleProviderCopyWith<$Res> {
factory $RuleProviderCopyWith(RuleProvider value, $Res Function(RuleProvider) _then) = _$RuleProviderCopyWithImpl;
@useResult
$Res call({
String name
});
}
/// @nodoc
class _$RuleProviderCopyWithImpl<$Res>
implements $RuleProviderCopyWith<$Res> {
_$RuleProviderCopyWithImpl(this._self, this._then);
final RuleProvider _self;
final $Res Function(RuleProvider) _then;
/// Create a copy of RuleProvider
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,}) {
return _then(RuleProvider(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [RuleProvider].
extension RuleProviderPatterns on RuleProvider {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RuleProvider value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RuleProvider() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RuleProvider value) $default,){
final _that = this;
switch (_that) {
case _RuleProvider():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RuleProvider value)? $default,){
final _that = this;
switch (_that) {
case _RuleProvider() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RuleProvider() when $default != null:
return $default(_that.name);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name) $default,) {final _that = this;
switch (_that) {
case _RuleProvider():
return $default(_that.name);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name)? $default,) {final _that = this;
switch (_that) {
case _RuleProvider() when $default != null:
return $default(_that.name);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RuleProvider implements RuleProvider {
const _RuleProvider({required this.name});
factory _RuleProvider.fromJson(Map<String, dynamic> json) => _$RuleProviderFromJson(json);
@override final String name;
/// Create a copy of RuleProvider
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RuleProviderCopyWith<_RuleProvider> get copyWith => __$RuleProviderCopyWithImpl<_RuleProvider>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RuleProviderToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RuleProvider&&(identical(other.name, name) || other.name == name));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,name);
}
@override
String toString() {
return 'RuleProvider(name: $name)';
}
}
/// @nodoc
abstract mixin class _$RuleProviderCopyWith<$Res> implements $RuleProviderCopyWith<$Res> {
factory _$RuleProviderCopyWith(_RuleProvider value, $Res Function(_RuleProvider) _then) = __$RuleProviderCopyWithImpl;
@override @useResult
$Res call({
String name
});
}
/// @nodoc
class __$RuleProviderCopyWithImpl<$Res>
implements _$RuleProviderCopyWith<$Res> {
__$RuleProviderCopyWithImpl(this._self, this._then);
final _RuleProvider _self;
final $Res Function(_RuleProvider) _then;
/// Create a copy of RuleProvider
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,}) {
return _then(_RuleProvider(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
mixin _$ProxyProvider {
String get name;
/// Create a copy of ProxyProvider
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProxyProviderCopyWith<ProxyProvider> get copyWith => _$ProxyProviderCopyWithImpl<ProxyProvider>(this as ProxyProvider, _$identity);
/// Serializes this ProxyProvider to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as ProxyProvider;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProxyProvider&&(identical(other.name, _this.name) || other.name == _this.name));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ProxyProvider;
return Object.hash(runtimeType,_this.name);
}
@override
String toString() {
final _this = this as ProxyProvider;
return 'ProxyProvider(name: ${_this.name})';
}
}
/// @nodoc
abstract mixin class $ProxyProviderCopyWith<$Res> {
factory $ProxyProviderCopyWith(ProxyProvider value, $Res Function(ProxyProvider) _then) = _$ProxyProviderCopyWithImpl;
@useResult
$Res call({
String name
});
}
/// @nodoc
class _$ProxyProviderCopyWithImpl<$Res>
implements $ProxyProviderCopyWith<$Res> {
_$ProxyProviderCopyWithImpl(this._self, this._then);
final ProxyProvider _self;
final $Res Function(ProxyProvider) _then;
/// Create a copy of ProxyProvider
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,}) {
return _then(ProxyProvider(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [ProxyProvider].
extension ProxyProviderPatterns on ProxyProvider {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ProxyProvider value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ProxyProvider() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ProxyProvider value) $default,){
final _that = this;
switch (_that) {
case _ProxyProvider():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ProxyProvider value)? $default,){
final _that = this;
switch (_that) {
case _ProxyProvider() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ProxyProvider() when $default != null:
return $default(_that.name);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name) $default,) {final _that = this;
switch (_that) {
case _ProxyProvider():
return $default(_that.name);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name)? $default,) {final _that = this;
switch (_that) {
case _ProxyProvider() when $default != null:
return $default(_that.name);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ProxyProvider implements ProxyProvider {
const _ProxyProvider({required this.name});
factory _ProxyProvider.fromJson(Map<String, dynamic> json) => _$ProxyProviderFromJson(json);
@override final String name;
/// Create a copy of ProxyProvider
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProxyProviderCopyWith<_ProxyProvider> get copyWith => __$ProxyProviderCopyWithImpl<_ProxyProvider>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ProxyProviderToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProxyProvider&&(identical(other.name, name) || other.name == name));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,name);
}
@override
String toString() {
return 'ProxyProvider(name: $name)';
}
}
/// @nodoc
abstract mixin class _$ProxyProviderCopyWith<$Res> implements $ProxyProviderCopyWith<$Res> {
factory _$ProxyProviderCopyWith(_ProxyProvider value, $Res Function(_ProxyProvider) _then) = __$ProxyProviderCopyWithImpl;
@override @useResult
$Res call({
String name
});
}
/// @nodoc
class __$ProxyProviderCopyWithImpl<$Res>
implements _$ProxyProviderCopyWith<$Res> {
__$ProxyProviderCopyWithImpl(this._self, this._then);
final _ProxyProvider _self;
final $Res Function(_ProxyProvider) _then;
/// Create a copy of ProxyProvider
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,}) {
return _then(_ProxyProvider(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
mixin _$Sniffer {
@@ -7479,7 +6936,7 @@ as String?,
/// @nodoc
mixin _$ClashConfig {
@JsonKey(name: 'proxy-groups') List<ProxyGroup> get proxyGroups;@JsonKey(fromJson: _genRules) List<Rule> get rules; List<Proxy> get proxies;@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> get proxyProviders;@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> get ruleProviders;@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> get subRules; Map<String, String> get proxyTypeMap;
@JsonKey(name: 'proxy-groups') List<ProxyGroup> get proxyGroups;@JsonKey(fromJson: _genRules) List<Rule> get rules; List<Proxy> get proxies;@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> get proxyProviders;@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> get ruleProviders;@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> get subRules;
/// Create a copy of ClashConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -7493,20 +6950,20 @@ $ClashConfigCopyWith<ClashConfig> get copyWith => _$ClashConfigCopyWithImpl<Clas
@override
bool operator ==(Object other) {
final _this = this as ClashConfig;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ClashConfig&&const DeepCollectionEquality().equals(other.proxyGroups, _this.proxyGroups)&&const DeepCollectionEquality().equals(other.rules, _this.rules)&&const DeepCollectionEquality().equals(other.proxies, _this.proxies)&&const DeepCollectionEquality().equals(other.proxyProviders, _this.proxyProviders)&&const DeepCollectionEquality().equals(other.ruleProviders, _this.ruleProviders)&&const DeepCollectionEquality().equals(other.subRules, _this.subRules)&&const DeepCollectionEquality().equals(other.proxyTypeMap, _this.proxyTypeMap));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ClashConfig&&const DeepCollectionEquality().equals(other.proxyGroups, _this.proxyGroups)&&const DeepCollectionEquality().equals(other.rules, _this.rules)&&const DeepCollectionEquality().equals(other.proxies, _this.proxies)&&const DeepCollectionEquality().equals(other.proxyProviders, _this.proxyProviders)&&const DeepCollectionEquality().equals(other.ruleProviders, _this.ruleProviders)&&const DeepCollectionEquality().equals(other.subRules, _this.subRules));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ClashConfig;
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.proxyGroups),const DeepCollectionEquality().hash(_this.rules),const DeepCollectionEquality().hash(_this.proxies),const DeepCollectionEquality().hash(_this.proxyProviders),const DeepCollectionEquality().hash(_this.ruleProviders),const DeepCollectionEquality().hash(_this.subRules),const DeepCollectionEquality().hash(_this.proxyTypeMap));
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.proxyGroups),const DeepCollectionEquality().hash(_this.rules),const DeepCollectionEquality().hash(_this.proxies),const DeepCollectionEquality().hash(_this.proxyProviders),const DeepCollectionEquality().hash(_this.ruleProviders),const DeepCollectionEquality().hash(_this.subRules));
}
@override
String toString() {
final _this = this as ClashConfig;
return 'ClashConfig(proxyGroups: ${_this.proxyGroups}, rules: ${_this.rules}, proxies: ${_this.proxies}, proxyProviders: ${_this.proxyProviders}, ruleProviders: ${_this.ruleProviders}, subRules: ${_this.subRules}, proxyTypeMap: ${_this.proxyTypeMap})';
return 'ClashConfig(proxyGroups: ${_this.proxyGroups}, rules: ${_this.rules}, proxies: ${_this.proxies}, proxyProviders: ${_this.proxyProviders}, ruleProviders: ${_this.ruleProviders}, subRules: ${_this.subRules})';
}
@@ -7517,7 +6974,7 @@ abstract mixin class $ClashConfigCopyWith<$Res> {
factory $ClashConfigCopyWith(ClashConfig value, $Res Function(ClashConfig) _then) = _$ClashConfigCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,@JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies,@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders,@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders,@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules, Map<String, String> proxyTypeMap
@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,@JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies,@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders,@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders,@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules
});
@@ -7534,7 +6991,7 @@ class _$ClashConfigCopyWithImpl<$Res>
/// Create a copy of ClashConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? proxyGroups = null,Object? rules = null,Object? proxies = null,Object? proxyProviders = null,Object? ruleProviders = null,Object? subRules = null,Object? proxyTypeMap = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? proxyGroups = null,Object? rules = null,Object? proxies = null,Object? proxyProviders = null,Object? ruleProviders = null,Object? subRules = null,}) {
return _then(ClashConfig(
proxyGroups: null == proxyGroups ? _self.proxyGroups : proxyGroups // ignore: cast_nullable_to_non_nullable
as List<ProxyGroup>,rules: null == rules ? _self.rules : rules // ignore: cast_nullable_to_non_nullable
@@ -7542,8 +6999,7 @@ as List<Rule>,proxies: null == proxies ? _self.proxies : proxies // ignore: cast
as List<Proxy>,proxyProviders: null == proxyProviders ? _self.proxyProviders : proxyProviders // ignore: cast_nullable_to_non_nullable
as List<String>,ruleProviders: null == ruleProviders ? _self.ruleProviders : ruleProviders // ignore: cast_nullable_to_non_nullable
as List<String>,subRules: null == subRules ? _self.subRules : subRules // ignore: cast_nullable_to_non_nullable
as List<String>,proxyTypeMap: null == proxyTypeMap ? _self.proxyTypeMap : proxyTypeMap // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
as List<String>,
));
}
@@ -7628,10 +7084,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules, Map<String, String> proxyTypeMap)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ClashConfig() when $default != null:
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules,_that.proxyTypeMap);case _:
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules);case _:
return orElse();
}
@@ -7649,10 +7105,10 @@ return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules, Map<String, String> proxyTypeMap) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules) $default,) {final _that = this;
switch (_that) {
case _ClashConfig():
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules,_that.proxyTypeMap);case _:
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules);case _:
throw StateError('Unexpected subclass');
}
@@ -7669,10 +7125,10 @@ return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules, Map<String, String> proxyTypeMap)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups, @JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies, @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders, @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders, @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules)? $default,) {final _that = this;
switch (_that) {
case _ClashConfig() when $default != null:
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules,_that.proxyTypeMap);case _:
return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders,_that.ruleProviders,_that.subRules);case _:
return null;
}
@@ -7684,7 +7140,7 @@ return $default(_that.proxyGroups,_that.rules,_that.proxies,_that.proxyProviders
@JsonSerializable()
class _ClashConfig implements ClashConfig {
const _ClashConfig({@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups = const [], @JsonKey(fromJson: _genRules) List<Rule> rules = const [], List<Proxy> proxies = const [], @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders = const [], @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders = const [], @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules = const [], Map<String, String> proxyTypeMap = const {}}): _proxyGroups = proxyGroups,_rules = rules,_proxies = proxies,_proxyProviders = proxyProviders,_ruleProviders = ruleProviders,_subRules = subRules,_proxyTypeMap = proxyTypeMap;
const _ClashConfig({@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups = const [], @JsonKey(fromJson: _genRules) List<Rule> rules = const [], List<Proxy> proxies = const [], @JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders = const [], @JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders = const [], @JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules = const []}): _proxyGroups = proxyGroups,_rules = rules,_proxies = proxies,_proxyProviders = proxyProviders,_ruleProviders = ruleProviders,_subRules = subRules;
factory _ClashConfig.fromJson(Map<String, dynamic> json) => _$ClashConfigFromJson(json);
final List<ProxyGroup> _proxyGroups;
@@ -7729,13 +7185,6 @@ class _ClashConfig implements ClashConfig {
return EqualUnmodifiableListView(_subRules);
}
final Map<String, String> _proxyTypeMap;
@override@JsonKey() Map<String, String> get proxyTypeMap {
if (_proxyTypeMap is EqualUnmodifiableMapView) return _proxyTypeMap;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_proxyTypeMap);
}
/// Create a copy of ClashConfig
/// with the given fields replaced by the non-null parameter values.
@@ -7750,18 +7199,18 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ClashConfig&&const DeepCollectionEquality().equals(other.proxyGroups, _proxyGroups)&&const DeepCollectionEquality().equals(other.rules, _rules)&&const DeepCollectionEquality().equals(other.proxies, _proxies)&&const DeepCollectionEquality().equals(other.proxyProviders, _proxyProviders)&&const DeepCollectionEquality().equals(other.ruleProviders, _ruleProviders)&&const DeepCollectionEquality().equals(other.subRules, _subRules)&&const DeepCollectionEquality().equals(other.proxyTypeMap, _proxyTypeMap));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ClashConfig&&const DeepCollectionEquality().equals(other.proxyGroups, _proxyGroups)&&const DeepCollectionEquality().equals(other.rules, _rules)&&const DeepCollectionEquality().equals(other.proxies, _proxies)&&const DeepCollectionEquality().equals(other.proxyProviders, _proxyProviders)&&const DeepCollectionEquality().equals(other.ruleProviders, _ruleProviders)&&const DeepCollectionEquality().equals(other.subRules, _subRules));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_proxyGroups),const DeepCollectionEquality().hash(_rules),const DeepCollectionEquality().hash(_proxies),const DeepCollectionEquality().hash(_proxyProviders),const DeepCollectionEquality().hash(_ruleProviders),const DeepCollectionEquality().hash(_subRules),const DeepCollectionEquality().hash(_proxyTypeMap));
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_proxyGroups),const DeepCollectionEquality().hash(_rules),const DeepCollectionEquality().hash(_proxies),const DeepCollectionEquality().hash(_proxyProviders),const DeepCollectionEquality().hash(_ruleProviders),const DeepCollectionEquality().hash(_subRules));
}
@override
String toString() {
return 'ClashConfig(proxyGroups: $proxyGroups, rules: $rules, proxies: $proxies, proxyProviders: $proxyProviders, ruleProviders: $ruleProviders, subRules: $subRules, proxyTypeMap: $proxyTypeMap)';
return 'ClashConfig(proxyGroups: $proxyGroups, rules: $rules, proxies: $proxies, proxyProviders: $proxyProviders, ruleProviders: $ruleProviders, subRules: $subRules)';
}
@@ -7772,7 +7221,7 @@ abstract mixin class _$ClashConfigCopyWith<$Res> implements $ClashConfigCopyWith
factory _$ClashConfigCopyWith(_ClashConfig value, $Res Function(_ClashConfig) _then) = __$ClashConfigCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,@JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies,@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders,@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders,@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules, Map<String, String> proxyTypeMap
@JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,@JsonKey(fromJson: _genRules) List<Rule> rules, List<Proxy> proxies,@JsonKey(name: 'proxy-providers', fromJson: _genList) List<String> proxyProviders,@JsonKey(name: 'rule-providers', fromJson: _genList) List<String> ruleProviders,@JsonKey(name: 'sub-rules', fromJson: _genList) List<String> subRules
});
@@ -7789,7 +7238,7 @@ class __$ClashConfigCopyWithImpl<$Res>
/// Create a copy of ClashConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? proxyGroups = null,Object? rules = null,Object? proxies = null,Object? proxyProviders = null,Object? ruleProviders = null,Object? subRules = null,Object? proxyTypeMap = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? proxyGroups = null,Object? rules = null,Object? proxies = null,Object? proxyProviders = null,Object? ruleProviders = null,Object? subRules = null,}) {
return _then(_ClashConfig(
proxyGroups: null == proxyGroups ? _self._proxyGroups : proxyGroups // ignore: cast_nullable_to_non_nullable
as List<ProxyGroup>,rules: null == rules ? _self._rules : rules // ignore: cast_nullable_to_non_nullable
@@ -7797,8 +7246,7 @@ as List<Rule>,proxies: null == proxies ? _self._proxies : proxies // ignore: cas
as List<Proxy>,proxyProviders: null == proxyProviders ? _self._proxyProviders : proxyProviders // ignore: cast_nullable_to_non_nullable
as List<String>,ruleProviders: null == ruleProviders ? _self._ruleProviders : ruleProviders // ignore: cast_nullable_to_non_nullable
as List<String>,subRules: null == subRules ? _self._subRules : subRules // ignore: cast_nullable_to_non_nullable
as List<String>,proxyTypeMap: null == proxyTypeMap ? _self._proxyTypeMap : proxyTypeMap // ignore: cast_nullable_to_non_nullable
as Map<String, String>,
as List<String>,
));
}
+2 -22
View File
@@ -33,9 +33,8 @@ _ProxyGroup _$ProxyGroupFromJson(Map<String, dynamic> json) => _ProxyGroup(
hashKey: json['hash-key'] as String?,
defaultSelected: json['default-selected'] as String?,
emptyFallback: json['empty-fallback'] as String?,
includeAll: json['include-all'] as bool?,
includeAllProxies: json['include-all-proxies'] as bool?,
includeAllProviders: json['include-all-providers'] as bool?,
includeAllProxies: _readIncludeAll(json, 'include-all-proxies') as bool?,
includeAllProviders: _readIncludeAll(json, 'include-all-providers') as bool?,
hidden: json['hidden'] as bool?,
icon: json['icon'] as String?,
order: json['order'] as String?,
@@ -68,7 +67,6 @@ Map<String, dynamic> _$ProxyGroupToJson(_ProxyGroup instance) =>
'hash-key': instance.hashKey,
'default-selected': instance.defaultSelected,
'empty-fallback': instance.emptyFallback,
'include-all': instance.includeAll,
'include-all-proxies': instance.includeAllProxies,
'include-all-providers': instance.includeAllProviders,
'hidden': instance.hidden,
@@ -186,18 +184,6 @@ const _$ProxyFieldEnumMap = {
ProxyField.ipVersion: 'ipVersion',
};
_RuleProvider _$RuleProviderFromJson(Map<String, dynamic> json) =>
_RuleProvider(name: json['name'] as String);
Map<String, dynamic> _$RuleProviderToJson(_RuleProvider instance) =>
<String, dynamic>{'name': instance.name};
_ProxyProvider _$ProxyProviderFromJson(Map<String, dynamic> json) =>
_ProxyProvider(name: json['name'] as String);
Map<String, dynamic> _$ProxyProviderToJson(_ProxyProvider instance) =>
<String, dynamic>{'name': instance.name};
_Sniffer _$SnifferFromJson(Map<String, dynamic> json) => _Sniffer(
enable: json['enable'] as bool? ?? false,
overrideDest: json['override-destination'] as bool? ?? true,
@@ -771,11 +757,6 @@ _ClashConfig _$ClashConfigFromJson(Map<String, dynamic> json) => _ClashConfig(
subRules: json['sub-rules'] == null
? const []
: _genList(json['sub-rules'] as Map<String, dynamic>),
proxyTypeMap:
(json['proxyTypeMap'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
const {},
);
Map<String, dynamic> _$ClashConfigToJson(_ClashConfig instance) =>
@@ -786,7 +767,6 @@ Map<String, dynamic> _$ClashConfigToJson(_ClashConfig instance) =>
'proxy-providers': instance.proxyProviders,
'rule-providers': instance.ruleProviders,
'sub-rules': instance.subRules,
'proxyTypeMap': instance.proxyTypeMap,
};
_PatchClashConfig _$PatchClashConfigFromJson(Map<String, dynamic> json) =>
+9
View File
@@ -144,6 +144,15 @@ extension ProfileExtension on Profile {
String get fileName => '$id.yaml';
String get exportFileName {
final label = realLabel;
final lower = label.toLowerCase();
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
return label;
}
return '$label.yaml';
}
String get updatingKey => 'profile_$id';
Future<Profile?> checkAndUpdateAndCopy({
+22 -16
View File
@@ -40,8 +40,11 @@ class ProfilesAction extends _$ProfilesAction {
}
Future<void> autoUpdateProfiles() async {
for (final profile in ref.read(profilesProvider)) {
if (!profile.autoUpdate) continue;
for (final id in [
for (final profile in ref.read(profilesProvider)) profile.id,
]) {
final profile = ref.read(profilesProvider).getProfile(id);
if (profile == null || !profile.autoUpdate) continue;
final isNotNeedUpdate = profile.lastUpdateDate
?.add(profile.autoUpdateDuration)
.isBeforeNow;
@@ -123,8 +126,11 @@ class ProfilesAction extends _$ProfilesAction {
}
Future<void> updateProfiles() async {
for (final profile in ref.read(profilesProvider)) {
if (profile.type != ProfileType.url) continue;
for (final id in [
for (final profile in ref.read(profilesProvider)) profile.id,
]) {
final profile = ref.read(profilesProvider).getProfile(id);
if (profile == null || profile.type != ProfileType.url) continue;
await updateProfile(profile, showLoading: true);
}
}
@@ -138,9 +144,20 @@ class ProfilesAction extends _$ProfilesAction {
: null;
try {
ref.read(profilesProvider.notifier).put(profile);
final newProfile = await profile.update(
final updated = await profile.update(
validate: (path, key) => _core.validateProfile(path, key),
);
// The download can take minutes; edits and deletes made meanwhile win.
final latest = ref.read(profilesProvider).getProfile(profile.id);
if (latest == null) {
await clearEffect(profile.id);
return;
}
final newProfile = latest.copyWith(
label: latest.label.takeFirstValid([updated.label]),
subscriptionInfo: updated.subscriptionInfo,
lastUpdateDate: updated.lastUpdateDate,
);
ref.read(profilesProvider.notifier).put(newProfile);
if (profile.id == ref.read(currentProfileIdProvider)) {
ref
@@ -435,23 +452,12 @@ class ProfilesAction extends _$ProfilesAction {
);
}
void setProfileAndAutoApply(Profile profile) {
ref.read(profilesProvider.notifier).put(profile);
if (profile.id == ref.read(currentProfileIdProvider)) {
ref.read(setupActionProvider.notifier).applyProfileDebounce();
}
}
Future<void> addProfileFormQrCode() async {
final url = await globalState.safeRun(picker.pickerConfigQRCode);
if (url == null) return;
unawaited(addProfileFromLink(url));
}
void reorder(List<Profile> profiles) {
ref.read(profilesProvider.notifier).reorder(profiles);
}
Future<void> clearEffect(int profileId) async {
final profilePath = await appPath.getProfilePath(profileId.toString());
final profileFile = File(profilePath);
+5 -22
View File
@@ -5,7 +5,6 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'generated/database.g.dart';
@@ -132,21 +131,11 @@ Stream<List<Profile>> profilesStream(Ref ref) {
return database.profilesDao.query().watch();
}
@riverpod
Stream<List<Rule>> addedRulesStream(Ref ref, int profileId) {
return database.rulesDao.queryAddedRules(profileId).watch();
}
@riverpod
Stream<Set<String>> customGroupNames(Ref ref) {
return database.proxyGroupsDao.names().watch().map(Set.of);
}
@riverpod
Stream<int> proxyGroupsCount(Ref ref, int profileId) {
return database.proxyGroupsDao.count(profileId).watchSingle();
}
@Riverpod(keepAlive: true)
class Profiles extends _$Profiles {
late final _writes = _Writes(_reread);
@@ -517,17 +506,6 @@ class IconSets extends _$IconSets with AsyncNotifierMixin, OptimisticMixin {
}
}
@riverpod
Future<Script?> script(Ref ref, int? scriptId) async {
final script = ref.watch(
scriptsProvider.future.select((state) async {
final scripts = await state;
return scripts.get(scriptId);
}),
);
return script;
}
mixin RuleListMixin on OptimisticMixin<List<Rule>> {
Future<void> persistRule(Rule rule);
@@ -639,6 +617,11 @@ class GlobalRules extends _$GlobalRules
Future<void> persistRule(Rule rule) => database.rulesDao.putRule(rule);
}
@riverpod
Stream<int> profileGroupsCount(Ref ref, int profileId) {
return database.proxyGroupsDao.profileGroupsCount(profileId).watchSingle();
}
/// What the standard extension adds to a profile, or a custom profile's rules.
@riverpod
class ProfileRules extends _$ProfileRules
+70 -212
View File
@@ -48,80 +48,6 @@ final class ProfilesStreamProvider
String _$profilesStreamHash() => r'ea944e081294567f0f63286e95e4e66cdc650383';
@ProviderFor(addedRulesStream)
final addedRulesStreamProvider = AddedRulesStreamFamily._();
final class AddedRulesStreamProvider
extends
$FunctionalProvider<
AsyncValue<List<Rule>>,
List<Rule>,
Stream<List<Rule>>
>
with $FutureModifier<List<Rule>>, $StreamProvider<List<Rule>> {
AddedRulesStreamProvider._({
required AddedRulesStreamFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'addedRulesStreamProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addedRulesStreamHash();
@override
String toString() {
return r'addedRulesStreamProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<List<Rule>> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<List<Rule>> create(Ref ref) {
final argument = this.argument as int;
return addedRulesStream(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AddedRulesStreamProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$addedRulesStreamHash() => r'5d37e4f080094a44c2f6f84dda60d6796f4b3c99';
final class AddedRulesStreamFamily extends $Family
with $FunctionalFamilyOverride<Stream<List<Rule>>, int> {
AddedRulesStreamFamily._()
: super(
retry: null,
name: r'addedRulesStreamProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AddedRulesStreamProvider call(int profileId) =>
AddedRulesStreamProvider._(argument: profileId, from: this);
@override
String toString() => r'addedRulesStreamProvider';
}
@ProviderFor(customGroupNames)
final customGroupNamesProvider = CustomGroupNamesProvider._();
@@ -161,75 +87,6 @@ final class CustomGroupNamesProvider
String _$customGroupNamesHash() => r'9d3d1a6b57aeee7b4cf34afd20e28336484a5de7';
@ProviderFor(proxyGroupsCount)
final proxyGroupsCountProvider = ProxyGroupsCountFamily._();
final class ProxyGroupsCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, Stream<int>>
with $FutureModifier<int>, $StreamProvider<int> {
ProxyGroupsCountProvider._({
required ProxyGroupsCountFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'proxyGroupsCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyGroupsCountHash();
@override
String toString() {
return r'proxyGroupsCountProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<int> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<int> create(Ref ref) {
final argument = this.argument as int;
return proxyGroupsCount(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ProxyGroupsCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$proxyGroupsCountHash() => r'9bf90fc25a9ae3b9ab7aa0784d4e47786f4c4d52';
final class ProxyGroupsCountFamily extends $Family
with $FunctionalFamilyOverride<Stream<int>, int> {
ProxyGroupsCountFamily._()
: super(
retry: null,
name: r'proxyGroupsCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ProxyGroupsCountProvider call(int profileId) =>
ProxyGroupsCountProvider._(argument: profileId, from: this);
@override
String toString() => r'proxyGroupsCountProvider';
}
@ProviderFor(Profiles)
final profilesProvider = ProfilesProvider._();
@@ -415,75 +272,6 @@ abstract class _$IconSets extends $StreamNotifier<List<IconSet>> {
}
}
@ProviderFor(script)
final scriptProvider = ScriptFamily._();
final class ScriptProvider
extends $FunctionalProvider<AsyncValue<Script?>, Script?, FutureOr<Script?>>
with $FutureModifier<Script?>, $FutureProvider<Script?> {
ScriptProvider._({
required ScriptFamily super.from,
required int? super.argument,
}) : super(
retry: null,
name: r'scriptProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$scriptHash();
@override
String toString() {
return r'scriptProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<Script?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Script?> create(Ref ref) {
final argument = this.argument as int?;
return script(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ScriptProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$scriptHash() => r'c97b48d58cef1bc928cdcfc1b292fd84ef515593';
final class ScriptFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<Script?>, int?> {
ScriptFamily._()
: super(
retry: null,
name: r'scriptProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ScriptProvider call(int? scriptId) =>
ScriptProvider._(argument: scriptId, from: this);
@override
String toString() => r'scriptProvider';
}
@ProviderFor(GlobalRules)
final globalRulesProvider = GlobalRulesProvider._();
@@ -528,6 +316,76 @@ abstract class _$GlobalRules extends $StreamNotifier<List<Rule>> {
}
}
@ProviderFor(profileGroupsCount)
final profileGroupsCountProvider = ProfileGroupsCountFamily._();
final class ProfileGroupsCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, Stream<int>>
with $FutureModifier<int>, $StreamProvider<int> {
ProfileGroupsCountProvider._({
required ProfileGroupsCountFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'profileGroupsCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$profileGroupsCountHash();
@override
String toString() {
return r'profileGroupsCountProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<int> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<int> create(Ref ref) {
final argument = this.argument as int;
return profileGroupsCount(ref, argument);
}
@override
bool operator ==(Object other) {
return other is ProfileGroupsCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$profileGroupsCountHash() =>
r'c82d409c32b5467619c51bba28b2066b6ea09c59';
final class ProfileGroupsCountFamily extends $Family
with $FunctionalFamilyOverride<Stream<int>, int> {
ProfileGroupsCountFamily._()
: super(
retry: null,
name: r'profileGroupsCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ProfileGroupsCountProvider call(int profileId) =>
ProfileGroupsCountProvider._(argument: profileId, from: this);
@override
String toString() => r'profileGroupsCountProvider';
}
/// What the standard extension adds to a profile, or a custom profile's rules.
@ProviderFor(ProfileRules)
+12 -7
View File
@@ -508,7 +508,7 @@ Set<String> _namesOnLoops(Map<String, List<String>> members) {
}
/// [target] and what under it leads back to [proxy], through a group's own
/// list, include-all where no filter narrows it, or an app proxy's entry in
/// list or, where an app proxy dials through another, that proxy's entry in
/// [dialers]; empty when the dial does not loop. What filters and providers
/// pick is left to the core, which fails a dial that comes back to its dialer.
Set<String> dialerLoop(
@@ -519,7 +519,7 @@ Set<String> dialerLoop(
}) => dialerLoopsOf(groups, proxy: proxy, dialers: dialers)(target);
bool _takesInEveryProxy(ProxyGroup group) =>
(group.includeAll == true || group.includeAllProxies == true) &&
group.includeAllProxies == true &&
(group.filter ?? '').isEmpty &&
(group.excludeFilter ?? '').isEmpty &&
(group.excludeType ?? '').isEmpty;
@@ -566,8 +566,7 @@ class _DialerGraph {
}
}
/// The proxy's own dialer is the dial being checked, never a way back; a
/// proxy without one is still taken in by every include-all group.
/// The proxy's own dialer is the dial being checked, never a way back.
Set<String> Function(String target) loopsOf(String proxy) {
if (!declared.contains(proxy)) {
return (_) => const {};
@@ -792,9 +791,13 @@ List<CustomIssue> proxyGroupIssues(ProxyGroup group, CustomProfileData data) {
final hasSource =
proxies.isNotEmpty ||
use.isNotEmpty ||
group.includeAll == true ||
group.includeAllProxies == true ||
group.includeAllProviders == true;
group.includeAllProviders == true &&
data.proxyGroups.any(
(item) =>
item.id != group.id &&
(item.use?.any(data.proxyProviders.contains) ?? false),
);
final missingProxies = [
for (final item in proxies)
if (!data.ruleTargets.contains(item) && !data.proxies.contains(item))
@@ -804,7 +807,9 @@ List<CustomIssue> proxyGroupIssues(ProxyGroup group, CustomProfileData data) {
// The core takes only a proxy here, never a group, and refuses the whole
// config otherwise.
final invalidEmptyFallback =
emptyFallback.isNotEmpty && !reservedProxyNames.contains(emptyFallback);
emptyFallback.isNotEmpty &&
!reservedProxyNames.contains(emptyFallback) &&
!data.namedProxies.contains(emptyFallback);
final missingProviders = use
.where((item) => !data.proxyProviders.contains(item))
.toList();
+14 -9
View File
@@ -50,6 +50,8 @@ class CustomProfileImport {
final List<Rule> rules;
final ProfileOverrides overrides;
final int droppedSubRules;
const CustomProfileImport({
required this.proxyProviders,
required this.ruleProviders,
@@ -58,6 +60,7 @@ class CustomProfileImport {
required this.proxyGroups,
required this.rules,
required this.overrides,
this.droppedSubRules = 0,
});
}
@@ -114,6 +117,14 @@ CustomProfileImport readCustomProfileImport(
groupNames: app.groupNames,
);
final ruleSets = ruleProviders.names;
final rules = [
for (final item in _listOf(document['rules']))
_importRule(item.toString(), names: proxies.names, ruleSets: ruleSets),
];
final keptRules = [
for (final rule in rules)
if (rule.ruleAction != RuleAction.SUB_RULE) rule,
];
return CustomProfileImport(
proxyProviders: proxyProviders.added,
ruleProviders: ruleProviders.added,
@@ -128,10 +139,8 @@ CustomProfileImport readCustomProfileImport(
use: proxyProviders.names,
),
],
rules: [
for (final item in _listOf(document['rules']))
_importRule(item.toString(), names: proxies.names, ruleSets: ruleSets),
],
rules: keptRules,
droppedSubRules: rules.length - keptRules.length,
overrides: _importOverrides(
document,
names: proxies.names,
@@ -412,11 +421,7 @@ ProxyGroup _importGroup(
required Map<String, String> use,
}) {
final json = _stringified({
// A custom profile has no include-all-providers, so include-all keeps
// only its proxies.
for (final MapEntry(:key, :value) in group.entries)
if (key != 'include-all' && key != 'include-all-providers') key: value,
if (group['include-all'] == true) 'include-all-proxies': true,
...group,
'id': 0,
'name': name,
if (group.containsKey('proxies'))
+4 -2
View File
@@ -1,11 +1,12 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/pages/scan.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/profiles/custom/custom.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -56,7 +57,8 @@ class AddProfileView extends ConsumerWidget {
Future<void> _toAdd(WidgetRef ref) async {
final profilesAction = ref.read(profilesActionProvider.notifier);
final appLocalizations = context.appLocalizations;
final res = await dialogs.showNamedUrlsInput(
final res = await showNamedUrlsForm(
context,
title: appLocalizations.importFromLink,
urlLabel: appLocalizations.link,
batchTip: appLocalizations.batchLinkInputTip,
+95 -174
View File
@@ -3,18 +3,18 @@ import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/config/dns.dart';
import 'package:fl_clash/views/config/ntp.dart';
import 'package:fl_clash/views/config/sniffer.dart';
import 'package:fl_clash/views/config/tun.dart';
import 'package:fl_clash/views/profiles/edit.dart';
import 'package:fl_clash/views/profiles/preview.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -42,6 +42,22 @@ void _push(BuildContext context, Widget view) {
BaseNavigator.push(context, view);
}
Widget _sectionRow(
BuildContext context,
Glyph glyph,
String title,
Widget state,
Widget view,
) {
return ListItem(
leading: GlyphIcon(glyph),
title: Text(title),
subtitle: state,
trailing: const GlyphIcon(AppGlyphs.chevronForward),
onTap: () => _push(context, view),
);
}
class CustomProfileView extends ConsumerStatefulWidget {
final int profileId;
@@ -58,8 +74,7 @@ class CustomProfileView extends ConsumerStatefulWidget {
ConsumerState<CustomProfileView> createState() => _CustomProfileViewState();
}
class _CustomProfileViewState extends ConsumerState<CustomProfileView>
with RouteSettledMixin<CustomProfileView> {
class _CustomProfileViewState extends ConsumerState<CustomProfileView> {
late SetupAction _setupAction;
late ProfilesAction _profilesAction;
late final Profile? _initialProfile;
@@ -81,7 +96,7 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
}
bool get _isUntouched {
if (!routeSettled) {
if (!_loaded) {
return true;
}
final profileId = widget.profileId;
@@ -102,9 +117,6 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
];
}
@override
void didSettleRoute() => setState(() {});
bool get _isEmpty {
final profileId = widget.profileId;
return ref.read(profileProvider(profileId))?.overrides ==
@@ -155,12 +167,30 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
if (!_isEmpty) {
final res = await dialogs.showMessage(
message: TextSpan(text: appLocalizations.importConfigReplaceTip),
destructive: true,
confirmText: appLocalizations.replace,
);
if (res != true || !mounted) {
return;
}
}
await _profilesAction.importCustomProfile(widget.profileId, config);
final dropped = [
if (config.droppedSubRules > 0)
appLocalizations.customImportDroppedSubRules(
config.droppedSubRules,
config.droppedSubRules.compact,
),
];
if (dropped.isNotEmpty && mounted) {
unawaited(
dialogs.showMessage(
title: appLocalizations.import,
message: TextSpan(text: dropped.join('\n\n')),
cancelable: false,
),
);
}
} finally {
if (mounted) {
setState(() {
@@ -190,6 +220,20 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
return true;
}
void _handleRename() {
final profile = ref.read(profileProvider(widget.profileId));
if (profile != null) {
unawaited(renameProfile(context, ref, profile));
}
}
void _handlePreview() {
final profile = ref.read(profileProvider(widget.profileId));
if (profile != null) {
_push(context, PreviewProfileView(profile: profile));
}
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
@@ -199,12 +243,9 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
profileId,
).select((state) => state?.realLabel ?? _initialProfile?.realLabel ?? ''),
);
// Reads nothing until the route settles, since loading mid-transition
// drops frames.
_loaded =
_loaded ||
(routeSettled &&
ref.watch(
(ref.watch(
customProfileDataProvider(
profileId,
).select((state) => state != null),
@@ -224,14 +265,23 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
profileId: profileId,
child: CommonScaffold(
title: label,
titleMenuItems: [
CommonPopupMenuItem(
glyph: AppGlyphs.textShort,
label: appLocalizations.rename,
onPressed: _handleRename,
),
CommonPopupMenuItem(
glyph: AppGlyphs.eye,
label: appLocalizations.runtimeConfig,
onPressed: _handlePreview,
),
],
isLoading: _importing,
actions: [
CommonPopupBox(
targetBuilder: (open) => FilledButton(
onPressed: _loaded && !_importing
? () =>
open(offset: Offset(0, context.isMobileView ? 0 : 20))
: null,
onPressed: _loaded && !_importing ? open : null,
child: Text(appLocalizations.import),
),
popupBuilder: (_) => CommonPopupMenu(
@@ -251,8 +301,8 @@ class _CustomProfileViewState extends ConsumerState<CustomProfileView>
),
],
body: NullStatusSwitcher(
isEmpty: !_loaded,
nullStatus: const NullStatus(
isLoading: !_loaded,
loadingStatus: const NullStatus(
illustration: NullStatusIllustration.profile,
),
child: ScrollConfiguration(
@@ -331,15 +381,9 @@ class _Overview extends ConsumerWidget {
_MoreSectionsView(profileId, showsTun: showsTun),
),
];
Widget tile(int index, Widget value, {bool invalid = false}) {
final (glyph, label, view) = sections[index];
return _OverviewTile(
glyph: glyph,
label: label,
invalid: invalid,
onPressed: () => _push(context, view),
value: value,
);
Widget row(int index, Widget state) {
final (glyph, title, view) = sections[index];
return _sectionRow(context, glyph, title, state, view);
}
ref.listen(customProfileDataProvider(profileId), (_, _) {});
@@ -392,38 +436,33 @@ class _Overview extends ConsumerWidget {
? appLocalizations.customIssuesSummary(issueCount)
: null,
),
_TileGrid(
children: [
tile(
0,
_CountText(count: ruleNum, issueCount: issueCounts.rules),
invalid: issueCounts.rules > 0,
),
tile(
generateSection(
items: [
row(0, _CountText(count: ruleNum, issueCount: issueCounts.rules)),
row(
1,
_CountText(
count: providers.proxy.length + providers.rule.length,
issueCount: missingProviders,
),
invalid: missingProviders > 0,
),
tile(
row(
2,
_StatusText(
_enabledText(appLocalizations, status.dns),
active: status.dns,
invalid: issueCounts.dns > 0,
),
invalid: issueCounts.dns > 0,
),
tile(
row(
3,
_StatusText(
moreInEffect > 0
? appLocalizations.sectionsInEffect(moreInEffect)
: appLocalizations.defaultText,
active: moreInEffect > 0,
invalid: issueCounts.dialers + issueCounts.ntp > 0,
),
invalid: issueCounts.dialers + issueCounts.ntp > 0,
),
],
),
@@ -474,15 +513,6 @@ class _MoreSectionsView extends ConsumerWidget {
),
),
);
Widget section(Glyph glyph, String title, Widget state, Widget view) {
return ListItem(
leading: GlyphIcon(glyph),
title: Text(title),
subtitle: state,
onTap: () => _push(context, view),
);
}
return CommonScaffold(
title: appLocalizations.more,
body: ListView(
@@ -490,9 +520,10 @@ class _MoreSectionsView extends ConsumerWidget {
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 16),
children: [
generateSectionV3(
generateSection(
items: [
section(
_sectionRow(
context,
AppGlyphs.link,
appLocalizations.dialerProxy,
Text(
@@ -508,7 +539,8 @@ class _MoreSectionsView extends ConsumerWidget {
),
CustomProxyDialersView(profileId),
),
section(
_sectionRow(
context,
AppGlyphs.clock,
'NTP',
Text(
@@ -519,14 +551,16 @@ class _MoreSectionsView extends ConsumerWidget {
),
NtpView(profileId),
),
section(
_sectionRow(
context,
AppGlyphs.eye,
appLocalizations.sniffer,
Text(_enabledText(appLocalizations, status.sniffer)),
SnifferView(profileId),
),
if (showsTun)
section(
_sectionRow(
context,
AppGlyphs.vpn,
appLocalizations.tun,
Text(
@@ -544,124 +578,6 @@ class _MoreSectionsView extends ConsumerWidget {
}
}
/// Balances its rows, so four tiles that fit three abreast make two rows of
/// two rather than three and one.
class _TileGrid extends StatelessWidget {
const _TileGrid({required this.children});
final List<Widget> children;
static const _spacing = 8.0;
static const _minTileWidth = 150.0;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final fit =
((constraints.maxWidth + _spacing) ~/ (_minTileWidth + _spacing))
.clamp(1, children.length);
final rows = (children.length / fit).ceil();
final columns = (children.length / rows).ceil();
return Column(
spacing: _spacing,
children: [
for (var start = 0; start < children.length; start += columns)
Row(
spacing: _spacing,
children: [
for (var index = start; index < start + columns; index++)
Expanded(
child: index < children.length
? children[index]
: const SizedBox.shrink(),
),
],
),
],
);
},
);
}
}
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.glyph,
required this.label,
required this.onPressed,
required this.value,
this.invalid = false,
});
final Glyph glyph;
final String label;
final VoidCallback onPressed;
final Widget value;
final bool invalid;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
return CommonCard(
type: context.isInBottomSheet
? CommonCardType.filled
: CommonCardType.plain,
radius: AppCorner.lg,
onPressed: onPressed,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
spacing: 12,
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: ShapeDecoration(
color: invalid
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
shape: AppShape.circle,
),
child: GlyphIcon(
glyph,
size: 20,
color: invalid
? colorScheme.onErrorContainer
: colorScheme.onSecondaryContainer,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
DefaultTextStyle.merge(
style: textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: value,
),
],
),
),
],
),
),
);
}
}
class _CountText extends StatelessWidget {
const _CountText({required this.count, this.issueCount = 0});
@@ -682,10 +598,11 @@ class _CountText extends StatelessWidget {
}
class _StatusText extends StatelessWidget {
const _StatusText(this.text, {required this.active});
const _StatusText(this.text, {required this.active, this.invalid = false});
final String text;
final bool active;
final bool invalid;
@override
Widget build(BuildContext context) {
@@ -693,7 +610,11 @@ class _StatusText extends StatelessWidget {
return Text(
text,
style: TextStyle(
color: active ? colorScheme.tertiary : colorScheme.onSurfaceVariant,
color: invalid
? colorScheme.error
: active
? colorScheme.tertiary
: colorScheme.onSurfaceVariant,
),
);
}
+30 -29
View File
@@ -4,11 +4,11 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -225,7 +225,11 @@ class _CustomProxiesViewState extends ConsumerState<CustomProxiesView> {
if (!mounted) {
return;
}
final res = await dialogs.showMessage(message: TextSpan(text: message));
final res = await dialogs.showMessage(
message: TextSpan(text: message),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true || !mounted) {
return;
}
@@ -318,6 +322,7 @@ class _CustomProxiesViewState extends ConsumerState<CustomProxiesView> {
added.join(', '),
),
),
destructive: true,
);
return res == true;
}
@@ -428,12 +433,11 @@ class _CustomProxiesViewState extends ConsumerState<CustomProxiesView> {
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.proxies),
illustration: NullStatusIllustration.proxies,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: _handleAddOrUpdate,
icon: const GlyphIcon(AppGlyphs.addCircle, fill: 1),
label: Text(appLocalizations.add),
),
action: TonalButton(
size: TonalButtonSize.bar,
glyph: AppGlyphs.addCircle,
label: appLocalizations.add,
onPressed: _handleAddOrUpdate,
),
),
child: Builder(
@@ -646,6 +650,7 @@ class _ShareLinkDialogState extends State<_ShareLinkDialog> {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: appLocalizations.quickEdit,
hasFields: true,
actions: [
TextButton(
onPressed: _reading ? null : _submit,
@@ -656,11 +661,9 @@ class _ShareLinkDialogState extends State<_ShareLinkDialog> {
controller: _controller,
autofocus: true,
minLines: 3,
maxLines: 6,
maxLines: null,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'URI',
alignLabelWithHint: true,
hintText: 'vless://…',
errorText: _error,
errorMaxLines: 3,
@@ -971,10 +974,9 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
ValueChanged<Object?>? onChanged,
}) {
final update = onChanged ?? (Object? value) => _update(key, value);
final field = TextFormField(
final field = FormRowTextField(
key: ValueKey('$key-$_revision'),
initialValue: value,
textAlign: TextAlign.end,
obscureText: obscureText,
keyboardType: digitsOnly ? TextInputType.number : TextInputType.text,
inputFormatters: digitsOnly
@@ -985,10 +987,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
onChanged: (value) {
update(digitsOnly ? int.tryParse(value) : value);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: hintText ?? context.appLocalizations.optional,
),
hintText: hintText ?? context.appLocalizations.optional,
);
return FormRow(
title: title,
@@ -1163,7 +1162,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
Flexible(
child: Text(
value,
style: context.listTitleStyle?.copyWith(
style: context.listTitleStyle.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
@@ -1300,7 +1299,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
Widget _buildEasyTierSection(CustomProxy proxy) {
final appLocalizations = context.appLocalizations;
final definition = proxy.definition;
return generateSectionV3(
return generateSection(
title: appLocalizations.network,
items: [
_buildTextItem(
@@ -1353,7 +1352,11 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
if (!mounted) {
return;
}
final res = await dialogs.showMessage(message: TextSpan(text: message));
final res = await dialogs.showMessage(
message: TextSpan(text: message),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
ref.read(customProxiesProvider.notifier).delAll([id]);
context.safeNestedPop();
@@ -1409,11 +1412,10 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
final easyTier = proxy.type == 'easytier';
final fields = proxy.fields;
return CommonScaffold(
iconActions: customFormActions(
context,
onQuickEdit: _handleQuickEdit,
onSave: _handleSave,
),
iconActions: [
customQuickEditAction(context, onPressed: _handleQuickEdit),
],
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: CustomScrollView(
slivers: [
SliverCustomIssuesBanner(issues: issues),
@@ -1423,7 +1425,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
).copyWith(bottom: 20),
sliver: SliverList.list(
children: [
generateSectionV3(
generateSection(
title: appLocalizations.basicInfo,
items: [
_buildTextItem(
@@ -1463,7 +1465,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
],
),
if (easyTier) _buildEasyTierSection(proxy),
generateSectionV3(
generateSection(
title: appLocalizations.authentication,
items: [
for (final field in fields)
@@ -1471,7 +1473,7 @@ class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
],
),
_buildOptions(proxy),
generateSectionV3(
generateSection(
title: appLocalizations.action,
items: [
if (!_isNew)
@@ -1546,7 +1548,6 @@ class _ProxyValuesView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return ListEditorPage<String, String>(
title: title,
selectionEnabled: true,
idOf: (value) => value,
itemsOf: (ref) => ref.watch(
customProxyProvider.select((state) => state.stringsOf(field)),
-1
View File
@@ -1,5 +1,4 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
+69 -79
View File
@@ -5,10 +5,10 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -210,6 +210,8 @@ class _CustomProxyGroupsSectionState
? message
: appLocalizations.proxyGroupInUse(inUse.join(', ')),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true || !mounted) {
return;
@@ -257,55 +259,34 @@ class _CustomProxyGroupsSectionState
];
}
List<Widget> _buildActions(Set<int> ids, Set<int> selected) {
List<IconButtonData> _buildActions(Set<int> ids, Set<int> selected) {
final appLocalizations = context.appLocalizations;
if (selected.isNotEmpty) {
return [
TonalButtonGroup(
size: TonalButtonSize.section,
children: [
IconButton(
tooltip: appLocalizations.delete,
onPressed: () => _handleDelete(
selected,
message: appLocalizations.deleteMultipTip(
appLocalizations.proxyGroup,
),
),
icon: const GlyphIcon(AppGlyphs.delete),
IconButtonData(
glyph: AppGlyphs.delete,
tooltip: appLocalizations.delete,
onPressed: () => _handleDelete(
selected,
message: appLocalizations.deleteMultipTip(
appLocalizations.proxyGroup,
),
IconButton(
tooltip: appLocalizations.selectAll,
onPressed: () => _handleSelectAll(ids),
icon: const GlyphIcon(AppGlyphs.selectAll),
),
],
),
),
IconButtonData(
glyph: AppGlyphs.selectAll,
tooltip: appLocalizations.selectAll,
onPressed: () => _handleSelectAll(ids),
),
];
}
return [
if (_sorting || ids.length > 1)
TonalButtonTheme(
size: TonalButtonSize.section,
child: ElasticPress(
child: AppBarActionButton(
data: sortModeAction(
context,
sorting: _sorting,
onPressed: _toggleSorting,
),
),
),
),
TonalButtonTheme(
size: TonalButtonSize.section,
child: ElasticPress(
child: IconButton(
tooltip: appLocalizations.add,
onPressed: _handleAddOrUpdate,
icon: const GlyphIcon(AppGlyphs.addCircle),
),
),
sortModeAction(context, sorting: _sorting, onPressed: _toggleSorting),
IconButtonData(
glyph: AppGlyphs.addCircle,
tooltip: appLocalizations.add,
onPressed: _handleAddOrUpdate,
),
];
}
@@ -353,9 +334,11 @@ class _CustomProxyGroupsSectionState
child: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListHeader(
title: appLocalizations.proxyGroup,
actions: _buildActions(ids, selected),
child: TonalButtonTheme(
child: ListHeader(
title: appLocalizations.proxyGroup,
actions: _buildActions(ids, selected),
),
),
),
if (proxyGroups != null && proxyGroups.isEmpty)
@@ -636,10 +619,16 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
}
Future<void> _showEmptyFallbackOptions() async {
final profileId = ProfileIdProvider.of(context)!.profileId;
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => Consumer(
builder: (_, ref, _) {
final proxies = ref.watch(
customProfileDataProvider(
profileId,
).select((data) => data?.namedProxies ?? const <String>{}),
);
return SelectionSheet<String>(
title: context.appLocalizations.emptyFallback,
sections: [
@@ -647,6 +636,10 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
label: context.appLocalizations.basicStrategy,
items: reservedProxyNames.toList(),
),
SelectionSection(
label: context.appLocalizations.proxies,
items: proxies.toList(),
),
],
labelBuilder: (item) => item,
selectedOf: (ref) =>
@@ -745,20 +738,16 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
String? suffix,
}) {
final appLocalizations = context.appLocalizations;
final field = TextFormField(
final field = FormRowTextField(
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.number),
textAlign: TextAlign.end,
initialValue: value?.toString(),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update((state) => apply(state, int.tryParse(value)));
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.optional,
),
hintText: appLocalizations.optional,
);
return _buildItem(
title: title,
@@ -770,7 +759,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
spacing: 4,
children: [
Flexible(child: field),
Text(suffix, style: context.textTheme.bodyMedium),
Text(suffix, style: AppTextStyle.subheadline.of(context)),
],
),
);
@@ -781,10 +770,9 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
return _buildItem(
title: appLocalizations.testUrl,
leading: leading,
trailing: TextFormField(
trailing: FormRowTextField(
keyboardType: TextInputType.url,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
textAlign: TextAlign.end,
initialValue: url,
onChanged: (value) {
ref
@@ -793,10 +781,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
(state) => state.copyWith(url: value.isEmpty ? null : value),
);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.optional,
),
hintText: appLocalizations.optional,
),
);
}
@@ -865,8 +850,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
return _buildItem(
title: appLocalizations.expectedStatus,
leading: leading,
trailing: TextFormField(
textAlign: TextAlign.end,
trailing: FormRowTextField(
initialValue: expectedStatus,
inputFormatters: TextInputLimits.limit(TextInputLimits.status),
onChanged: (value) {
@@ -878,10 +862,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
),
);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.optional,
),
hintText: appLocalizations.optional,
),
);
}
@@ -931,7 +912,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
else
Text(
appLocalizations.optional,
style: context.listTitleStyle?.copyWith(
style: context.listTitleStyle.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
@@ -946,7 +927,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
return _buildItem(
invalid: invalid,
title: appLocalizations.name,
trailing: TextFormField(
trailing: FormRowTextField(
initialValue: name,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.policyName(TextInputLimits.groupName),
@@ -958,11 +939,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
onFieldSubmitted: (_) {
_handleSave();
},
textAlign: TextAlign.end,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.inputProxyGroupName,
),
hintText: appLocalizations.inputProxyGroupName,
),
);
}
@@ -1066,11 +1043,25 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
}
Future<void> _handleDelete(int profileId) async {
final appLocalizations = context.appLocalizations;
final id = ref.read(proxyGroupProvider).id;
final inUse = await ref.read(profilesActionProvider.notifier).groupsInUse(
profileId,
{id},
);
if (!mounted) {
return;
}
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.confirmDeleteProxyGroup),
message: TextSpan(
text: inUse.isEmpty
? appLocalizations.confirmDeleteProxyGroup
: appLocalizations.proxyGroupInUse(inUse.join(', ')),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
final id = ref.read(proxyGroupProvider).id;
ref.read(proxyGroupsProvider(profileId).notifier).delAll([id]);
context.safeNestedPop();
}
@@ -1350,11 +1341,10 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
.whereType<InvalidEmptyFallbackIssue>()
.toList();
return CommonScaffold(
iconActions: customFormActions(
context,
onQuickEdit: _handleQuickEdit,
onSave: _handleSave,
),
iconActions: [
customQuickEditAction(context, onPressed: _handleQuickEdit),
],
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: CustomScrollView(
key: ValueKey(_revision),
slivers: [
@@ -1363,7 +1353,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.list(
children: [
generateSectionV3(
generateSection(
title: appLocalizations.general,
items: [
_field(
@@ -1388,7 +1378,7 @@ class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
sliver: SliverList.list(
children: [
_buildOptions(emptyFallbackIssues: emptyFallbackIssues),
generateSectionV3(
generateSection(
title: appLocalizations.action,
items: [
if (!_isNew)
+36 -30
View File
@@ -4,10 +4,10 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
@@ -202,6 +202,8 @@ class _IconPickerViewState extends ConsumerState<IconPickerView> {
message: TextSpan(
text: appLocalizations.deleteTip(_iconLabel(context, icon)),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true) {
return;
@@ -405,7 +407,6 @@ class _CurrentIcon extends StatelessWidget {
final description = _describe(context);
final subtitle = description.subtitle;
return CommonCard(
type: CommonCardType.filled,
radius: AppCorner.xl,
child: Padding(
padding: const EdgeInsets.all(12),
@@ -429,7 +430,7 @@ class _CurrentIcon extends StatelessWidget {
description.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge,
style: AppTextStyle.body.of(context),
),
),
if (subtitle != null && subtitle.isNotEmpty)
@@ -437,9 +438,11 @@ class _CurrentIcon extends StatelessWidget {
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
style: AppTextStyle.footnote
.of(context)
.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
],
),
@@ -501,7 +504,7 @@ class SliverIconGrid extends StatelessWidget {
mainAxisExtent:
_iconPlateSize +
_iconLabelGap +
globalState.measure.bodySmallHeight,
globalState.measure.textHeight(AppTextStyle.footnote),
),
itemCount: icons.length,
itemBuilder: (context, index) {
@@ -544,40 +547,43 @@ class _IconTile extends StatelessWidget {
child: Column(
spacing: _iconLabelGap,
children: [
Material(
color: colorScheme.secondaryContainer,
shape: isSelected
? AppShape.md.copyWith(
side: BorderSide(color: colorScheme.primary, width: 2),
)
: AppShape.md,
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: AppShape.md,
onTap: onPressed,
onLongPress: onLongPress,
child: SizedBox.square(
dimension: _iconPlateSize,
child: Center(
Stack(
clipBehavior: Clip.none,
children: [
Material(
color: context.raisedSurfaceLevel.colorOf(colorScheme),
shape: AppShape.md,
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: AppShape.md,
onTap: onPressed,
onLongPress: onLongPress,
child: SizedBox.square(
dimension: _iconSize,
child: IconTheme.merge(
data: const IconThemeData(size: _iconSize),
child: CommonTargetIcon(src: icon.url),
dimension: _iconPlateSize,
child: Center(
child: SizedBox.square(
dimension: _iconSize,
child: IconTheme.merge(
data: const IconThemeData(size: _iconSize),
child: CommonTargetIcon(src: icon.url),
),
),
),
),
),
),
),
if (isSelected)
const Positioned(top: -4, right: -4, child: SelectionBadge()),
],
),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: AppTextStyle.footnote
.of(context)
.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
+80 -29
View File
@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/providers/providers.dart';
@@ -13,9 +12,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// `proxies` names a group's proxies, `use` its providers, `use-position` where
/// those sit among the proxies, and `filter` what of the providers' proxies it
/// takes. `exclude-filter` drops whatever matches it, named members too.
/// The include-all keys take in only the local proxies and providers some group
/// of the profile names, since the config declares no others.
enum _MemberKind {
proxy,
provider,
allProxies,
allProviders,
filter,
excludeFilter;
@@ -24,7 +27,7 @@ enum _MemberKind {
_MemberKind get run => this == provider ? proxy : this;
(Color, Color) chipColorsOf(ColorScheme colorScheme) => switch (this) {
proxy || provider => (
proxy || provider || allProxies || allProviders => (
colorScheme.secondaryContainer,
colorScheme.onSecondaryContainer,
),
@@ -46,18 +49,23 @@ enum _MemberKind {
provider => ref.watch(
customProfileProxyProviderIsValidProvider(profileId, name),
),
filter || excludeFilter => true,
allProxies || allProviders || filter || excludeFilter => true,
};
String invalidMessage(BuildContext context, String name) => switch (this) {
proxy => context.appLocalizations.invalidProxy(name),
provider => context.appLocalizations.invalidProxyProvider(name),
filter || excludeFilter => '',
allProxies || allProviders || filter || excludeFilter => '',
};
}
typedef _Member = ({_MemberKind kind, String name});
const _everyName = '*';
List<String> _everyOf(bool? includeAll) =>
includeAll == true ? const [_everyName] : const [];
/// The form state of type [M] whose [kinds] of members are listed.
abstract class _MemberHost<M> {
const _MemberHost();
@@ -166,6 +174,8 @@ class _GroupMembers extends _MemberHost<ProxyGroup> {
List<String> namesOf(ProxyGroup group, _MemberKind kind) => switch (kind) {
_MemberKind.proxy => group.proxies ?? const [],
_MemberKind.provider => group.use ?? const [],
_MemberKind.allProxies => _everyOf(group.includeAllProxies),
_MemberKind.allProviders => _everyOf(group.includeAllProviders),
_MemberKind.filter => filterParts(group.filter),
_MemberKind.excludeFilter => filterParts(group.excludeFilter),
};
@@ -178,6 +188,12 @@ class _GroupMembers extends _MemberHost<ProxyGroup> {
) => switch (kind) {
_MemberKind.proxy => group.copyWith(proxies: names.isEmpty ? null : names),
_MemberKind.provider => group.copyWith(use: names.isEmpty ? null : names),
_MemberKind.allProxies => group.copyWith(
includeAllProxies: names.isEmpty ? null : true,
),
_MemberKind.allProviders => group.copyWith(
includeAllProviders: names.isEmpty ? null : true,
),
_MemberKind.filter => group.copyWith(filter: joinFilterParts(names)),
_MemberKind.excludeFilter => group.copyWith(
excludeFilter: joinFilterParts(names),
@@ -465,7 +481,14 @@ class _MembersSliverState<M> extends ConsumerState<_MembersSliver<M>>
child: _MemberRow(
member: member,
index: index,
title: member.kind.isFilter ? presets[name] ?? name : name,
title: switch (member.kind) {
_MemberKind.allProxies => appLocalizations.allReferencedProxies,
_MemberKind.allProviders =>
appLocalizations.allReferencedProxyProviders,
_MemberKind.filter ||
_MemberKind.excludeFilter => presets[name] ?? name,
_MemberKind.proxy || _MemberKind.provider => name,
},
subtitle: switch (member.kind) {
_MemberKind.proxy =>
groupTypes[name] ??
@@ -474,6 +497,8 @@ class _MembersSliverState<M> extends ConsumerState<_MembersSliver<M>>
? appLocalizations.basicStrategy
: null),
_MemberKind.provider => appLocalizations.proxyProviders,
_MemberKind.allProxies ||
_MemberKind.allProviders => appLocalizations.autoInclude,
_MemberKind.filter => appLocalizations.filters,
_MemberKind.excludeFilter => appLocalizations.excludeFilter,
},
@@ -571,21 +596,24 @@ class _MemberRow extends ConsumerWidget {
}
}
typedef _Entry = ({String name, String title, String? subtitle});
typedef _Entry = ({
_MemberKind kind,
String name,
String title,
String? subtitle,
});
_Entry _named(String name, String? subtitle) =>
(name: name, title: name, subtitle: subtitle);
_Entry _named(_MemberKind kind, String name, String? subtitle) =>
(kind: kind, name: name, title: name, subtitle: subtitle);
_Entry _every(_MemberKind kind, String title) =>
(kind: kind, name: _everyName, title: title, subtitle: null);
class _Category {
final String label;
final _MemberKind kind;
final List<_Entry> entries;
const _Category({
required this.label,
required this.kind,
required this.entries,
});
const _Category({required this.label, required this.entries});
}
class _MemberPicker<M> extends ConsumerStatefulWidget {
@@ -646,7 +674,12 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
return [
for (final filter in filters)
if (!filter.isAppliedTo(value))
(name: filter.regex, title: filter.label, subtitle: filter.regex),
(
kind: kind,
name: filter.regex,
title: filter.label,
subtitle: filter.regex,
),
];
}
@@ -658,18 +691,19 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
if (kinds.contains(_MemberKind.filter))
_Category(
label: appLocalizations.filters,
kind: _MemberKind.filter,
entries: presetsMissingFrom(_MemberKind.filter),
),
if (kinds.contains(_MemberKind.excludeFilter))
_Category(
label: appLocalizations.excludeFilter,
kind: _MemberKind.excludeFilter,
entries: presetsMissingFrom(_MemberKind.excludeFilter),
),
];
}
bool _takesInEvery(_MemberKind kind) =>
_host.namesOf(_origin, kind).isNotEmpty;
List<_Category> _proxyCategories(AppLocalizations appLocalizations) {
final profileId = ProfileIdProvider.of(context)!.profileId;
final proxies = _host.takenIn(_origin, _MemberKind.proxy);
@@ -679,30 +713,47 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
),
);
final nodes = _nodeTypes(ref);
final hasProviders = ref
.watch(appProviderNamesProvider(ProviderKind.proxy))
.isNotEmpty;
return [
_Category(
label: appLocalizations.basicStrategy,
kind: _MemberKind.proxy,
entries: [
for (final target in RuleTarget.baseTargetNames)
if (!proxies.contains(target)) _named(target, null),
if (!proxies.contains(target))
_named(_MemberKind.proxy, target, null),
],
),
_Category(
label: appLocalizations.autoInclude,
entries: [
if (nodes.isNotEmpty && !_takesInEvery(_MemberKind.allProxies))
_every(
_MemberKind.allProxies,
appLocalizations.allReferencedProxies,
),
if (hasProviders && !_takesInEvery(_MemberKind.allProviders))
_every(
_MemberKind.allProviders,
appLocalizations.allReferencedProxyProviders,
),
],
),
_Category(
label: appLocalizations.localProxies,
kind: _MemberKind.proxy,
entries: [
for (final MapEntry(key: name, value: description) in nodes.entries)
if (!proxies.contains(name)) _named(name, description),
if (!proxies.contains(name))
_named(_MemberKind.proxy, name, description),
],
),
_Category(
label: appLocalizations.proxyGroup,
kind: _MemberKind.proxy,
entries: [
for (final item in groups.value)
if (!proxies.contains(item.name))
_named(item.name, item.type.value),
_named(_MemberKind.proxy, item.name, item.type.value),
],
),
];
@@ -716,10 +767,10 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
final providers = _providerTypes(ref, appLocalizations);
return _Category(
label: appLocalizations.proxyProviders,
kind: _MemberKind.provider,
entries: [
for (final name in providerNames)
if (!use.contains(name)) _named(name, providers[name]),
if (!use.contains(name))
_named(_MemberKind.provider, name, providers[name]),
],
);
}
@@ -731,7 +782,6 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
for (final category in _categories(appLocalizations))
_Category(
label: category.label,
kind: category.kind,
entries: category.entries
.whereMatches(_query, (entry) => [entry.title, entry.subtitle])
.toList(),
@@ -770,7 +820,7 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
initiallyExpanded: false,
itemCount: category.entries.length,
itemBuilder: (_, index) =>
_buildEntry(category.kind, category.entries[index]),
_buildEntry(category.entries[index]),
),
],
),
@@ -782,7 +832,8 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
);
}
Widget _buildEntry(_MemberKind kind, _Entry entry) {
Widget _buildEntry(_Entry entry) {
final kind = entry.kind;
final member = (kind: kind, name: entry.name);
final isSelected = _selected.contains(member);
final subtitle = entry.subtitle;
@@ -797,7 +848,7 @@ class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
_ when kind.isFilter => FilterRegexText(subtitle),
_ => TooltipLabel(subtitle, maxLines: 1),
},
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
trailing: isSelected ? const SelectionCheck() : null,
);
}
}
+14 -21
View File
@@ -3,10 +3,10 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/profiles/custom/members.dart';
import 'package:fl_clash/views/profiles/custom/quick_edit.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -66,7 +66,7 @@ class CustomProvidersView extends ConsumerWidget {
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 16),
children: [
generateSectionV3(
generateSection(
title: appLocalizations.proxies,
items: [
for (final name in proxy)
@@ -79,7 +79,7 @@ class CustomProvidersView extends ConsumerWidget {
),
],
),
generateSectionV3(
generateSection(
title: appLocalizations.rules,
items: [
for (final name in rule)
@@ -161,12 +161,13 @@ class _ProxyProviderItem extends ConsumerWidget {
? InfoMessageButton(
message: context.appLocalizations.invalidProxyProvider(name),
)
: CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
: TonalButtonTheme(
size: TonalButtonSize.compact,
child: ElasticPress(
child: IconButton(
tooltip: context.appLocalizations.edit,
onPressed: () => _handleEdit(context, ref),
icon: const GlyphIcon(AppGlyphs.edit, fill: 1),
icon: const GlyphIcon(AppGlyphs.edit),
),
),
),
@@ -407,15 +408,11 @@ class _ProxyProviderOptionsViewState
return FormRow(
title: option.label(context.appLocalizations),
leading: leading,
trailing: TextFormField(
textAlign: TextAlign.end,
trailing: FormRowTextField(
initialValue: value,
inputFormatters: TextInputLimits.limit(maxLength),
onChanged: (value) => onChanged(value.isEmpty ? null : value),
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: context.appLocalizations.optional,
),
hintText: context.appLocalizations.optional,
),
);
}
@@ -435,21 +432,17 @@ class _ProxyProviderOptionsViewState
spacing: 4,
children: [
Flexible(
child: TextFormField(
child: FormRowTextField(
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(
TextInputLimits.number,
),
textAlign: TextAlign.end,
initialValue: value?.toString(),
onChanged: (value) => onChanged(int.tryParse(value)),
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: context.appLocalizations.optional,
),
hintText: context.appLocalizations.optional,
),
),
Text(suffix, style: context.textTheme.bodyMedium),
Text(suffix, style: AppTextStyle.subheadline.of(context)),
],
),
);
@@ -604,7 +597,7 @@ class _ProxyProviderOptionsViewState
void handleAdd() => _handleAddOption(remaining);
return CommonScaffold(
title: widget.name,
iconActions: customFormActions(context, onSave: _handleSave),
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: CustomScrollView(
slivers: [
SliverPadding(
+21 -20
View File
@@ -1,31 +1,31 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/widgets/sheet_navigator.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
List<IconButtonData> customFormActions(
IconButtonData customQuickEditAction(
BuildContext context, {
VoidCallback? onQuickEdit,
required VoidCallback onSave,
required VoidCallback onPressed,
}) {
final appLocalizations = context.appLocalizations;
return [
if (onQuickEdit != null)
IconButtonData(
glyph: AppGlyphs.compose,
onPressed: onQuickEdit,
tooltip: appLocalizations.quickEdit,
),
IconButtonData(
glyph: AppGlyphs.check,
onPressed: onSave,
tooltip: appLocalizations.save,
),
];
return IconButtonData(
glyph: AppGlyphs.compose,
onPressed: onPressed,
tooltip: context.appLocalizations.quickEdit,
);
}
IconButtonData customSaveAction(
BuildContext context, {
required VoidCallback onPressed,
}) {
return IconButtonData(
glyph: AppGlyphs.check,
onPressed: onPressed,
tooltip: context.appLocalizations.save,
);
}
/// Thrown by an `apply` the user called off, which keeps the editor open.
@@ -65,6 +65,7 @@ Future<void> showCustomQuickEdit(
message: TextSpan(
text: '$message\n\n${currentAppLocalizations.discardChanges}',
),
destructive: true,
);
return res == true;
}
+27 -74
View File
@@ -1,10 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -48,6 +48,8 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
_handleDelete({rule.id});
@@ -100,6 +102,9 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
content,
previous: previous,
)) {
if (rule.ruleAction == RuleAction.SUB_RULE) {
continue;
}
final error = _ruleSaveError(context, rule);
if (error != null) {
throw MessageException(
@@ -139,7 +144,6 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
final profileData = ref.watch(customProfileDataProvider(_profileId));
return ListEditorPage<Rule, int>(
title: appLocalizations.rule,
selectionEnabled: true,
idOf: (rule) => rule.id,
itemsOf: (ref) {
return ref.watch(profileRulesProvider(_profileId)).value;
@@ -167,7 +171,7 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
onAdd: () => _handleAddOrUpdate(),
addMenuItems: [
CommonPopupMenuItem(
glyph: AppGlyphs.bolt,
glyph: AppGlyphs.rulePresets,
label: appLocalizations.quickActions,
onPressed: _handleQuickActions,
),
@@ -256,7 +260,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
action.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle?.copyWith(
style: context.listTitleStyle.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
@@ -273,7 +277,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
final payloadError = ref.watch(
ruleProvider.select((state) => state.payloadError),
);
final field = TextFormField(
final field = FormRowTextField(
initialValue: content,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
@@ -282,11 +286,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
.read(ruleProvider.notifier)
.update((state) => state.copyWith(content: value));
},
textAlign: TextAlign.end,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.inputRuleContent,
),
hintText: appLocalizations.inputRuleContent,
);
return _buildItem(
invalid: payloadError != null,
@@ -317,7 +317,9 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.ruleProvider)),
onSelected: (item) => Navigator.of(context).pop(item),
emptyLabel: context.appLocalizations.proxyProvidersEmpty,
emptyLabel: context.appLocalizations.nullTip(
context.appLocalizations.ruleProviders,
),
);
},
),
@@ -359,7 +361,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
ruleProvider ?? appLocalizations.selectRuleSet,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle?.copyWith(color: foregroundColor),
style: context.listTitleStyle.copyWith(color: foregroundColor),
),
),
),
@@ -454,7 +456,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
target ?? appLocalizations.selectSplitStrategy,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle?.copyWith(
style: context.listTitleStyle.copyWith(
color: foregroundColor,
),
),
@@ -469,56 +471,6 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
);
}
Future<void> _handleSelectedSubRule() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => SelectionSheet<String>(
title: context.appLocalizations.subRule,
sections: const [SelectionSection(items: <String>[])],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.subRule)),
onSelected: (item) => Navigator.of(context).pop(item),
emptyLabel: context.appLocalizations.subRuleEmpty,
),
),
);
if (res == null) {
return;
}
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(subRule: res));
}
Widget _buildSubRuleItem(String? subRule) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.subRule,
onPressed: _handleSelectedSubRule,
trailing: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
flex: 1,
child: TooltipText(
text: Text(
subRule ?? appLocalizations.selectSubRule,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildNoResolveItem(bool noResolve, bool src) {
final appLocalizations = context.appLocalizations;
// The core turns no-resolve on with src, so the switch cannot disagree.
@@ -567,6 +519,8 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
final id = ref.read(ruleProvider).id;
@@ -581,14 +535,14 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
final profileId = ProfileIdProvider.of(context)!.profileId;
final rule = ref.watch(ruleProvider);
return CommonScaffold(
iconActions: customFormActions(context, onSave: _handleSave),
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
generateSection(
title: appLocalizations.basicInfo,
items: [
_buildTypeItem(rule.ruleAction),
@@ -596,20 +550,18 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
rule.ruleAction == RuleAction.RULE_SET
? _buildRuleProviderItem(profileId, rule.ruleProvider)
: _buildContentItem(rule.content),
rule.ruleAction != RuleAction.SUB_RULE
? _buildTargetItem(profileId, rule.ruleTarget)
: _buildSubRuleItem(rule.subRule),
_buildTargetItem(profileId, rule.ruleTarget),
],
),
if (rule.ruleAction.hasParams)
generateSectionV3(
generateSection(
title: appLocalizations.additionalParameters,
items: [
_buildNoResolveItem(rule.noResolve, rule.src),
_buildSrcItem(rule.src),
],
),
generateSectionV3(
generateSection(
title: appLocalizations.action,
items: [
if (rule.id != -1)
@@ -648,6 +600,9 @@ String? _handleSaveRule(BuildContext context, WidgetRef ref) {
String? _ruleSaveError(BuildContext context, Rule rule) {
final appLocalizations = context.appLocalizations;
if (rule.ruleAction == RuleAction.SUB_RULE) {
return appLocalizations.invalidSubRule(rule.subRule ?? '');
}
final payloadError = rule.payloadError;
if (payloadError != null) {
return payloadError.getMessage(context);
@@ -655,13 +610,11 @@ String? _ruleSaveError(BuildContext context, Rule rule) {
if (rule.ruleAction != RuleAction.MATCH &&
rule.realContent?.isNotEmpty != true) {
return rule.ruleAction == RuleAction.RULE_SET
? appLocalizations.proxyProvidersNotEmpty
? appLocalizations.emptyTip(appLocalizations.ruleSet)
: appLocalizations.contentNotEmpty;
}
if (rule.realTarget?.isNotEmpty != true) {
return rule.ruleAction == RuleAction.SUB_RULE
? appLocalizations.subRuleNotEmpty
: appLocalizations.splitStrategyNotEmpty;
return appLocalizations.splitStrategyNotEmpty;
}
return null;
}
-1
View File
@@ -1 +0,0 @@
export 'package:fl_clash/features/form/form_row.dart' show InfoMessageButton;
+228 -143
View File
@@ -4,26 +4,25 @@ import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
enum _ProfileField { label, url, autoUpdateInterval }
class EditProfileView extends ConsumerStatefulWidget {
final Profile profile;
final BuildContext context;
const EditProfileView({
super.key,
required this.context,
required this.profile,
});
const EditProfileView({super.key, required this.profile});
@override
ConsumerState<EditProfileView> createState() => _EditProfileViewState();
@@ -36,7 +35,7 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
late final TextEditingController _autoUpdateDurationController;
late bool _autoUpdate;
String? _rawText;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _attempted = false;
final _fileInfoNotifier = ValueNotifier<FileInfo?>(null);
late SetupAction _setupAction;
Uint8List? _fileData;
@@ -54,9 +53,7 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
text: widget.profile.autoUpdateDuration.inMinutes.toString(),
);
_setupAction = ref.read(setupActionProvider.notifier);
if (widget.profile.type != ProfileType.custom) {
_updateFileInfo();
}
_updateFileInfo();
}
Future<void> _updateFileInfo() async {
@@ -68,22 +65,83 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
_fileInfoNotifier.value = fileInfo;
}
({_ProfileField field, String message})? _issue(
AppLocalizations appLocalizations,
) {
final labelError = validateProfileName(
appLocalizations,
_labelController.text,
);
if (labelError != null) {
return (field: _ProfileField.label, message: labelError);
}
if (widget.profile.type != ProfileType.url) {
return null;
}
final url = _urlController.text;
if (url.isEmpty) {
return (
field: _ProfileField.url,
message: appLocalizations.profileUrlNullValidationDesc,
);
}
if (!url.isUrl) {
return (
field: _ProfileField.url,
message: appLocalizations.profileUrlInvalidValidationDesc,
);
}
if (!_autoUpdate) {
return null;
}
final interval = _autoUpdateDurationController.text;
if (interval.isEmpty) {
return (
field: _ProfileField.autoUpdateInterval,
message: appLocalizations.profileAutoUpdateIntervalNullValidationDesc,
);
}
if ((int.tryParse(interval) ?? 0) <= 0) {
return (
field: _ProfileField.autoUpdateInterval,
message:
appLocalizations.profileAutoUpdateIntervalInvalidValidationDesc,
);
}
return null;
}
void _handleChanged(String _) {
if (_attempted) {
setState(() {});
}
}
Future<void> _handleConfirm() async {
if (!_formKey.currentState!.validate()) return;
var profile = widget.profile.copyWith(
if (_issue(context.appLocalizations) != null) {
setState(() {
_attempted = true;
});
return;
}
final current =
ref.read(profilesProvider).getProfile(widget.profile.id) ??
widget.profile;
final interval = int.tryParse(_autoUpdateDurationController.text);
var profile = current.copyWith(
url: _urlController.text,
ageSecretKey: _ageSecretKeyController.text.trim(),
label: _labelController.text,
autoUpdate: _autoUpdate,
autoUpdateDuration: Duration(
minutes: int.parse(_autoUpdateDurationController.text),
),
autoUpdateDuration: interval != null && interval > 0
? Duration(minutes: interval)
: current.autoUpdateDuration,
);
final profilesAction = ref.read(profilesActionProvider.notifier);
final appLocalizations = context.appLocalizations;
final hasUpdate =
widget.profile.url != profile.url ||
widget.profile.ageSecretKey != profile.ageSecretKey;
current.url != profile.url ||
current.ageSecretKey != profile.ageSecretKey;
if (_fileData != null) {
if (profile.type == ProfileType.url && _autoUpdate) {
final res = await dialogs.showMessage(
@@ -179,11 +237,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
},
);
final data = await BaseNavigator.push<String>(context, editorPage);
if (data == null) {
if (data == null || !mounted) {
return;
}
_rawText = data;
_fileData = Uint8List.fromList(utf8.encode(data));
setState(() {
_fileData = Uint8List.fromList(utf8.encode(data));
});
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
size: _fileData?.length ?? 0,
lastModified: DateTime.now(),
@@ -193,10 +253,13 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
Future<void> _uploadProfileFile() async {
final platformFile = await globalState.safeRun(picker.pickerFile);
if (platformFile == null) return;
_fileData = await platformFile.readBytes();
final fileData = await platformFile.readBytes();
if (!mounted) {
return;
}
setState(() {
_fileData = fileData;
});
_fileInfoNotifier.value = _fileInfoNotifier.value?.copyWith(
size: _fileData?.length ?? 0,
lastModified: DateTime.now(),
@@ -232,64 +295,74 @@ class _EditProfileViewState extends ConsumerState<EditProfileView> {
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final items = <Widget>[
_ProfileNameField(controller: _labelController),
if (widget.profile.type == ProfileType.url)
_ProfileUrlField(controller: _urlController),
if (widget.profile.type != ProfileType.custom)
_AgeSecretKeyField(controller: _ageSecretKeyController),
if (widget.profile.type == ProfileType.url) ...[
ListItem.toggle(
title: Text(appLocalizations.autoUpdate),
value: _autoUpdate,
onChanged: _setAutoUpdate,
),
if (_autoUpdate)
_AutoUpdateIntervalField(controller: _autoUpdateDurationController),
],
if (widget.profile.type != ProfileType.custom)
_ProfileFileItem(
fileInfoNotifier: _fileInfoNotifier,
onEdit: _editProfileFile,
onUpload: _uploadProfileFile,
final isUrl = widget.profile.type == ProfileType.url;
final issue = _attempted ? _issue(appLocalizations) : null;
final sections = [
generateSection(
items: [
_ProfileNameField(
controller: _labelController,
invalid: issue?.field == _ProfileField.label,
onChanged: _handleChanged,
),
if (isUrl)
_ProfileUrlField(
controller: _urlController,
invalid: issue?.field == _ProfileField.url,
onChanged: _handleChanged,
),
_AgeSecretKeyField(controller: _ageSecretKeyController),
],
),
if (isUrl)
generateSection(
items: [
FormRow(
title: appLocalizations.autoUpdate,
onPressed: () => _setAutoUpdate(!_autoUpdate),
trailing: Switch(value: _autoUpdate, onChanged: _setAutoUpdate),
),
if (_autoUpdate)
_AutoUpdateIntervalField(
controller: _autoUpdateDurationController,
invalid: issue?.field == _ProfileField.autoUpdateInterval,
onChanged: _handleChanged,
),
],
),
_ProfileFileItem(
fileInfoNotifier: _fileInfoNotifier,
onEdit: _editProfileFile,
onUpload: _uploadProfileFile,
),
];
return FocusTraversalGroup(
policy: PageTraversalPolicy(),
child: PageFocusScope(
child: CommonPopScope(
onPop: (context) {
if (_fileData == null) {
return true;
}
_handleBack();
return false;
},
onPop: _fileData == null
? null
: (_) {
_handleBack();
return false;
},
child: CommonScaffold(
title: appLocalizations.edit,
iconActions: [
IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleConfirm,
tooltip: appLocalizations.save,
),
],
body: Form(
key: _formKey,
child: Builder(
builder: (context) => ListView.separated(
padding: kMaterialListPadding.copyWith(
top: context.contentTopPadding,
bottom: BottomInsetScope.of(context) + 16,
),
itemBuilder: (_, index) {
return items[index];
},
separatorBuilder: (_, _) {
return const SizedBox(height: 24);
},
itemCount: items.length,
confirmationAction: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleConfirm,
tooltip: appLocalizations.save,
),
body: Builder(
builder: (context) => ListView(
padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(
top: context.contentTopPadding,
bottom: BottomInsetScope.of(context) + 16,
),
children: [
ErrorBanner(message: issue?.message),
...sections.separated(const SizedBox(height: 24)),
],
),
),
),
@@ -331,51 +404,59 @@ Future<void> renameProfile(
}
class _ProfileNameField extends StatelessWidget {
const _ProfileNameField({required this.controller});
const _ProfileNameField({
required this.controller,
required this.invalid,
required this.onChanged,
});
final TextEditingController controller;
final bool invalid;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return ListItem(
title: TextFormField(
textInputAction: TextInputAction.next,
return FormRow(
title: appLocalizations.name,
invalid: invalid,
trailing: FormRowTextField(
controller: controller,
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
decoration: InputDecoration(labelText: appLocalizations.name),
validator: (value) => validateProfileName(appLocalizations, value),
onChanged: onChanged,
),
);
}
}
class _ProfileUrlField extends StatelessWidget {
const _ProfileUrlField({required this.controller});
const _ProfileUrlField({
required this.controller,
required this.invalid,
required this.onChanged,
});
final TextEditingController controller;
final bool invalid;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return ListItem(
title: TextFormField(
textInputAction: TextInputAction.next,
keyboardType: TextInputType.url,
return FormRow(
title: appLocalizations.url,
invalid: invalid,
trailing: FormRowTextField(
controller: controller,
hintText: appLocalizations.required,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
maxLines: 5,
minLines: 1,
decoration: InputDecoration(labelText: appLocalizations.url),
validator: (String? value) {
if (value == null || value.isEmpty) {
return appLocalizations.profileUrlNullValidationDesc;
}
if (!value.isUrl) {
return appLocalizations.profileUrlInvalidValidationDesc;
}
return null;
},
onChanged: onChanged,
),
);
}
@@ -389,48 +470,55 @@ class _AgeSecretKeyField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return ListItem(
title: SecretTextFormField(
textInputAction: TextInputAction.next,
return FormRow(
title: appLocalizations.ageSecretKey,
trailing: FormRowSecretTextField(
controller: controller,
maxLines: 5,
decoration: InputDecoration(
labelText: appLocalizations.ageSecretKey,
hintText: appLocalizations.optional,
),
hintText: appLocalizations.optional,
textInputAction: TextInputAction.next,
),
);
}
}
class _AutoUpdateIntervalField extends StatelessWidget {
const _AutoUpdateIntervalField({required this.controller});
const _AutoUpdateIntervalField({
required this.controller,
required this.invalid,
required this.onChanged,
});
final TextEditingController controller;
final bool invalid;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return ListItem(
title: TextFormField(
textInputAction: TextInputAction.next,
controller: controller,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.interval),
decoration: InputDecoration(
labelText: appLocalizations.autoUpdateInterval,
),
validator: (String? value) {
if (value == null || value.isEmpty) {
return appLocalizations.profileAutoUpdateIntervalNullValidationDesc;
}
try {
int.parse(value);
} catch (_) {
return appLocalizations
.profileAutoUpdateIntervalInvalidValidationDesc;
}
return null;
},
return FormRow(
title: appLocalizations.autoUpdateInterval,
invalid: invalid,
trailing: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(
child: FormRowTextField(
controller: controller,
hintText: appLocalizations.required,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
inputFormatters: TextInputLimits.digitsOnly(
TextInputLimits.interval,
),
onChanged: onChanged,
),
),
Text(
appLocalizations.minutes,
style: AppTextStyle.subheadline.of(context),
),
],
),
);
}
@@ -482,28 +570,25 @@ class _ProfileFileItem extends StatelessWidget {
alignment: Alignment.centerLeft,
child: fileInfo == null
? Container()
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ItemPositionProvider(
position: ItemPosition.startAndEnd,
child: ContextMenuRegion(
menuItems: _menuItems(context),
child: DecorationListItem(
contentPadding: const EdgeInsets.only(left: 16),
title: Text(appLocalizations.profile),
subtitle: _buildSubtitle(context, fileInfo),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
fileInfo.size.traffic.show,
style: context.listCaptionStyle,
),
const DisclosureIndicator(),
],
),
onPressed: onEdit,
: ItemPositionProvider(
position: ItemPosition.startAndEnd,
child: ContextMenuRegion(
menuItems: _menuItems(context),
child: DecorationListItem(
contentPadding: const EdgeInsets.only(left: 16),
title: Text(appLocalizations.profile),
subtitle: _buildSubtitle(context, fileInfo),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
fileInfo.size.traffic.show,
style: context.listCaptionStyle,
),
const DisclosureIndicator(),
],
),
onPressed: onEdit,
),
),
),
+7 -4
View File
@@ -1,7 +1,7 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -105,6 +105,7 @@ class _Title extends ConsumerWidget {
for (final type in ExtendType.values)
CommonCard(
isSelected: extendType == type,
fillsSelection: true,
onPressed: () {
_handleChangeType(ref, profileId, type);
},
@@ -129,9 +130,11 @@ class _Title extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_getDesc(context, extendType),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant.opacity80,
),
style: AppTextStyle.footnote
.of(context)
.copyWith(
color: context.colorScheme.onSurfaceVariant.opacity80,
),
),
),
],
+12 -40
View File
@@ -16,12 +16,6 @@ class ScriptContent extends ConsumerWidget {
});
}
void _handleRadioChange(WidgetRef ref, int profileId, int? scriptId) {
ref.read(profilesProvider.notifier).updateProfile(profileId, (state) {
return state.copyWith(scriptId: scriptId);
});
}
@override
Widget build(BuildContext context, ref) {
final appLocalizations = context.appLocalizations;
@@ -44,24 +38,17 @@ class ScriptContent extends ConsumerWidget {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: RadioGroup<int>(
groupValue: scriptId,
onChanged: (value) {
_handleRadioChange(ref, profileId, value);
},
child: generateSectionV3(
items: [
for (final script in scripts)
_ScriptOption(
label: script.label,
value: script.id,
isSelected: script.id == scriptId,
onSelected: () {
_handleChange(ref, profileId, script.id);
},
),
],
),
child: generateSection(
items: [
for (final script in scripts)
_ScriptOption(
label: script.label,
isSelected: script.id == scriptId,
onSelected: () {
_handleChange(ref, profileId, script.id);
},
),
],
),
),
),
@@ -82,13 +69,11 @@ class ScriptContent extends ConsumerWidget {
class _ScriptOption extends StatelessWidget {
const _ScriptOption({
required this.label,
required this.value,
required this.isSelected,
required this.onSelected,
});
final String label;
final int value;
final bool isSelected;
final VoidCallback onSelected;
@@ -96,21 +81,8 @@ class _ScriptOption extends StatelessWidget {
Widget build(BuildContext context) {
return DecorationListItem(
isSelected: isSelected,
horizontalTitleGap: 8,
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: SizedBox(
width: 24,
height: 24,
child: ExcludeFocus(
child: Radio<int>(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
toggleable: true,
value: value,
),
),
),
title: Text(label),
trailing: isSelected ? const SelectionCheck() : null,
onPressed: onSelected,
);
}
+46 -67
View File
@@ -1,10 +1,9 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/models/state.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/config/rules.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -22,8 +21,9 @@ class _StandardContentState extends ConsumerState<StandardContent> {
late int _profileId;
Future<void> _handleAddOrUpdate([Rule? rule]) async {
final res = await dialogs.showCommonDialog<Rule>(
child: AddOrEditRuleDialog(rule: rule),
final res = await showFormSheet<Rule>(
context: context,
builder: (_) => RuleForm(rule: rule),
);
if (res == null) {
return;
@@ -68,6 +68,8 @@ class _StandardContentState extends ConsumerState<StandardContent> {
message: TextSpan(
text: appLocalizations.deleteMultipTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true) {
return;
@@ -122,14 +124,12 @@ class _StandardContentState extends ConsumerState<StandardContent> {
final addedRules = ref.watch(profileRulesProvider(_profileId)).value ?? [];
final selectedRules = ref.watch(itemsProvider(_key));
return CommonPopScope(
onPop: (_) {
if (selectedRules.isNotEmpty) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop();
return false;
},
onPop: selectedRules.isEmpty
? null
: (_) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
},
child: SliverMainAxisGroup(
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 24)),
@@ -139,56 +139,33 @@ class _StandardContentState extends ConsumerState<StandardContent> {
ListHeader(
title: appLocalizations.addedRules,
padding: EdgeInsets.symmetric(
horizontal: 16.mAp,
vertical: 8.mAp,
horizontal: 16.mAp(context),
vertical: 8.mAp(context),
),
actions: [
if (selectedRules.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.delete,
onPressed: () {
_handleDelete();
},
icon: const GlyphIcon(AppGlyphs.delete, fill: 1),
),
),
IconButtonData(
glyph: AppGlyphs.delete,
tooltip: appLocalizations.delete,
onPressed: _handleDelete,
),
IconButtonData(
glyph: AppGlyphs.selectAll,
tooltip: appLocalizations.selectAll,
onPressed: _handleSelectAll,
),
] else ...[
IconButtonData(
glyph: AppGlyphs.rulePresets,
tooltip: appLocalizations.quickActions,
onPressed: _handleQuickActions,
),
IconButtonData(
glyph: AppGlyphs.addCircle,
tooltip: appLocalizations.add,
onPressed: _handleAddOrUpdate,
),
],
if (selectedRules.isEmpty)
CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: appLocalizations.quickActions,
onPressed: _handleQuickActions,
icon: const GlyphIcon(AppGlyphs.bolt, fill: 1),
),
),
),
if (selectedRules.isNotEmpty)
CommonMinFilledButtonTheme(
child: ElasticButton(
child: FilledButton(
onPressed: () {
_handleSelectAll();
},
child: Text(appLocalizations.selectAll),
),
),
)
else
CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: appLocalizations.add,
onPressed: () {
_handleAddOrUpdate();
},
icon: const GlyphIcon(AppGlyphs.addCircle, fill: 1),
),
),
),
],
),
],
@@ -300,11 +277,14 @@ class _MatchTargetItem extends ConsumerWidget {
label: appLocalizations.matchTarget,
trailing: Text(
matchTarget ?? appLocalizations.followProfile,
style: context.textTheme.bodyMedium?.toJetBrainsMono.copyWith(
color: invalid
? context.colorScheme.error
: context.colorScheme.tertiary,
),
style: AppTextStyle.subheadline
.of(context)
.toJetBrainsMono
.copyWith(
color: invalid
? context.colorScheme.error
: context.colorScheme.tertiary,
),
),
onPressed: () {
_handleSelect(context, ref);
@@ -369,11 +349,10 @@ class _EditGlobalAddedRulesState extends ConsumerState<_EditGlobalAddedRules> {
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.rule),
illustration: NullStatusIllustration.rules,
action: ElasticButton(
child: FilledButton.tonal(
onPressed: _handleToAddedRules,
child: Text(appLocalizations.goToConfigureAddedRules),
),
action: TonalButton(
size: TonalButtonSize.bar,
label: appLocalizations.goToConfigureAddedRules,
onPressed: _handleToAddedRules,
),
),
child: ScrollConfiguration(
+104 -114
View File
@@ -1,12 +1,11 @@
import 'dart:async';
import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/profiles/custom/custom.dart';
import 'package:fl_clash/views/profiles/extend/extend.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -22,29 +21,25 @@ const _infoGap = 4.0;
double get _profileItemHeight {
final measure = globalState.measure;
return max(
SourceIcon.size,
measure.titleMediumHeight + _infoGap + measure.bodySmallHeight,
) +
return measure.textHeight(AppTextStyle.headline) +
_infoGap +
measure.textHeight(AppTextStyle.footnote) +
2 * listRowVerticalPadding;
}
class ProfilesView extends ConsumerStatefulWidget {
const ProfilesView({super.key});
const ProfilesView({super.key, this.picker = false});
/// Clears the bar and opens at the top, for switching profiles from a sheet.
final bool picker;
@override
ConsumerState<ProfilesView> createState() => _ProfilesViewState();
}
class _ProfilesViewState extends ConsumerState<ProfilesView> {
Function? applyConfigDebounce;
bool _isUpdating = false;
@override
void initState() {
super.initState();
}
Future<void> _updateProfiles(List<Profile> profiles) async {
if (_isUpdating == true) {
return;
@@ -104,18 +99,19 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
final appLocalizations = context.appLocalizations;
final isLoading = ref.watch(loadingProvider(LoadingTag.profiles));
final state = ref.watch(profilesStateProvider);
final spacing = cardSpacing;
final spacing = cardSpacingOf(context, ref.watch(viewModeProvider));
final picker = widget.picker;
return CommonScaffold(
isLoading: isLoading,
title: appLocalizations.profiles,
primaryAction: state.profiles.isEmpty
primaryAction: state.profiles.isEmpty || picker
? null
: IconButtonData(
glyph: AppGlyphs.addCircle,
onPressed: () => showAddProfilePage(context),
tooltip: appLocalizations.addProfile,
),
iconActions: _buildActions(state.profiles),
iconActions: picker ? const [] : _buildActions(state.profiles),
foldPrimaryAction: true,
body: NullStatusSwitcher(
isEmpty: state.profiles.isEmpty,
@@ -123,18 +119,18 @@ class _ProfilesViewState extends ConsumerState<ProfilesView> {
label: appLocalizations.nullTip(appLocalizations.profiles),
description: appLocalizations.nullProfileDesc,
illustration: NullStatusIllustration.profile,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: () => showAddProfilePage(context),
icon: const GlyphIcon(AppGlyphs.addCircle, fill: 1),
label: Text(appLocalizations.addProfile),
),
action: TonalButton(
size: TonalButtonSize.bar,
glyph: AppGlyphs.addCircle,
label: appLocalizations.addProfile,
onPressed: () => showAddProfilePage(context),
),
),
child: _ProfilesGrid(
profiles: state.profiles,
currentProfileId: state.currentProfileId,
spacing: spacing,
revealsSelected: !picker,
),
),
);
@@ -148,11 +144,13 @@ class _ProfilesGrid extends ConsumerStatefulWidget {
required this.profiles,
required this.currentProfileId,
required this.spacing,
required this.revealsSelected,
});
final List<Profile> profiles;
final int? currentProfileId;
final double spacing;
final bool revealsSelected;
@override
ConsumerState<_ProfilesGrid> createState() => _ProfilesGridState();
@@ -173,7 +171,9 @@ class _ProfilesGridState extends ConsumerState<_ProfilesGrid> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_revealed || !PageActivityScope.isActiveOf(context)) {
if (_revealed ||
!widget.revealsSelected ||
!PageActivityScope.isActiveOf(context)) {
return;
}
_revealed = true;
@@ -196,7 +196,7 @@ class _ProfilesGridState extends ConsumerState<_ProfilesGrid> {
int _columnsFor(double width) => getProfilesColumns(
width - _horizontalPadding * 2,
spacing: widget.spacing,
minItemWidth: profileItemMinWidth.ap,
minItemWidth: profileItemMinWidth.ap(context),
);
void _revealSelected() {
@@ -230,14 +230,14 @@ class _ProfilesGridState extends ConsumerState<_ProfilesGrid> {
@override
Widget build(BuildContext context) {
final profiles = widget.profiles;
return LayoutBuilder(
builder: (_, constraints) {
return WidthBuilder(
builder: (_, width) {
return GridView.builder(
key: profilesStoreKey,
controller: _controller,
padding: _padding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columnsFor(constraints.maxWidth),
crossAxisCount: _columnsFor(width),
mainAxisSpacing: widget.spacing,
crossAxisSpacing: widget.spacing,
mainAxisExtent: _profileItemHeight,
@@ -286,6 +286,8 @@ class ProfileItem extends ConsumerWidget {
users.map((item) => item.realLabel).join(', '),
),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true) {
return;
@@ -299,34 +301,6 @@ class ProfileItem extends ConsumerWidget {
);
}
void _handleShowSubscriptionInfo(BuildContext context) {
unawaited(
dialogs.showCommonDialog<void>(
context: context,
child: Builder(
builder: (context) {
return CommonDialog(
backgroundColor: context.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
title: context.appLocalizations.subscriptionInfo,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(context.appLocalizations.confirm),
),
],
child: SubscriptionInfoDetailView(
subscriptionInfo: profile.subscriptionInfo!,
),
);
},
),
),
);
}
Future updateProfile(WidgetRef ref) async {
if (profile.type != ProfileType.url) return;
await globalState.loadingRun(() async {
@@ -339,23 +313,25 @@ class ProfileItem extends ConsumerWidget {
void _handleShowEditExtendPage(BuildContext context) {
showExtend(
context,
builder: (context) => EditProfileView(profile: profile, context: context),
builder: (context) => EditProfileView(profile: profile),
);
}
SubscriptionInfo? get _subscriptionInfo =>
profile.type == ProfileType.url ? profile.subscriptionInfo : null;
Widget? _buildTraffic(BuildContext context) {
String _subtitle(BuildContext context) {
final info = _subscriptionInfo;
if (info?.usage == null) {
return null;
if (info != null && info.usage != null) {
final used = info.upload + info.download;
return '${used.traffic.show} / ${info.total.traffic.show}';
}
final used = info!.upload + info.download;
return Text(
'${used.traffic.show} / ${info.total.traffic.show}',
style: context.textTheme.bodySmall?.toLighter,
);
final appLocalizations = context.appLocalizations;
return switch (profile.type) {
ProfileType.url => appLocalizations.remote,
ProfileType.file => appLocalizations.local,
ProfileType.custom => appLocalizations.custom,
};
}
Widget _buildTrailing(BuildContext context, {required bool isUpdating}) {
@@ -366,15 +342,11 @@ class ProfileItem extends ConsumerWidget {
children: [...previousChildren, ?currentChild],
),
child: isUpdating
? const SizedBox.square(
key: ValueKey('updating'),
dimension: 24,
child: CommonCircleLoading(),
)
? const CommonCircleLoading(key: ValueKey('updating'), size: 24)
: LastUpdateTimeText(
key: const ValueKey('lastUpdate'),
lastUpdateDate: profile.lastUpdateDate,
style: context.textTheme.bodySmall?.toLighter,
style: AppTextStyle.footnote.of(context).toLighter,
compact: true,
),
);
@@ -395,7 +367,7 @@ class ProfileItem extends ConsumerWidget {
final res = await globalState.safeRun<bool>(() async {
final mFile = await profile.file;
final value = await picker.saveFile(
profile.realLabel,
profile.exportFileName,
mFile.readAsBytesSync(),
);
if (value == null) return false;
@@ -445,7 +417,7 @@ class ProfileItem extends ConsumerWidget {
),
CommonPopupMenuItem(
glyph: AppGlyphs.eye,
label: appLocalizations.finalConfig,
label: appLocalizations.runtimeConfig,
onPressed: () {
_handlePreview(context);
},
@@ -473,7 +445,7 @@ class ProfileItem extends ConsumerWidget {
subItems: [
CommonPopupMenuItem(
glyph: AppGlyphs.eye,
label: appLocalizations.finalConfig,
label: appLocalizations.runtimeConfig,
onPressed: () {
_handlePreview(context);
},
@@ -510,39 +482,35 @@ class ProfileItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isUpdating = ref.watch(isUpdatingProvider(profile.updatingKey));
final subscriptionInfo = _subscriptionInfo;
final usage = subscriptionInfo?.usage;
final isSelected = profile.id == groupValue;
return ContextMenuRegion(
menuItems: isUpdating ? null : _menuItems(context, ref),
child: CommonCard(
enterActionsOnRight: true,
radius: AppCorner.xl,
isSelected: profile.id == groupValue,
isSelected: isSelected,
onPressed: () {
onChanged(profile.id);
},
child: ListItem(
key: Key(profile.id.toString()),
horizontalTitleGap: 12,
minTileHeight: _profileItemHeight,
minVerticalPadding: listRowVerticalPadding,
padding: const EdgeInsets.only(left: SourceIcon.inset, right: 16),
leading: SourceIcon(
kind: switch (profile.type) {
ProfileType.url => SourceKind.remote,
ProfileType.file => SourceKind.file,
ProfileType.custom => SourceKind.custom,
},
usage: usage,
tooltip: context.appLocalizations.subscriptionInfo,
onPressed: usage == null
? null
: () => _handleShowSubscriptionInfo(context),
trailing: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (profile.type == ProfileType.custom)
_GroupsCountChip(profileId: profile.id)
else
_buildTrailing(context, isUpdating: isUpdating),
if (isSelected) const SelectionBadge(size: 24),
],
),
trailing: _buildTrailing(context, isUpdating: isUpdating),
title: _ProfileCardTitle(
profile: profile,
expire: subscriptionInfo?.expireDate,
info: _buildTraffic(context),
subtitle: _subtitle(context),
),
),
),
@@ -554,40 +522,60 @@ class _ProfileCardTitle extends StatelessWidget {
const _ProfileCardTitle({
required this.profile,
required this.expire,
required this.info,
required this.subtitle,
});
final Profile profile;
final DateTime? expire;
final Widget? info;
final String subtitle;
@override
Widget build(BuildContext context) {
final title = ChipTitle(
title: profile.realLabel,
expire: expire,
style: context.textTheme.titleMedium,
);
final info = this.info;
if (info == null) {
return title;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
ChipTitle(
title: profile.realLabel,
expire: expire,
style: AppTextStyle.headline.of(context),
),
const SizedBox(height: _infoGap),
DefaultTextStyle.merge(
Text(
subtitle,
style: AppTextStyle.footnote.of(context).toLighter,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: info,
),
],
);
}
}
/// Square-cornered so a lone digit never reads as a second [SelectionBadge].
class _GroupsCountChip extends ConsumerWidget {
const _GroupsCountChip({required this.profileId});
final int profileId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(profileGroupsCountProvider(profileId)).value;
if (count == null) {
return const SizedBox.shrink();
}
final colorScheme = context.colorScheme;
return Tooltip(
message: context.appLocalizations.proxyGroupsCount(count, count.compact),
child: TonalChip(
label: count.compact,
color: colorScheme.primary.opacity15,
foregroundColor: colorScheme.primary,
),
);
}
}
class ReorderableProfilesSheet extends ConsumerStatefulWidget {
final List<Profile> profiles;
@@ -624,22 +612,24 @@ class _ReorderableProfilesSheetState
void _handleSave() {
Navigator.of(context).pop();
ref.read(profilesProvider.notifier).reorder(profiles);
final latest = ref.read(profilesProvider);
final ids = {for (final profile in profiles) profile.id};
ref.read(profilesProvider.notifier).reorder([
for (final profile in profiles) ?latest.getProfile(profile.id),
for (final profile in latest)
if (!ids.contains(profile.id)) profile,
]);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonScaffold(
actions: [
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
),
),
],
confirmationAction: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
),
body: Padding(
padding: const EdgeInsets.only(bottom: 32),
child: ReorderableListView.builder(
+10 -9
View File
@@ -35,8 +35,7 @@ class ResourcesView extends ConsumerStatefulWidget {
ConsumerState<ResourcesView> createState() => _ResourcesViewState();
}
class _ResourcesViewState extends ConsumerState<ResourcesView>
with RouteMotionHoldMixin<ResourcesView>, RouteSettledMixin<ResourcesView> {
class _ResourcesViewState extends ConsumerState<ResourcesView> {
_RemoteResources? _resources;
var _fileInfos = const <String, FileInfo?>{};
@@ -46,11 +45,9 @@ class _ResourcesViewState extends ConsumerState<ResourcesView>
ref.listenManual(iconSetsProvider, (_, _) => _refreshResources());
ref.listenManual(clashProvidersProvider, (_, _) => _refreshResources());
ref.listenManual(scriptsProvider, (_, _) => _refreshResources());
unawaited(_load());
}
@override
void didSettleRoute() => unawaited(_load());
Future<void> _load() async {
await Future.wait<Object>([
ref.read(iconSetsProvider.future),
@@ -101,7 +98,7 @@ class _ResourcesViewState extends ConsumerState<ResourcesView>
if (_resources == null) {
return;
}
updateWhenRouteSettled(() => setState(() => _resources = _readResources()));
setState(() => _resources = _readResources());
}
Future<void> _updateAll() async {
@@ -120,6 +117,9 @@ class _ResourcesViewState extends ConsumerState<ResourcesView>
value: updateInterval.toString(),
suffixText: appLocalizations.hours,
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(
TextInputLimits.geoUpdateInterval,
),
validator: (value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(
@@ -206,9 +206,10 @@ class _ResourcesViewState extends ConsumerState<ResourcesView>
),
],
body: NullStatusSwitcher(
isEmpty: resources == null,
holdsArrival: true,
nullStatus: const NullStatus(illustration: NullStatusIllustration.data),
isLoading: resources == null,
loadingStatus: const NullStatus(
illustration: NullStatusIllustration.data,
),
child: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
-320
View File
@@ -1,320 +0,0 @@
import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:material_ui/material_ui.dart';
enum SourceKind { remote, file, custom }
extension on SourceKind {
Color tintOf(ColorScheme colorScheme) => switch (this) {
SourceKind.remote => colorScheme.primary,
SourceKind.file => colorScheme.tertiary,
SourceKind.custom => colorScheme.secondary,
};
}
/// The tile sits [inset] from the edge of an [AppCorner.xl] surface, so its
/// corner is concentric with the surface's.
const _sourceIconInset = 12.0;
const _sourceIconSize = 40.0;
final _sourceIconShape = AppShape.all(AppCorner.xl - _sourceIconInset);
class SourceIcon extends StatelessWidget {
const SourceIcon({
super.key,
required this.kind,
this.usage,
this.tooltip,
this.onPressed,
});
static const size = _sourceIconSize;
static const inset = _sourceIconInset;
final SourceKind kind;
final double? usage;
final String? tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final usage = this.usage;
final icon = Container(
width: size,
height: size,
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
color: kind.tintOf(colorScheme).opacity15,
shape: _sourceIconShape,
),
child: TweenAnimationBuilder<double>(
tween: Tween(end: usage ?? 0),
duration: commonDuration,
curve: Curves.easeOutCubic,
builder: (_, level, _) => CustomPaint(
painter: _SourceArtPainter(
kind: kind,
colorScheme: colorScheme,
level: usage == null ? null : level,
),
),
),
);
if (onPressed == null) {
return icon;
}
return IconButton(
tooltip: tooltip,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(size),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: _sourceIconShape,
),
onPressed: onPressed,
icon: icon,
);
}
}
class _SourceArtPainter extends CustomPainter {
const _SourceArtPainter({
required this.kind,
required this.colorScheme,
required this.level,
});
static const _waveAmplitude = 1.2;
static const _cloudBounds = Rect.fromLTRB(5.5, 9.5, 34.5, 30);
static final _cloud = _cloudPath(_cloudBounds);
static const _badgeCenter = Offset(30.5, 27);
static final _badgedCloud = Path.combine(
PathOperation.difference,
_cloud,
Path()..addOval(Rect.fromCircle(center: _badgeCenter, radius: 7.4)),
);
final SourceKind kind;
final ColorScheme colorScheme;
final double? level;
@override
void paint(Canvas canvas, Size size) {
canvas.scale(size.width / _sourceIconSize);
switch (kind) {
case SourceKind.remote:
_paintRemote(canvas);
case SourceKind.file:
_paintFile(canvas);
case SourceKind.custom:
_paintCustom(canvas);
}
}
static Path _cloudPath(Rect bounds) {
final width = bounds.width;
final left = bounds.left;
final bottom = bounds.bottom;
final leftLobe = width * 0.25;
final rightLobe = width * 0.21;
final top = bounds.height - width * 0.33;
return Path.combine(
PathOperation.union,
Path.combine(
PathOperation.union,
Path()
..addOval(
Rect.fromCircle(
center: Offset(left + leftLobe, bottom - leftLobe),
radius: leftLobe,
),
)
..addOval(
Rect.fromCircle(
center: Offset(left + width - rightLobe, bottom - rightLobe),
radius: rightLobe,
),
),
Path()..addOval(
Rect.fromCircle(
center: Offset(left + width * 0.53, bounds.top + width * 0.33),
radius: width * 0.33,
),
),
),
Path()..addRect(
Rect.fromLTRB(
left + leftLobe,
bottom - top,
left + width - rightLobe,
bottom,
),
),
);
}
void _paintRemote(Canvas canvas) {
final primary = colorScheme.primary;
final tertiary = colorScheme.tertiary;
final level = this.level;
// A solid cloud is what a gauge with nothing used looks like.
if (level == null) {
canvas.drawPath(_badgedCloud, Paint()..color = primary);
_paintGlyph(canvas, AppGlyphs.removeCircle, _badgeCenter, 14, tertiary);
return;
}
canvas
..drawPath(_cloud, Paint()..color = primary.withValues(alpha: 0.32))
..save()
..clipPath(_cloud);
_paintWater(canvas, _cloudBounds, 1 - level, primary);
canvas.restore();
}
void _paintFile(Canvas canvas) {
final tertiary = colorScheme.tertiary;
final ink = Color.lerp(tertiary, colorScheme.onTertiary, 0.5)!;
canvas
..save()
..translate(16.5, 20.5)
..rotate(-pi / 24)
..drawRSuperellipse(
RSuperellipse.fromRectAndRadius(
Rect.fromCenter(center: Offset.zero, width: 14, height: 20.5),
const Radius.circular(3.5),
),
Paint()..color = colorScheme.secondary.withValues(alpha: 0.38),
)
..restore();
const page = Rect.fromLTRB(15, 8.5, 30, 31);
const fold = 6.0;
final corner = Path()
..moveTo(page.right - fold, page.top)
..lineTo(page.right, page.top)
..lineTo(page.right, page.top + fold)
..close();
canvas
..save()
..clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(page)
..addPath(corner, Offset.zero),
)
..drawRSuperellipse(
RSuperellipse.fromRectAndRadius(page, const Radius.circular(3.5)),
Paint()..color = tertiary,
)
..restore()
..drawPath(
Path()
..moveTo(page.right - fold, page.top)
..lineTo(page.right - fold, page.top + fold - 2)
..quadraticBezierTo(
page.right - fold,
page.top + fold,
page.right - fold + 2,
page.top + fold,
)
..lineTo(page.right, page.top + fold)
..close(),
Paint()..color = ink,
);
for (final (top, right) in const [(19.5, 26.5), (24.0, 23.5)]) {
canvas.drawRSuperellipse(
RSuperellipse.fromLTRBR(
18.5,
top,
right,
top + 2.2,
const Radius.circular(1.1),
),
Paint()..color = ink,
);
}
}
void _paintCustom(Canvas canvas) {
const block = 11.5;
const gap = 3.0;
const start = (_sourceIconSize - block * 2 - gap) / 2;
const end = start + block + gap;
for (final (left, top, color) in [
(start, start, colorScheme.primary),
(start, end, colorScheme.tertiary),
(end, end, colorScheme.secondary),
]) {
canvas.drawRSuperellipse(
RSuperellipse.fromLTRBR(
left,
top,
left + block,
top + block,
const Radius.circular(3.5),
),
Paint()..color = color,
);
}
final plus = Paint()
..color = colorScheme.primary
..strokeWidth = 3
..strokeCap = StrokeCap.round;
const center = Offset(end + block / 2, start + block / 2);
const arm = block / 2 - 1.5;
canvas
..drawLine(center.translate(-arm, 0), center.translate(arm, 0), plus)
..drawLine(center.translate(0, -arm), center.translate(0, arm), plus);
}
void _paintGlyph(
Canvas canvas,
Glyph glyph,
Offset center,
double extent,
Color color,
) {
canvas
..save()
..translate(center.dx - extent / 2, center.dy - extent / 2);
GlyphPainter(
glyph: glyph,
fill: 1,
color: color,
).paint(canvas, Size.square(extent));
canvas.restore();
}
void _paintWater(Canvas canvas, Rect bounds, double level, Color color) {
final surface = bounds.bottom - bounds.height * level;
final amplitude = _waveAmplitude * sin(pi * level);
final quarter = bounds.width / 4;
canvas.drawPath(
Path()
..moveTo(bounds.left, surface)
..quadraticBezierTo(
bounds.left + quarter,
surface - amplitude * 2,
bounds.left + quarter * 2,
surface,
)
..quadraticBezierTo(
bounds.left + quarter * 3,
surface + amplitude * 2,
bounds.right,
surface,
)
..lineTo(bounds.right, bounds.bottom)
..lineTo(bounds.left, bounds.bottom)
..close(),
Paint()..color = color,
);
}
@override
bool shouldRepaint(_SourceArtPainter oldDelegate) =>
oldDelegate.kind != kind ||
oldDelegate.colorScheme != colorScheme ||
oldDelegate.level != level;
}
+6 -2
View File
@@ -337,7 +337,6 @@ void main() {
hashKey: 'in-user',
defaultSelected: 'Old',
emptyFallback: 'Old',
includeAll: true,
includeAllProxies: false,
includeAllProviders: true,
hidden: false,
@@ -359,13 +358,18 @@ void main() {
final groups = await database.proxyGroupsDao.query(profile.id).get();
expect(groups.first, first.copyWith(profileId: profile.id));
expect(
await database.proxyGroupsDao
.profileGroupsCount(profile.id)
.getSingle(),
2,
);
expect(
(await (database.select(
database.proxyGroups,
)..where((t) => t.id.equals(second.id))).getSingle()).definition,
'{"default-selected":"Primary","empty-fallback":"Primary"}',
);
expect(await database.proxyGroupsDao.count(profile.id).getSingle(), 2);
await database.proxyGroupsDao.rewrite(
groups,
+57 -11
View File
@@ -6,7 +6,6 @@ import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
@@ -17,6 +16,7 @@ import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/profiles/custom/custom_proxies.dart';
import 'package:fl_clash/views/profiles/custom/dialers.dart';
import 'package:fl_clash/views/profiles/custom/groups.dart';
@@ -328,7 +328,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(menuItem(AppLocalizations.current.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.value.map((rule) => rule.id), [1, 3]);
@@ -412,7 +412,7 @@ void main() {
await tester.pump();
await tester.tap(find.byTooltip(AppLocalizations.current.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {100, 102});
@@ -497,7 +497,7 @@ void main() {
find.text(AppLocalizations.current.confirmDeleteProxyGroup),
findsOneWidget,
);
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
@@ -522,7 +522,39 @@ void main() {
find.text(AppLocalizations.current.proxyGroupInUse('Group 1')),
findsOneWidget,
);
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.tap(find.text(AppLocalizations.current.delete));
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('the edit page warns when the profile still names the group', (
tester,
) async {
final notifier = await _pumpProxyGroups(tester, inUse: {'Group 1'});
await tester.tap(find.text('Group 1'));
await tester.pumpAndSettle();
final delete = find.descendant(
of: find.byType(EditProxyGroupView),
matching: find.text(AppLocalizations.current.delete),
);
await tester.ensureVisible(delete);
await tester.pumpAndSettle();
await tester.tap(delete);
await tester.pumpAndSettle();
expect(
find.text(AppLocalizations.current.proxyGroupInUse('Group 1')),
findsOneWidget,
);
await tester.tap(
find.descendant(
of: find.byType(CommonDialog),
matching: find.text(AppLocalizations.current.delete),
),
);
await tester.pumpAndSettle();
expect(notifier.deleted.single, {101});
@@ -614,7 +646,11 @@ void main() {
Finder valueIn(String title, String value) =>
find.descendant(of: row(title), matching: find.text(value));
Future<void> tapRow(String title) async {
await tester.ensureVisible(row(title));
await Scrollable.ensureVisible(
tester.element(row(title)),
alignment: 0.5,
);
await tester.pumpAndSettle();
await tester.tap(row(title));
await tester.pumpAndSettle();
}
@@ -725,7 +761,9 @@ void main() {
for (final title in loadBalanceOptions) {
await add(title);
}
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
await tester.pumpAndSettle();
@@ -784,7 +822,9 @@ void main() {
await tester.enterText(fieldIn(title), '');
}
await tester.pump();
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
await tester.pumpAndSettle();
@@ -843,7 +883,9 @@ void main() {
(state) => state.copyWith(name: 'Mine', proxies: const ['DIRECT']),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await _expectClosesWithoutIssues(tester);
expect(proxyGroups.value.map((group) => group.name), ['Mine']);
@@ -913,7 +955,9 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pumpAndSettle();
expect(proxyGroups.value, isEmpty);
@@ -936,7 +980,9 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pump();
form.update((state) => state.copyWith(name: 'Later'));
when(() => core.validateFilters(any())).thenAnswer((_) async => ['', '']);
+21 -1
View File
@@ -166,6 +166,23 @@ void main() {
expect(rules.skip(1).map((rule) => rule.id), [1, 2]);
});
testWidgets('drops the SUB-RULE lines', (tester) async {
final notifier = await pumpRules(tester);
final (:editor, :context) = await _openListEditor(tester);
final popped = await editor.onPop!(
context,
editor.title,
'- SUB-RULE,(NETWORK,tcp),streaming\n${editor.content}',
);
expect(popped, isTrue);
expect(notifier.replaced.single.map((rule) => rule.rawValue), [
'DOMAIN,a.com,DIRECT',
'MATCH,DIRECT',
]);
});
testWidgets('names the line of a rule the form would refuse', (
tester,
) async {
@@ -203,7 +220,10 @@ void main() {
expect(
find.descendant(
of: form,
matching: find.byTooltip(currentAppLocalizations.save),
matching: find.widgetWithText(
TextButton,
currentAppLocalizations.save,
),
),
findsOneWidget,
);
+40 -8
View File
@@ -1,7 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
@@ -14,7 +13,6 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -67,6 +65,14 @@ class _TestClashProviders extends ClashProviders {
]);
}
/// The list of the open selection sheet, not the search field docked below it.
final _selectionList = find
.descendant(
of: find.byWidgetPredicate((widget) => widget is SelectionSheet),
matching: find.byType(Scrollable),
)
.first;
class _Harness {
late final ProviderContainer container;
late final _RecordingProfileRules rules;
@@ -127,7 +133,9 @@ class _Harness {
}
Future<void> save(WidgetTester tester) async {
await tester.tap(find.byGlyph(AppGlyphs.check));
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pumpAndSettle();
}
@@ -137,7 +145,7 @@ class _Harness {
await tester.scrollUntilVisible(
find.text(action.name),
300,
scrollable: find.byType(Scrollable).last,
scrollable: _selectionList,
);
// That leaves the row at the viewport's top, under the floating bar.
await Scrollable.ensureVisible(
@@ -185,7 +193,7 @@ void main() {
await tester.scrollUntilVisible(
find.text(RuleAction.MATCH.name),
300,
scrollable: find.byType(Scrollable).last,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
@@ -193,6 +201,25 @@ void main() {
expect(find.text(RuleAction.SUB_RULE.name), findsNothing);
});
testWidgets('a SUB-RULE kept from before cannot be saved as one', (
tester,
) async {
final harness = _Harness();
await harness.pump(
tester,
initialRules: [Rule.parse('SUB-RULE,(NETWORK,tcp),streaming')],
);
await tester.tap(find.text(RuleAction.SUB_RULE.name));
await tester.pumpAndSettle();
await harness.save(tester);
expect(
find.text(currentAppLocalizations.invalidSubRule('streaming')),
findsWidgets,
);
expect(harness.rules.puts, isEmpty);
});
testWidgets('the rule set sheet lists the app rule sets in one list', (
tester,
) async {
@@ -225,7 +252,7 @@ void main() {
await tester.scrollUntilVisible(
find.text(RuleAction.MATCH.name),
300,
scrollable: find.byType(Scrollable).last,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.MATCH.name).last);
@@ -255,7 +282,7 @@ void main() {
await tester.scrollUntilVisible(
find.text(RuleAction.NOT.name),
300,
scrollable: find.byType(Scrollable).last,
scrollable: _selectionList,
);
await tester.pumpAndSettle();
await tester.tap(find.text(RuleAction.NOT.name).last);
@@ -423,7 +450,12 @@ void main() {
await tester.tap(find.text(l10n.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.confirm));
await tester.tap(
find.descendant(
of: find.byType(CommonDialog),
matching: find.text(l10n.delete),
),
);
await tester.pumpAndSettle();
expect(harness.rules.deletes, [rule.id]);
+31 -3
View File
@@ -233,7 +233,7 @@ void main() {
}
Finder tile(String label) =>
find.ancestor(of: find.text(label), matching: find.byType(CommonCard));
find.ancestor(of: find.text(label), matching: find.byType(ListItem));
String? statusOf(WidgetTester tester, String label) {
final l = currentAppLocalizations;
@@ -251,7 +251,7 @@ void main() {
.data;
}
testWidgets('leads its proxy groups with a tile per section', (
testWidgets('leads its proxy groups with a row per section', (
tester,
) async {
await pumpCustom(
@@ -319,7 +319,7 @@ void main() {
expect(find.text(l.importFile), findsOneWidget);
});
testWidgets('keeps its tiles above an empty group list', (tester) async {
testWidgets('keeps its rows above an empty group list', (tester) async {
await pumpCustom(tester);
final l = currentAppLocalizations;
@@ -365,6 +365,34 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(TunView), findsOneWidget);
});
testWidgets('renames the profile from its title menu', (tester) async {
await pumpCustom(tester);
final l = currentAppLocalizations;
Finder title(String label) =>
find.descendant(of: find.byType(AppBar), matching: find.text(label));
await tester.tap(title('mine'));
await tester.pumpAndSettle();
expect(find.text(l.runtimeConfig), findsOneWidget);
await tester.tap(find.text(l.rename));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(InputDialog),
matching: find.byType(EditableText),
),
'theirs',
);
await tester.tap(find.text(l.submit));
await tester.pumpAndSettle();
expect(
globalState.container.read(profilesProvider).single.label,
'theirs',
);
expect(title('theirs'), findsOneWidget);
});
});
group('a new custom profile', () {
+78 -22
View File
@@ -1,14 +1,18 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/rule.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
Finder _banner(String text) =>
find.descendant(of: find.byType(ErrorBanner), matching: find.text(text));
void main() {
testWidgets('add rule validates content and returns the entered rule', (
tester,
@@ -24,9 +28,9 @@ void main() {
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<Rule>(
result = await showFormSheet<Rule>(
context: context,
builder: (_) => const AddOrEditRuleDialog(),
builder: (_) => const RuleForm(),
);
},
child: const Text('open'),
@@ -39,15 +43,18 @@ void main() {
await tester.pumpAndSettle();
final l10n = AppLocalizations.current;
await tester.tap(find.text(l10n.confirm));
await tester.tap(find.widgetWithText(TextButton, l10n.save));
await tester.pumpAndSettle();
expect(find.text(l10n.emptyTip(l10n.content)), findsOneWidget);
expect(_banner(l10n.emptyTip(l10n.content)), findsOneWidget);
expect(result, isNull);
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.tap(find.text(l10n.confirm));
await tester.pumpAndSettle();
expect(find.byType(ErrorBanner), findsNothing);
await tester.tap(find.widgetWithText(TextButton, l10n.save));
await tester.pumpAndSettle();
expect(find.byType(AddOrEditRuleDialog), findsNothing);
expect(find.byType(RuleForm), findsNothing);
expect(result, isNotNull);
expect(result!.ruleAction, RuleAction.DOMAIN);
expect(result!.content, 'example.com');
@@ -159,9 +166,9 @@ void main() {
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<Rule>(
result = await showFormSheet<Rule>(
context: context,
builder: (_) => const AddOrEditRuleDialog(),
builder: (_) => const RuleForm(),
);
},
child: const Text('open'),
@@ -175,21 +182,15 @@ void main() {
final l10n = AppLocalizations.current;
await tester.enterText(find.byType(TextFormField), 'example.com');
await tester.tap(find.byType(DropdownMenu));
await tester.tap(find.text(l10n.splitStrategy));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.matchTarget).last);
await tester.pumpAndSettle();
expect(
tester
.widget<EditableText>(find.byType(EditableText).last)
.controller
.text,
l10n.matchTarget,
);
expect(find.text(l10n.matchTarget), findsOneWidget);
await tester.tap(find.text(l10n.confirm));
await tester.tap(find.widgetWithText(TextButton, l10n.save));
await tester.pumpAndSettle();
expect(result, isNotNull);
@@ -198,7 +199,9 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('edit rule keeps the id and prefills the fields', (tester) async {
testWidgets('edit rule keeps the id and order and prefills the fields', (
tester,
) async {
Rule? result;
const rule = Rule(
id: 42,
@@ -206,6 +209,7 @@ void main() {
content: 'CN',
ruleTarget: 'GEOIP',
noResolve: true,
order: 'a5',
);
await tester.pumpWidget(
TestApp(
@@ -217,9 +221,9 @@ void main() {
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<Rule>(
result = await showFormSheet<Rule>(
context: context,
builder: (_) => const AddOrEditRuleDialog(rule: rule),
builder: (_) => const RuleForm(rule: rule),
);
},
child: const Text('open'),
@@ -234,15 +238,67 @@ void main() {
expect(find.text('CN'), findsOneWidget);
await tester.enterText(find.byType(TextFormField), 'US');
await tester.tap(find.text(AppLocalizations.current.confirm));
await tester.tap(
find.widgetWithText(TextButton, AppLocalizations.current.save),
);
await tester.pumpAndSettle();
expect(result, isNotNull);
expect(result!.id, 42);
expect(result!.order, 'a5');
expect(result!.content, 'US');
expect(result!.ruleAction, RuleAction.GEOIP);
expect(result!.noResolve, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a payload the core would refuse is held at the field', (
tester,
) async {
Rule? result;
const rule = Rule(
id: 7,
ruleAction: RuleAction.DST_PORT,
content: '443',
ruleTarget: 'DIRECT',
);
await tester.pumpWidget(
TestApp(
wrapInProviderScope: true,
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1200, 800)),
],
child: Builder(
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showFormSheet<Rule>(
context: context,
builder: (_) => const RuleForm(rule: rule),
);
},
child: const Text('open'),
);
},
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.current;
await tester.enterText(find.byType(TextFormField), 'https');
await tester.tap(find.widgetWithText(TextButton, l10n.save));
await tester.pumpAndSettle();
expect(_banner(l10n.invalidRangeContent), findsOneWidget);
expect(result, isNull);
await tester.enterText(find.byType(TextFormField), '80/443');
await tester.tap(find.widgetWithText(TextButton, l10n.save));
await tester.pumpAndSettle();
expect(result?.content, '80/443');
await tester.pumpWidget(const SizedBox.shrink());
});
}
+4
View File
@@ -94,6 +94,10 @@ void main() {
expect(urlProfile.realAutoUpdate, true);
expect(urlProfile.realLabel, 'Remote');
expect(urlProfile.copyWith(label: '').realLabel, 'example.com');
expect(fileProfile.exportFileName, '7.yaml');
expect(urlProfile.exportFileName, 'Remote.yaml');
expect(urlProfile.copyWith(label: 'a.YML').exportFileName, 'a.YML');
});
test('an unnamed profile falls back to its url host, then its id', () {
@@ -690,5 +690,31 @@ void main() {
expect(setup.forced, [true]);
});
test(
'a group taking in every provider or proxy reapplies only on the named',
() async {
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 23,
name: 'All',
type: GroupType.Selector,
includeAllProxies: true,
includeAllProviders: true,
order: 'b',
).toCompanion(profileId),
);
await action().applyIfReferenced(ProviderKind.proxy, {
'Elsewhere',
}, force: true);
await action().applyIfProxiesNamed({'Unnamed'});
await action().applyIfReferenced(ProviderKind.proxy, {
'Home',
}, force: true);
expect(setup.forced, [true]);
},
);
});
}
+42 -17
View File
@@ -20,6 +20,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
import 'package:yaml/yaml.dart';
import '../helpers/app_paths.dart';
import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
@@ -513,23 +514,50 @@ proxy-groups:
expect(config.proxyGroups.single.name, 'GLOBAL');
});
test(
'drops include-all-providers and keeps the proxies of include-all',
() {
final config = _read('''
test('keeps the keys that take in every proxy or provider', () {
final config = _read('''
proxy-groups:
- {name: All, type: select, include-all: true}
- {name: Plain, type: select, proxies: [DIRECT], include-all: false}
- {name: All, type: select, proxies: [DIRECT], include-all: true}
- {name: Providers, type: select, include-all-providers: true}
''');
final [all, providers] = config.proxyGroups;
expect(all.includeAll, isNull);
expect(all.includeAllProxies, isTrue);
expect(all.includeAllProviders, isNull);
expect(providers.includeAllProviders, isNull);
expect(providers.includeAllProxies, isNull);
},
);
final [plain, all, providers] = config.proxyGroups;
expect(plain.definition, {
'name': 'Plain',
'type': 'select',
'proxies': ['DIRECT'],
});
expect(all.definition, {
'name': 'All',
'type': 'select',
'proxies': ['DIRECT'],
'include-all-proxies': true,
'include-all-providers': true,
});
expect(providers.definition, {
'name': 'Providers',
'type': 'select',
'include-all-providers': true,
});
});
test('drops the SUB-RULE lines, since there are no sub-rules', () {
final config = _read('''
rules:
- SUB-RULE,(NETWORK,tcp),streaming
- MATCH,DIRECT
sub-rules:
streaming:
- DOMAIN,video.example,DIRECT
''');
expect(
[for (final rule in config.rules) rule.rawValue],
['MATCH,DIRECT'],
);
expect(config.droppedSubRules, 1);
});
test('renames the proxies DNS servers dial through', () {
final config = _read(
@@ -657,10 +685,7 @@ tun:
setUp(() async {
await AppLocalizations.load(const Locale('en'));
home = Directory.systemTemp.createTempSync('flclash-custom-import-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
await useAppPaths(home);
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
await testDatabase.profiles.put(custom.toCompanion());
+87 -12
View File
@@ -484,15 +484,34 @@ void main() {
expect(loop('Work', const {'Work': 'Away', 'Away': 'Home'}), isEmpty);
});
test('include-all reaches a proxy only once a group declares it', () {
test('include-all-proxies takes in only the proxies a group declares', () {
const all = ProxyGroup(
id: 6,
name: 'All',
type: GroupType.Selector,
includeAll: true,
includeAllProxies: true,
);
const exit = ProxyGroup(
id: 7,
name: 'Exit',
type: GroupType.Selector,
proxies: ['Work'],
);
expect(dialerLoop('All', [all], proxy: 'Home'), isEmpty);
expect(dialerLoop('All', [all, landing], proxy: 'Home'), {'All'});
expect(
dialerLoop('Relay', [all, landing, relay], proxy: 'Home'),
isEmpty,
);
expect(
dialerLoop(
'Exit',
[exit, all, landing],
proxy: 'Home',
dialers: const {'Work': 'All'},
),
{'Exit', 'Work', 'All'},
);
});
test('what a filter picks is left to the core', () {
@@ -500,7 +519,7 @@ void main() {
id: 6,
name: 'All',
type: GroupType.Selector,
includeAll: true,
includeAllProxies: true,
);
for (final group in [
all.copyWith(filter: '(?i)hk'),
@@ -621,6 +640,35 @@ void main() {
],
);
});
test('include-all-proxies declares no app proxy of its own and drops a '
'dialer it loops back through', () {
const unnamed = CustomProxy(
id: 12,
definition: {'name': 'Unnamed', 'type': 'ss'},
);
const all = ProxyGroup(
id: 6,
name: 'All',
type: GroupType.Selector,
includeAllProxies: true,
);
expect(
appProxiesPayload(
[home, unnamed],
dialers: {9: 'All', 12: 'Relay'},
groups: const [all, landing],
),
[home.definition],
);
expect(
const CustomProfileData(
proxyGroups: [all, landing],
proxies: {'Home', 'Unnamed'},
).namedProxies,
{'Home'},
);
});
});
group('issues', () {
@@ -647,6 +695,28 @@ void main() {
proxyGroupIssues(group.copyWith(includeAllProxies: true), profileData),
isEmpty,
);
final providers = group.copyWith(includeAllProviders: true);
final subscribed = profileData.copyWith(proxyProviders: const {'Sub'});
expect(proxyGroupIssues(providers, subscribed), [
const CustomIssue.noProxySource(),
]);
expect(
proxyGroupIssues(
providers,
subscribed.copyWith(
proxyGroups: [
...subscribed.proxyGroups,
const ProxyGroup(
id: 4,
name: 'D',
type: GroupType.Selector,
use: ['Sub'],
),
],
),
),
isEmpty,
);
});
test('a group cannot take a local proxy\'s name, named here or not', () {
@@ -683,26 +753,31 @@ void main() {
);
});
test('empty-fallback takes only a built-in', () {
test('empty-fallback takes a built-in or a proxy a group names', () {
const group = ProxyGroup(
id: 3,
name: 'C',
type: GroupType.Selector,
proxies: ['Node'],
);
for (final name in ['COMPATIBLE', 'PASS']) {
final data = profileData.copyWith(
proxyGroups: [
...profileData.proxyGroups,
group.copyWith(id: 4, name: 'D', proxies: ['Home']),
],
proxies: const {'Home', 'Spare'},
);
for (final name in ['COMPATIBLE', 'PASS', 'Home']) {
expect(
proxyGroupIssues(group.copyWith(emptyFallback: name), profileData),
proxyGroupIssues(group.copyWith(emptyFallback: name), data),
isEmpty,
reason: name,
);
}
for (final name in ['A', 'Node', 'Gone']) {
expect(
proxyGroupIssues(group.copyWith(emptyFallback: name), profileData),
[CustomIssue.invalidEmptyFallback(name)],
reason: name,
);
for (final name in ['A', 'Node', 'Gone', 'Spare']) {
expect(proxyGroupIssues(group.copyWith(emptyFallback: name), data), [
CustomIssue.invalidEmptyFallback(name),
], reason: name);
}
});
+53
View File
@@ -1656,6 +1656,59 @@ void main() {
expect((providers['plain'] as YamlMap).keys, {'type', 'path'});
});
test('a custom profile group taking in every proxy or provider declares '
'none the profile does not name', () async {
final profile = Profile.custom(label: 'p');
final core = _MockCoreHandlerInterface();
final setupState = nullProfileSetupState.copyWith(
profileId: profile.id,
profileType: ProfileType.custom,
proxyGroups: [
const ProxyGroup(
id: 1,
name: 'g',
type: GroupType.Selector,
filter: 'HK',
includeAllProxies: true,
includeAllProviders: true,
),
const ProxyGroup(
id: 2,
name: 'h',
type: GroupType.Selector,
proxies: ['mine'],
use: ['sub'],
),
],
profileProviders: const {'sub': 42, 'plain': 43},
appProxies: const [
CustomProxy(id: 11, definition: {'name': 'mine', 'type': 'direct'}),
CustomProxy(id: 15, definition: {'name': 'spare', 'type': 'direct'}),
],
);
final scoped = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
],
);
addTearDown(scoped.dispose);
final res = await scoped
.read(setupActionProvider.notifier)
.getProfile(
setupState: setupState,
patchConfig: const PatchClashConfig(),
);
final config = loadYaml(res.yaml) as YamlMap;
expect((config['proxy-providers'] as YamlMap).keys, {'sub'});
expect([for (final proxy in config['proxies']) proxy['name']], ['mine']);
final group = (config['proxy-groups'] as YamlList).first as YamlMap;
expect(group['include-all-proxies'], isTrue);
expect(group['include-all-providers'], isTrue);
});
test('only a subscription profile runs its script extension', () async {
final engine = scriptEvaluator;
addTearDown(() => scriptEvaluator = engine);
+88 -7
View File
@@ -444,6 +444,13 @@ void main() {
await tester.pumpAndSettle();
expect(offered('REJECT'), findsNothing);
await tester.tap(
find.descendant(
of: picker,
matching: find.textContaining('${l.filters} ('),
),
);
await tester.pumpAndSettle();
for (final header in [
'${l.basicStrategy} (5)',
'${l.localProxies} (1)',
@@ -452,13 +459,6 @@ void main() {
await tester.tap(offered(header));
await tester.pumpAndSettle();
}
await tester.tap(
find.descendant(
of: picker,
matching: find.textContaining('${l.filters} ('),
),
);
await tester.pumpAndSettle();
expect(offered('DIRECT'), findsNothing);
expect(offered('mine'), findsNothing);
expect(offered(l.url), findsOneWidget);
@@ -472,6 +472,11 @@ void main() {
expect(container.read(proxyGroupProvider).proxies, ['DIRECT']);
await tester.tap(offered('REJECT'));
await tester.tap(offered('Home'));
await Scrollable.ensureVisible(
tester.element(offered('p')),
alignment: 0.5,
);
await tester.pumpAndSettle();
await tester.tap(offered('p'));
await tester.scrollUntilVisible(
offered(_hongKong.label),
@@ -499,6 +504,82 @@ void main() {
expect(group.filter, 'mine`${_hongKong.regex}');
});
testWidgets('the picker adds taking in every proxy or provider as a member '
'beside the named ones', (tester) async {
final l = currentAppLocalizations;
container = buildContainer(
_group(proxies: ['DIRECT']),
customProxies: const [_home],
);
final picker = find.byWidgetPredicate(
(widget) =>
widget.runtimeType.toString() == '_MemberPicker<ProxyGroup>',
);
Finder offered(String name) =>
find.descendant(of: picker, matching: find.text(name));
Finder listed(String name) => find.descendant(
of: find.byType(SuperSliverReorderableList),
matching: find.text(name),
);
await pumpGroupSheet(tester);
await tester.ensureVisible(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addNodes));
await tester.pumpAndSettle();
for (final header in [
'${l.autoInclude} (2)',
'${l.proxyProviders} (1)',
]) {
await tester.tap(offered(header));
await tester.pumpAndSettle();
}
await tester.tap(offered('p'));
await tester.tap(offered(l.allReferencedProxyProviders));
await tester.tap(offered(l.allReferencedProxies));
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
final group = container.read(proxyGroupProvider);
expect(group.proxies, ['DIRECT']);
expect(group.use, ['p']);
expect(group.includeAllProxies, isTrue);
expect(group.includeAllProviders, isTrue);
expect(listed(l.allReferencedProxies), findsOneWidget);
expect(listed(l.allReferencedProxyProviders), findsOneWidget);
expect(listed('p'), findsOneWidget);
await tester.ensureVisible(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(find.text(l.addNodes));
await tester.pumpAndSettle();
await tester.tap(offered('${l.localProxies} (1)'));
await tester.pumpAndSettle();
expect(offered('Home'), findsOneWidget);
for (final section in [l.autoInclude, l.proxyProviders]) {
expect(
find.descendant(
of: picker,
matching: find.textContaining('$section ('),
),
findsNothing,
);
}
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: rowOf(l.allReferencedProxyProviders),
matching: find.byTooltip(l.remove),
),
);
await tester.pump();
expect(container.read(proxyGroupProvider).includeAllProviders, isNull);
expect(container.read(proxyGroupProvider).includeAllProxies, isTrue);
});
testWidgets('the picker adds exclude filters as members of their own', (
tester,
) async {
+10 -25
View File
@@ -11,28 +11,13 @@ import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import '../helpers/app_paths.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
const _profileId = 1;
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
class _TestProxyGroups extends ProxyGroups {
_TestProxyGroups(this.groups);
@@ -73,10 +58,10 @@ void main() {
late ProviderContainer container;
setUpAll(() {
PathProviderPlatform.instance = _FakePathProvider(
Directory.systemTemp.createTempSync('custom_providers').path,
);
setUpAll(() async {
final home = Directory.systemTemp.createTempSync('custom_providers');
addTearDown(() => home.deleteSync(recursive: true));
await useAppPaths(home);
});
Future<void> pumpView(
@@ -209,7 +194,7 @@ void main() {
await tester.pageBack();
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField), '600');
await tester.tap(find.byTooltip(l.save));
await tester.tap(find.widgetWithText(TextButton, l.save));
await tester.pumpAndSettle();
expect(
@@ -241,7 +226,7 @@ void main() {
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.tap(find.widgetWithText(TextButton, l.save));
await tester.pumpAndSettle();
expect(
@@ -272,7 +257,7 @@ void main() {
}
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.tap(find.widgetWithText(TextButton, l.save));
await tester.pumpAndSettle();
expect(
@@ -327,7 +312,7 @@ void main() {
await tester.pumpAndSettle();
await tester.enterText(fieldIn(l.testUrl), '');
await tester.enterText(fieldIn(l.testInterval), '');
await tester.tap(find.byTooltip(l.save));
await tester.tap(find.widgetWithText(TextButton, l.save));
await tester.pumpAndSettle();
expect(
@@ -386,7 +371,7 @@ void main() {
find.descendant(of: excluded, matching: find.byTooltip(l.remove)),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(l.save));
await tester.tap(find.widgetWithText(TextButton, l.save));
await tester.pumpAndSettle();
expect(
+2 -12
View File
@@ -431,9 +431,7 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('shows the loading illustration until every row is ready', (
tester,
) async {
testWidgets('shows every row in one step once all are ready', (tester) async {
const size = Size(1000, 1400);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -469,21 +467,13 @@ void main() {
);
await tester.tap(find.text('open'));
await tester.pump();
await settle(tester);
final l10n = currentAppLocalizations;
final illustration = find.byKey(
const ValueKey(NullStatusIllustration.data),
);
expect(illustration, findsOneWidget);
expect(find.text(GeoResource.MMDB.name), findsNothing);
await tester.pumpAndSettle();
final l10n = currentAppLocalizations;
await pumpUntilFound(tester, find.text(GeoResource.MMDB.name));
expect(find.text('Remote script'), findsOneWidget);
expect(find.text(l10n.unknown), findsNWidgets(GeoResource.values.length));
await tester.pumpAndSettle();
expect(illustration, findsNothing);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
+35 -52
View File
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -12,23 +11,11 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
import '../helpers/app_paths.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
/// The edit page disposes the profile by scheduling an auto-apply; keep that a
/// no-op so the setup machinery never runs in a widget test.
@@ -37,13 +24,6 @@ class _NoopSetupAction extends SetupAction {
void autoApplyProfile() {}
}
class _RecordingProfilesAction extends ProfilesAction {
final put = <Profile>[];
@override
void putProfile(Profile profile) => put.add(profile);
}
Profile _urlProfile() =>
Profile.normal(label: 'test', url: 'https://example.com/sub');
@@ -85,13 +65,12 @@ Future<FocusNode> pumpEditProfile(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.measure = Measure.of(context, 1);
globalState.theme = CommonTheme.of(context, 1);
return child!;
return wrapAppScopes(context, child!);
},
home: Scaffold(
body: Column(
@@ -104,7 +83,6 @@ Future<FocusNode> pumpEditProfile(
child: Builder(
builder: (context) => Scaffold(
body: EditProfileView(
context: context,
profile: profile ?? _urlProfile(),
),
),
@@ -143,9 +121,7 @@ void main() {
setUpAll(() async {
tempDir = Directory.systemTemp.createTempSync('edit_profile_focus_test');
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
// A path resolved inside one test's fake zone never wakes a later test.
await appPath.dataDir.future;
await useAppPaths(tempDir);
});
tearDownAll(() {
@@ -172,28 +148,6 @@ void main() {
expect(find.byTooltip('Save'), findsOneWidget);
});
testWidgets('a custom profile edits only its name', (tester) async {
final action = _RecordingProfilesAction();
final profile = Profile.custom(label: 'Mine');
await pumpEditProfile(
tester,
profile: profile,
overrides: [profilesActionProvider.overrideWith(() => action)],
);
expect(find.byType(TextFormField), findsOneWidget);
expect(find.text(currentAppLocalizations.autoUpdate), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, currentAppLocalizations.name),
'Renamed',
);
await tester.tap(find.byTooltip(currentAppLocalizations.save));
await tester.pumpAndSettle();
expect(action.put.single, profile.copyWith(label: 'Renamed'));
});
testWidgets(
'the profile file row shows its size and update time without chips',
(tester) async {
@@ -220,4 +174,33 @@ void main() {
expect(find.byType(MetaChip), findsNothing);
},
);
testWidgets('saving with auto update off ignores an emptied interval', (
tester,
) async {
final profile = _urlProfile();
await pumpEditProfile(
tester,
profile: profile,
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
],
);
final interval = find.byWidgetPredicate(
(widget) =>
widget is TextField &&
widget.controller?.text == '${profile.autoUpdateDuration.inMinutes}',
);
await tester.enterText(interval, '');
await tester.tap(find.byType(Switch));
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pump();
expect(tester.takeException(), isNull);
final saved = globalState.container.read(profilesProvider).single;
expect(saved.autoUpdate, isFalse);
expect(saved.autoUpdateDuration, profile.autoUpdateDuration);
});
}
+80 -41
View File
@@ -1,5 +1,4 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -14,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/context_menu.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
Profile urlProfile(String label) =>
@@ -44,6 +44,7 @@ Future<ProviderContainer> pumpProfiles(
required List<Profile> profiles,
int? currentProfileId,
ValueListenable<bool>? isActive,
bool picker = false,
List<Override> overrides = const [],
}) async {
tester.view.physicalSize = const Size(900, 800);
@@ -71,21 +72,20 @@ Future<ProviderContainer> pumpProfiles(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.measure = Measure.of(context, 1);
globalState.theme = CommonTheme.of(context, 1);
return child!;
return wrapAppScopes(context, child!);
},
home: isActive == null
? const ProfilesView()
? ProfilesView(picker: picker)
: ValueListenableBuilder(
valueListenable: isActive,
builder: (_, isActive, child) =>
PageActivityScope(isActive: isActive, child: child!),
child: const ProfilesView(),
child: ProfilesView(picker: picker),
),
),
),
@@ -95,8 +95,7 @@ Future<ProviderContainer> pumpProfiles(
}
void main() {
testWidgets('a focused profile card opens its menu and reaches its '
'subscription info', (tester) async {
testWidgets('a focused profile card opens its menu', (tester) async {
final profiles = [
urlProfile('url 1'),
Profile.normal(label: 'file 1'),
@@ -105,7 +104,7 @@ void main() {
await pumpProfiles(tester, profiles: profiles);
final cards = find
.byType(OutlinedButton)
.byType(FilledButton)
.evaluate()
.map((element) => element.widget)
.toList();
@@ -114,10 +113,8 @@ void main() {
for (var tab = 0; tab < 50; tab++) {
final context = FocusManager.instance.primaryFocus?.context;
final focusedCard = context
?.findAncestorWidgetOfExactType<OutlinedButton>();
final focusedAction = context
?.findAncestorWidgetOfExactType<IconButton>();
if (focusedAction == null && identical(focusedCard, cards[index])) {
?.findAncestorWidgetOfExactType<FilledButton>();
if (identical(focusedCard, cards[index])) {
return;
}
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
@@ -142,23 +139,6 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(CommonPopupMenu), findsNothing);
if (profiles[profileIndex].subscriptionInfo == null) {
continue;
}
await focusCard(profileIndex);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final context = FocusManager.instance.primaryFocus?.context;
expect(
context?.findAncestorWidgetOfExactType<IconButton>()?.tooltip,
currentAppLocalizations.subscriptionInfo,
);
expect(
context?.findAncestorWidgetOfExactType<ListItem>()?.key,
Key(profiles[profileIndex].id.toString()),
);
}
});
@@ -191,15 +171,60 @@ void main() {
);
});
testWidgets('the usage icon opens the subscription dialog', (tester) async {
await pumpProfiles(tester, profiles: [urlProfile('url')]);
testWidgets('a card without traffic names its source under the title and '
'has no subscription info button', (tester) async {
final l = currentAppLocalizations;
await pumpProfiles(
tester,
profiles: [
Profile.normal(label: 'url', url: 'https://example.com/sub'),
Profile.normal(label: 'file'),
Profile.custom(label: 'mine'),
],
);
await tester.tap(find.byTooltip(currentAppLocalizations.subscriptionInfo));
await tester.pumpAndSettle();
for (final (label, source) in [
('url', l.remote),
('file', l.local),
('mine', l.custom),
]) {
expect(
find.descendant(
of: find.ancestor(
of: find.text(label),
matching: find.byType(ProfileItem),
),
matching: find.text(source),
),
findsOneWidget,
);
}
expect(
find.byTooltip(currentAppLocalizations.subscriptionInfo),
findsNothing,
);
});
expect(find.byType(CommonDialog), findsOneWidget);
expect(find.byType(SubscriptionInfoDetailView), findsOneWidget);
expect(find.text(currentAppLocalizations.subscriptionInfo), findsOneWidget);
testWidgets('a custom card counts its groups in place of an update time', (
tester,
) async {
await pumpProfiles(
tester,
profiles: [Profile.custom(label: 'mine')],
overrides: [
profileGroupsCountProvider.overrideWith((_, _) => Stream.value(6)),
],
);
await tester.pump();
Finder inCard(Finder matching) =>
find.descendant(of: find.byType(ProfileItem), matching: matching);
expect(inCard(find.widgetWithText(TonalChip, '6')), findsOneWidget);
expect(
inCard(find.byTooltip(currentAppLocalizations.proxyGroupsCount(6, '6'))),
findsOneWidget,
);
expect(inCard(find.byType(LastUpdateTimeText)), findsNothing);
});
Future<void> openProfileMenu(WidgetTester tester, String label) async {
@@ -214,12 +239,12 @@ void main() {
await openProfileMenu(tester, 'url');
expect(find.text(l.extend), findsOneWidget);
expect(find.text(l.finalConfig), findsNothing);
expect(find.text(l.runtimeConfig), findsNothing);
expect(find.text(l.rename), findsNothing);
await tester.tap(find.text(l.more).last);
await tester.pumpAndSettle();
expect(find.text(l.finalConfig), findsOneWidget);
expect(find.text(l.runtimeConfig), findsOneWidget);
expect(find.text(l.subscriptionInfo), findsNothing);
});
@@ -230,7 +255,7 @@ void main() {
await pumpProfiles(tester, profiles: [Profile.custom(label: 'mine')]);
await openProfileMenu(tester, 'mine');
expect(find.text(l.finalConfig), findsOneWidget);
expect(find.text(l.runtimeConfig), findsOneWidget);
expect(find.text(l.extend), findsNothing);
expect(find.text(l.more), findsNothing);
});
@@ -266,7 +291,7 @@ void main() {
expect(find.textContaining('Home is still used'), findsOneWidget);
expect(find.textContaining('used by Work'), findsOneWidget);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(action.deleted, [home.id]);
@@ -303,6 +328,20 @@ void main() {
expectRevealed(tester);
});
testWidgets('not in a picker, whose bar is left empty', (tester) async {
await pumpProfiles(
tester,
profiles: profiles,
currentProfileId: selected.id,
picker: true,
);
await tester.pumpAndSettle();
expect(find.text(profiles.first.label), findsOneWidget);
expect(find.text(selected.label), findsNothing);
expect(find.byType(AppBarActionButton), findsNothing);
});
testWidgets('not when the kept page becomes active again', (tester) async {
final isActive = ValueNotifier(true);
addTearDown(isActive.dispose);