feat(overwrite): add app-level providers, scripts and validated custom proxies

Providers and custom proxies stay behind feature flags.

Changelog: Invalid override warnings and rule presets
This commit is contained in:
chen08209
2026-09-25 03:56:59 +08:00
parent a390a42032
commit 46fb7fee04
76 changed files with 7370 additions and 1171 deletions
+12
View File
@@ -95,6 +95,12 @@ const profilesDirectoryName = 'profiles';
const providersDirectoryName = 'providers';
const proxiesProviderDirectoryName = 'proxies';
const rulesProviderDirectoryName = 'rules';
String providerCacheDirectoryName(ProviderKind kind) => switch (kind) {
ProviderKind.proxy => proxiesProviderDirectoryName,
ProviderKind.rule => rulesProviderDirectoryName,
};
const localhost = '127.0.0.1';
const clashConfigKey = 'clash_config';
const configKey = 'config';
@@ -118,6 +124,8 @@ const ruleListEquality = ListEquality<Rule>();
const scriptListEquality = ListEquality<Script>();
const profileListEquality = ListEquality<Profile>();
const proxyGroupsEquality = ListEquality<ProxyGroup>();
const customProxiesEquality = ListEquality<CustomProxy>();
const clashProviderListEquality = ListEquality<ClashProvider>();
const hotKeyActionListEquality = ListEquality<HotKeyAction>();
const stringAndStringMapEntryListEquality =
ListEquality<MapEntry<String, String>>();
@@ -158,5 +166,9 @@ const main = (config) => {
return config;
}''';
const proxyProviderTemplate = 'proxies: []\n';
const ruleProviderTemplate = 'payload: []\n';
const backupDatabaseName = 'database.sqlite';
const configJsonName = 'config.json';
+17 -10
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
@@ -80,16 +81,6 @@ class AppPath {
return join(mHomeDirPath, 'database.sqlite');
}
Future<String> get backupFilePath async {
final mHomeDirPath = await homeDirPath;
return join(mHomeDirPath, 'backup.zip');
}
Future<String> get restoreDirPath async {
final mHomeDirPath = await homeDirPath;
return join(mHomeDirPath, 'restore');
}
Future<String> get tempFilePath async {
final mTempDir = await tempDir.future;
return join(mTempDir.path, 'temp$uniqueId');
@@ -129,6 +120,22 @@ class AppPath {
return join(path, '$fileName.js');
}
Future<String> get providerCacheRootPath async {
final directory = await homeDirPath;
return join(directory, providersDirectoryName);
}
Future<String> getProviderCachePath(
ProviderKind kind,
String fileName,
) async {
return join(
await providerCacheRootPath,
providerCacheDirectoryName(kind),
fileName,
);
}
Future<String> getProvidersRootPath() async {
final directory = await profilesPath;
return join(directory, providersDirectoryName);
+52 -12
View File
@@ -29,6 +29,18 @@ extension StringExtension on String {
return toLowerCase().compareTo(other.toLowerCase());
}
String get countryFlagEmoji {
final code = toUpperCase();
if (!RegExp(r'^[A-Z]{2}$').hasMatch(code)) {
return this;
}
const regionalIndicatorA = 0x1F1E6;
return String.fromCharCodes([
code.codeUnitAt(0) - 0x41 + regionalIndicatorA,
code.codeUnitAt(1) - 0x41 + regionalIndicatorA,
]);
}
String safeSubstring(int start, [int? end]) {
if (isEmpty) return '';
final safeStart = start.clamp(0, length);
@@ -101,6 +113,46 @@ extension StringExtension on String {
}
return this;
}
String take(int maxLength) {
return length <= maxLength ? this : substring(0, maxLength);
}
String get fileStem {
final dot = lastIndexOf('.');
return dot > 0 ? substring(0, dot) : this;
}
String get urlFileName {
final segments = Uri.tryParse(this)?.pathSegments ?? const [];
return segments.lastWhere(
(segment) => segment.isNotEmpty,
orElse: () => '',
);
}
}
final _labelCounter = RegExp(r'\((\d{1,9})\)$');
String uniqueLabelFor(
String name, {
required String fallback,
required bool Function(String label) taken,
}) {
const maxLength = TextInputLimits.name;
final label = name.trim().takeFirstValid([fallback]).take(maxLength).trim();
if (!taken(label)) {
return label;
}
final counter = _labelCounter.firstMatch(label);
final stem = counter == null ? label : label.substring(0, counter.start);
for (var index = int.parse(counter?[1] ?? '0') + 1; ; index++) {
final suffix = '($index)';
final candidate = stem.take(maxLength - suffix.length) + suffix;
if (!taken(candidate)) {
return candidate;
}
}
}
extension StringNullExt on String? {
@@ -148,15 +200,3 @@ String generateRandomSecret(int length) {
),
);
}
String getOverwriteLabel(String label) {
final reg = RegExp(r'\((\d+)\)$');
final matches = reg.allMatches(label);
if (matches.isNotEmpty) {
final match = matches.last;
final number = int.parse(match[1] ?? '0') + 1;
return '${label.substring(0, match.start)}($number)';
} else {
return '$label(1)';
}
}
+4
View File
@@ -199,6 +199,10 @@ class CoreController {
);
}
Future<String> dumpRuleSet(String path) {
return _interface.dumpRuleSet(path);
}
Future<String> updateExternalProvider({required String providerName}) async {
return _interface.updateExternalProvider(providerName);
}
+7
View File
@@ -27,6 +27,8 @@ mixin CoreInterface {
Future<Map<String, dynamic>> getConfig(String path);
Future<String> dumpRuleSet(String path);
Future<Delay?> asyncTestDelay(String url, String proxyName);
Future<ProbeResult?> probe(ProbeParams params);
@@ -213,6 +215,11 @@ abstract class CoreHandlerInterface with CoreInterface {
);
}
@override
Future<String> dumpRuleSet(String path) async {
return _invokeMessage(method: CoreMethod.dumpRuleSet, arguments: path);
}
@override
Future<bool> crash() async {
return await _invokeMethod<bool>(method: CoreMethod.crash) ?? false;
+1
View File
@@ -12,6 +12,7 @@ enum CoreMethod {
validateProxies,
updateConfig,
getConfig,
dumpRuleSet,
getProxies,
changeProxy,
getTraffic,
+36 -10
View File
@@ -116,16 +116,42 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
);
}
Future<void> renameUse({required String oldName, required String newName}) {
return customUpdate(
'UPDATE ${proxyGroups.entityName} '
'SET ${proxyGroups.use.name} = REPLACE(${proxyGroups.use.name}, ?, ?) '
'WHERE ${proxyGroups.profileId.name} IS NOT NULL',
variables: [
Variable.withString('"$oldName"'),
Variable.withString('"$newName"'),
],
);
Future<Set<int>> profileIdsUsing(String provider) async {
final query = selectOnly(proxyGroups)
..addColumns([proxyGroups.profileId, proxyGroups.use])
..where(proxyGroups.profileId.isNotNull() & proxyGroups.use.isNotNull());
return {
for (final row in await query.get())
if (row.readWithConverter(proxyGroups.use)!.contains(provider))
row.read(proxyGroups.profileId)!,
};
}
Future<void> renameUse(
Iterable<int> profileIds, {
required String oldName,
required String newName,
}) async {
if (profileIds.isEmpty) {
return;
}
final rows =
await (proxyGroups.select()
..where((t) => t.profileId.isIn(profileIds) & t.use.isNotNull()))
.get();
for (final row in rows) {
final use = row.use!;
if (!use.contains(oldName)) {
continue;
}
await (proxyGroups.update()..where((t) => t.id.equals(row.id))).write(
ProxyGroupsCompanion(
use: Value([
for (final name in use) name == oldName ? newName : name,
]),
),
);
}
}
void setAllWithBatch(
+32 -3
View File
@@ -240,10 +240,14 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
return stmt.write(RulesCompanion(ruleTarget: Value(newName)));
}
Future<int> renameCustomRuleProvider({
Future<int> renameCustomRuleProvider(
Iterable<int> profileIds, {
required String oldName,
required String newName,
}) {
}) async {
if (profileIds.isEmpty) {
return 0;
}
final stmt = rules.update()
..where(
(t) =>
@@ -254,12 +258,37 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
(t) => t.id.isInQuery(
selectOnly(profileRuleLinks)
..addColumns([profileRuleLinks.ruleId])
..where(profileRuleLinks.scene.equalsValue(RuleScene.custom)),
..where(
profileRuleLinks.profileId.isIn(profileIds) &
profileRuleLinks.scene.equalsValue(RuleScene.custom),
),
),
);
return stmt.write(RulesCompanion(ruleProvider: Value(newName)));
}
Future<Set<int>> profileIdsUsingCustomRuleProvider(String provider) async {
final query =
selectOnly(rules, distinct: true).join([
innerJoin(
profileRuleLinks,
profileRuleLinks.ruleId.equalsExp(rules.id),
useColumns: false,
),
])
..addColumns([profileRuleLinks.profileId])
..where(
rules.ruleAction.equalsValue(RuleAction.RULE_SET) &
rules.ruleProvider.equals(provider) &
profileRuleLinks.scene.equalsValue(RuleScene.custom) &
profileRuleLinks.profileId.isNotNull(),
);
return {
for (final row in await query.get())
row.read(profileRuleLinks.profileId)!,
};
}
JoinedSelectStatement<HasResultSet, dynamic> _getSelectStatement({
int? profileId,
RuleScene? scene,
+64 -3
View File
@@ -62,6 +62,32 @@ enum GroupType {
String() => throw UnimplementedError(),
};
}
/// The core refuses a relay group, so it stays parseable but unofferable.
static List<GroupType> get selectableValues =>
values.where((item) => item != Relay).toList();
}
enum LoadBalanceStrategy {
@JsonValue('consistent-hashing')
consistentHashing('consistent-hashing'),
@JsonValue('round-robin')
roundRobin('round-robin'),
@JsonValue('sticky-sessions')
stickySessions('sticky-sessions');
final String value;
const LoadBalanceStrategy(this.value);
static LoadBalanceStrategy? parse(String? value) {
for (final item in values) {
if (item.value == value) {
return item;
}
}
return null;
}
}
extension GroupTypeExtension on GroupType {
@@ -572,19 +598,53 @@ extension RuleActionExt on RuleAction {
}
}
enum RulePayloadError { network, numberRange, dscpRange }
extension RulePayloadErrorExt on RulePayloadError {
String getMessage(BuildContext context) {
final appLocalizations = context.appLocalizations;
return switch (this) {
RulePayloadError.network => appLocalizations.invalidNetworkContent,
RulePayloadError.numberRange => appLocalizations.invalidRangeContent,
RulePayloadError.dscpRange => appLocalizations.invalidDscpContent,
};
}
}
enum OverwriteType { standard, script, custom }
enum RuleTarget {
DIRECT,
REJECT;
DIRECT('DIRECT'),
REJECT('REJECT'),
REJECT_DROP('REJECT-DROP');
final String value;
const RuleTarget(this.value);
static final List<String> baseTargetNames = List.unmodifiable(
RuleTarget.values.map((item) => item.name),
RuleTarget.values.map((item) => item.value),
);
static final Set<String> baseTargets = Set.unmodifiable(baseTargetNames);
}
enum ProviderKind { proxy, rule }
/// Where a provider name a custom overwrite uses resolves, in lookup order.
enum ProviderSource { subscription, profile, app }
enum RuleProviderBehavior { domain, ipcidr, classical }
enum RuleProviderFormat { yaml, text, mrs }
extension RuleProviderFormatExt on RuleProviderFormat? {
/// The core reads an mrs set only as a domain or an ipcidr one.
List<RuleProviderBehavior> get behaviors => this == RuleProviderFormat.mrs
? const [RuleProviderBehavior.domain, RuleProviderBehavior.ipcidr]
: RuleProviderBehavior.values;
}
enum RestoreStrategy { compatible, override }
enum Language { yaml, javaScript, json }
@@ -595,6 +655,7 @@ enum QueryTag { proxies, access }
enum LoadingTag {
profiles,
scripts,
backup_restore,
access,
proxies,
+3
View File
@@ -1,6 +1,9 @@
export 'overwrite_editor_page.dart';
export 'overwrite_form_row.dart';
export 'overwrite_issue.dart';
export 'overwrite_nested_sheet.dart';
export 'overwrite_selection_sheet.dart';
export 'overwrite_stage_flow.dart';
export 'provider_source.dart';
export 'rule.dart';
export 'rule_preset.dart';
+158 -80
View File
@@ -1,6 +1,8 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/models/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -16,9 +18,9 @@ typedef OverwriteItemBuilder<T> =
VoidCallback onToggleSelected,
);
class OverwriteEditorPage<T> extends ConsumerStatefulWidget {
class OverwriteEditorPage<T, K> extends ConsumerStatefulWidget {
final String title;
final List<T> Function(WidgetRef ref) itemsOf;
final List<T>? Function(WidgetRef ref) itemsOf;
final OverwriteItemBuilder<T> itemBuilder;
final void Function(int oldIndex, int newIndex) onReorder;
final VoidCallback onAdd;
@@ -26,8 +28,9 @@ class OverwriteEditorPage<T> extends ConsumerStatefulWidget {
final double? itemExtent;
final bool selectionEnabled;
final bool dragFromRow;
final dynamic Function(T item)? idOf;
final Future<bool> Function(Set<dynamic> selected)? onDelete;
final K Function(T item) idOf;
final void Function(Set<K> ids)? onDelete;
final Iterable<String?> Function(T item)? searchFieldsOf;
const OverwriteEditorPage({
super.key,
@@ -40,18 +43,22 @@ class OverwriteEditorPage<T> extends ConsumerStatefulWidget {
this.itemExtent,
this.selectionEnabled = false,
this.dragFromRow = false,
this.idOf,
required this.idOf,
this.onDelete,
this.searchFieldsOf,
});
@override
ConsumerState<OverwriteEditorPage<T>> createState() =>
_OverwriteEditorPageState<T>();
ConsumerState<OverwriteEditorPage<T, K>> createState() =>
_OverwriteEditorPageState<T, K>();
}
class _OverwriteEditorPageState<T> extends ConsumerState<OverwriteEditorPage<T>>
with UniqueKeyStateMixin {
class _OverwriteEditorPageState<T, K>
extends ConsumerState<OverwriteEditorPage<T, K>> {
late final ScrollController _scrollController;
var _query = SearchQuery('');
final _searchTexts = Expando<String>();
var _selected = <K>{};
@override
void initState() {
@@ -65,35 +72,62 @@ class _OverwriteEditorPageState<T> extends ConsumerState<OverwriteEditorPage<T>>
super.dispose();
}
Set<dynamic> get _selected =>
widget.selectionEnabled ? ref.watch(itemsProvider(key)) : const {};
Set<K> _liveSelection(List<T>? items) {
if (!widget.selectionEnabled || _selected.isEmpty || items == null) {
return const {};
}
return items.map(widget.idOf).where(_selected.contains).toSet();
}
dynamic _idOf(T item) => widget.idOf?.call(item) ?? item;
List<T> _visibleItems(List<T> items) {
final searchFieldsOf = widget.searchFieldsOf;
if (searchFieldsOf == null) {
return items;
}
return items
.whereMatches(_query, searchFieldsOf, texts: _searchTexts)
.toList();
}
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
void _handleToggleSelected(T item) {
if (!widget.selectionEnabled) {
return;
}
final id = _idOf(item);
ref.read(itemsProvider(key).notifier).update((selected) {
final newSelected = Set<dynamic>.from(selected)..addOrRemove(id);
return newSelected;
setState(() {
_selected = {..._selected}..addOrRemove(widget.idOf(item));
});
}
Future<void> _handleSelectAll() async {
final ids = widget.itemsOf(ref).map(_idOf).toSet();
ref.read(itemsProvider(key).notifier).update((selected) {
return selected.containsAll(ids) ? {} : ids;
void _handleSelectAll(List<T> visibleItems) {
final ids = visibleItems.map(widget.idOf).toSet();
setState(() {
_selected = _selected.containsAll(ids) ? {} : ids;
});
}
Future<void> _handleDelete() async {
final selected = _selected;
final deleted = await widget.onDelete?.call(selected) ?? true;
if (deleted && mounted) {
ref.read(itemsProvider(key).notifier).value = {};
Future<void> _handleDelete(
void Function(Set<K> ids) onDelete,
Set<K> ids, {
required Set<K> kept,
}) async {
final res = await dialogs.showMessage(
message: TextSpan(
text: context.appLocalizations.deleteMultipTip(widget.title),
),
);
if (res != true || !mounted) {
return;
}
onDelete(ids);
setState(() {
_selected = kept;
});
}
Widget _buildItem(
@@ -101,9 +135,10 @@ class _OverwriteEditorPageState<T> extends ConsumerState<OverwriteEditorPage<T>>
T item,
int index,
int total,
Set<dynamic> selected,
) {
final id = _idOf(item);
Set<K> selected, {
required bool reorderable,
}) {
final id = widget.idOf(item);
final child = ItemPositionProvider(
position: ItemPosition.get(index, total),
child: widget.itemBuilder(
@@ -111,12 +146,12 @@ class _OverwriteEditorPageState<T> extends ConsumerState<OverwriteEditorPage<T>>
ref,
item,
index,
widget.selectionEnabled && selected.isNotEmpty,
widget.selectionEnabled && selected.contains(id),
selected.isNotEmpty,
selected.contains(id),
() => _handleToggleSelected(item),
),
);
if (widget.dragFromRow) {
if (reorderable && widget.dragFromRow) {
return ReorderableDelayedDragStartListener(
key: ValueKey(id),
index: index,
@@ -129,66 +164,109 @@ class _OverwriteEditorPageState<T> extends ConsumerState<OverwriteEditorPage<T>>
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final items = widget.itemsOf(ref);
final selected = _selected;
final loadedItems = widget.itemsOf(ref);
final items = _visibleItems(loadedItems ?? const []);
final isSearching = widget.searchFieldsOf != null && _query.isNotEmpty;
final selected = _liveSelection(loadedItems);
final visibleSelected = isSearching ? _liveSelection(items) : selected;
final isSelecting = selected.isNotEmpty;
final onDelete = widget.onDelete;
final selectionActions = [
if (onDelete != null)
IconButtonData(
glyph: AppGlyphs.delete,
onPressed: visibleSelected.isEmpty
? null
: () => _handleDelete(
onDelete,
visibleSelected,
kept: selected.difference(visibleSelected),
),
tooltip: appLocalizations.delete,
),
IconButtonData(
glyph: AppGlyphs.selectAll,
onPressed: () => _handleSelectAll(items),
tooltip: appLocalizations.selectAll,
),
];
return CommonScaffold(
title: widget.title,
searchState: widget.searchFieldsOf != null
? AppBarSearchState(onSearch: _handleSearch)
: null,
actions: [
if (widget.selectionEnabled &&
widget.onDelete != null &&
selected.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: appLocalizations.delete,
onPressed: _handleDelete,
icon: const Icon(Icons.delete),
),
if (!isSelecting)
FilledButton.tonal(
onPressed: widget.onAdd,
child: Text(appLocalizations.add),
),
const SizedBox(width: 2),
],
CommonMinFilledButtonTheme(
child: widget.selectionEnabled && selected.isNotEmpty
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: widget.onAdd,
child: Text(appLocalizations.add),
),
),
const SizedBox(width: 8),
],
selectionActions: isSelecting ? selectionActions : const [],
body: NullStatusSwitcher(
isLoading: loadedItems == null,
isEmpty: items.isEmpty,
isSearching: isSearching,
nullStatus: NullStatus(label: widget.emptyLabel),
child: CommonScrollBar(
controller: _scrollController,
child: ReorderableListView.builder(
scrollController: _scrollController,
buildDefaultDragHandles: false,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
).copyWith(bottom: 24),
itemBuilder: (_, index) {
final item = items[index];
return _buildItem(context, item, index, items.length, selected);
},
itemExtent: widget.itemExtent,
itemCount: items.length,
proxyDecorator: (child, index, animation) {
final item = items[index];
return commonProxyDecorator(
_buildItem(context, item, index, items.length, selected),
index,
animation,
);
},
onReorderItem: widget.onReorder,
),
child: isSearching
? _buildSearchResults(context, items, selected)
: _buildReorderableList(context, items, selected),
),
),
);
}
EdgeInsets _listPadding(BuildContext context) =>
EdgeInsets.fromLTRB(16, context.contentTopPadding, 16, 24);
Widget _buildReorderableList(
BuildContext context,
List<T> items,
Set<K> selected,
) {
Widget itemAt(int index) => _buildItem(
context,
items[index],
index,
items.length,
selected,
reorderable: true,
);
return ReorderableListView.builder(
scrollController: _scrollController,
buildDefaultDragHandles: false,
padding: _listPadding(context),
itemBuilder: (_, index) => itemAt(index),
itemExtent: widget.itemExtent,
itemCount: items.length,
proxyDecorator: (child, index, animation) =>
commonProxyDecorator(itemAt(index), index, animation),
onReorderItem: widget.onReorder,
);
}
// Indices here are positions among the matches, which onReorder cannot
// map back onto the full list, so a filtered list is not reorderable.
Widget _buildSearchResults(
BuildContext context,
List<T> items,
Set<K> selected,
) {
return ListView.builder(
controller: _scrollController,
padding: _listPadding(context),
itemBuilder: (_, index) => _buildItem(
context,
items[index],
index,
items.length,
selected,
reorderable: false,
),
itemExtent: widget.itemExtent,
itemCount: items.length,
);
}
}
@@ -1,4 +1,5 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -16,7 +17,12 @@ class InfoMessageButton extends StatelessWidget {
onPressed: () {
dialogs.showMessage(message: TextSpan(text: message));
},
icon: Icon(Icons.info, size: 20.ap, color: context.colorScheme.error),
icon: GlyphIcon(
AppGlyphs.info,
fill: 1,
size: 20.ap,
color: context.colorScheme.error,
),
),
);
}
+127
View File
@@ -0,0 +1,127 @@
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:material_ui/material_ui.dart';
import 'overwrite_form_row.dart';
extension OverwriteIssueExt on OverwriteIssue {
String getMessage(BuildContext context) {
final appLocalizations = context.appLocalizations;
return switch (this) {
EmptyNameIssue() => appLocalizations.overwriteIssueEmptyName,
ReservedNameIssue(:final name) =>
appLocalizations.overwriteIssueReservedName(name),
DuplicateNameIssue(:final name) =>
appLocalizations.overwriteIssueDuplicateName(name),
CoreRejectedIssue(:final message) =>
appLocalizations.overwriteIssueCoreRejected(message),
MissingProxiesIssue(:final names) =>
appLocalizations.overwriteIssueMissingProxies(names.join(', ')),
MissingProvidersIssue(:final names) =>
appLocalizations.overwriteIssueMissingProviders(names.join(', ')),
NoProxySourceIssue() => appLocalizations.overwriteIssueNoProxySource,
GroupLoopIssue(:final names) => appLocalizations.overwriteIssueGroupLoop(
names.join(' › '),
),
InvalidPayloadIssue(:final error) => error.getMessage(context),
MissingRuleSetIssue(:final name) => appLocalizations.invalidRuleSet(name),
MissingSubRuleIssue(:final name) => appLocalizations.invalidSubRule(name),
MissingTargetIssue(:final name) => appLocalizations.invalidPolicy(name),
SubscriptionGroupMissingProxiesIssue(:final names) =>
appLocalizations.overwriteIssueSubscriptionGroupMissingProxies(
names.join(', '),
),
};
}
}
extension OverwriteIssuesExt on Iterable<OverwriteIssue> {
String getMessage(BuildContext context) {
if (length == 1) {
return first.getMessage(context);
}
return map((issue) => '• ${issue.getMessage(context)}').join('\n');
}
}
class OverwriteIssueButton extends StatelessWidget {
final List<OverwriteIssue> issues;
const OverwriteIssueButton({super.key, required this.issues});
@override
Widget build(BuildContext context) {
return InfoMessageButton(message: issues.getMessage(context));
}
}
class OverwriteIssuesBanner extends StatelessWidget {
final List<OverwriteIssue> issues;
const OverwriteIssuesBanner({super.key, required this.issues});
@override
Widget build(BuildContext context) {
return OverwriteErrorBanner(
message: issues.isEmpty ? null : issues.getMessage(context),
);
}
}
class OverwriteErrorBanner extends StatelessWidget {
final String? message;
const OverwriteErrorBanner({super.key, required this.message});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final message = this.message;
return AnimatedSize(
duration: commonDuration,
alignment: Alignment.topCenter,
child: message == null
? const SizedBox(width: double.infinity)
: Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: ShapeDecoration(
color: colorScheme.errorContainer,
shape: AppShape.md,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
GlyphIcon(
AppGlyphs.error,
size: 20.ap,
color: colorScheme.onErrorContainer,
),
Expanded(
child: Text(
message,
style: context.textTheme.bodyMedium?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
);
}
}
Future<void> showOverwriteIssues(
BuildContext context,
List<OverwriteIssue> issues,
) async {
await dialogs.showMessage(
message: TextSpan(text: issues.getMessage(context)),
cancelable: false,
);
}
@@ -13,16 +13,12 @@ Future<void> showOverwriteNestedSheet<T>({
required int profileId,
required List<Override> overrides,
required T Function(WidgetRef ref) currentOf,
required bool Function(BuildContext context, WidgetRef ref) save,
required FutureOr<bool> Function(BuildContext context, WidgetRef ref) save,
required WidgetBuilder formBuilder,
}) {
return showSheet(
context: context,
props: const SheetProps(
isScrollControlled: true,
backgroundColor: Colors.transparent,
maxWidth: double.maxFinite,
),
props: nestedPagedSheetProps,
builder: (_) {
return ProfileIdProvider(
profileId: profileId,
@@ -41,7 +37,7 @@ Future<void> showOverwriteNestedSheet<T>({
class OverwriteNestedSheet<T> extends ConsumerStatefulWidget {
final T Function(WidgetRef ref) currentOf;
final bool Function(BuildContext context, WidgetRef ref) save;
final FutureOr<bool> Function(BuildContext context, WidgetRef ref) save;
final WidgetBuilder formBuilder;
const OverwriteNestedSheet({
@@ -58,7 +54,6 @@ class OverwriteNestedSheet<T> extends ConsumerStatefulWidget {
class _OverwriteNestedSheetState<T>
extends ConsumerState<OverwriteNestedSheet<T>> {
final GlobalKey<NavigatorState> _nestedNavigatorKey = GlobalKey();
late final T _origin;
bool _closing = false;
@@ -68,12 +63,11 @@ class _OverwriteNestedSheetState<T>
_origin = widget.currentOf(ref);
}
Future<void> _handleClose() async {
Future<void> _handleDismiss(bool hasPushedPages) async {
if (_closing) return;
_closing = true;
try {
final state = _nestedNavigatorKey.currentState;
if (state != null && state.canPop()) {
if (hasPushedPages) {
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.confirmExitWindow),
);
@@ -82,7 +76,7 @@ class _OverwriteNestedSheetState<T>
}
}
if (context.mounted) {
unawaited(_handleExit());
await _handleExit();
}
} finally {
_closing = false;
@@ -106,66 +100,17 @@ class _OverwriteNestedSheetState<T>
Navigator.of(context).pop();
return;
}
if (widget.save(context, ref)) {
if (await widget.save(context, ref) && mounted) {
Navigator.of(context).pop();
}
}
Future<void> _handlePop() async {
final state = _nestedNavigatorKey.currentState;
if (state != null && state.canPop()) {
state.pop();
} else {
unawaited(_handleExit());
}
}
@override
Widget build(BuildContext context) {
final nestedNavigator = Navigator(
key: _nestedNavigatorKey,
onGenerateInitialRoutes: (navigator, initialRoute) {
return [
PagedSheetRoute(builder: (context) => widget.formBuilder(context)),
];
},
);
final sheetProvider = SheetProvider.of(context);
return CommonPopScope(
onPop: (_) async {
unawaited(_handlePop());
return false;
},
child: sheetProvider!.copyWith(
nestedNavigatorPop: ([data]) {
Navigator.of(context).pop(data);
},
child: SizedBox(
width: sheetProvider.type == SheetType.sideSheet ? 400 : null,
height: double.infinity,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () async {
await _handleClose();
},
),
),
Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: double.infinity,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: PagedSheet(child: nestedNavigator),
),
),
),
],
),
),
),
return NestedPagedSheet(
builder: widget.formBuilder,
onExit: () => unawaited(_handleExit()),
onDismiss: _handleDismiss,
);
}
}
@@ -1,16 +1,17 @@
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/widgets/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const _maxRevealSteps = 6;
import 'package:super_sliver_list/super_sliver_list.dart';
class OverwriteSelectionSection<T> {
final String? label;
final List<T> items;
final String Function(BuildContext context, T item)? subtitleBuilder;
final String? Function(BuildContext context, T item)? subtitleBuilder;
const OverwriteSelectionSection({
this.label,
@@ -47,64 +48,85 @@ class OverwriteSelectionSheet<T> extends ConsumerStatefulWidget {
class _OverwriteSelectionSheetState<T>
extends ConsumerState<OverwriteSelectionSheet<T>> {
final _controller = ScrollController();
final _revealController = ListController();
final _selectedKey = GlobalKey();
var _revealRequested = false;
var _query = SearchQuery('');
@override
void dispose() {
_controller.dispose();
_revealController.dispose();
super.dispose();
}
int get _itemCount =>
widget.sections.fold(0, (value, section) => value + section.items.length);
List<int> get _sectionOffsets {
final offsets = <int>[];
var offset = 0;
for (final section in widget.sections) {
offsets.add(offset);
offset += section.items.length;
}
return offsets;
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
int _indexOf(T? selected) {
var index = 0;
for (final section in widget.sections) {
for (final item in section.items) {
if (item == selected) {
return index;
}
index++;
}
List<OverwriteSelectionSection<T>> _matchingSections(BuildContext context) {
if (_query.isEmpty) {
return widget.sections;
}
return -1;
return [
for (final section in widget.sections)
OverwriteSelectionSection(
label: section.label,
items: section.items
.whereMatches(
_query,
(item) => [
widget.labelBuilder(item),
section.subtitleBuilder?.call(context, item),
],
)
.toList(),
subtitleBuilder: section.subtitleBuilder,
),
].where((section) => section.items.isNotEmpty).toList();
}
// The list builds lazily, so a selection below the first viewport has no
// element to reveal yet. Each jump builds more children, which also sharpens
// the sliver's own extent estimate for the next step.
Future<void> _revealSelected(int index, int count) async {
for (var step = 0; step < _maxRevealSteps; step++) {
if (!mounted || !_controller.hasClients) {
return;
int _countOf(List<OverwriteSelectionSection<T>> sections) =>
sections.fold(0, (value, section) => value + section.items.length);
({int section, int index})? _locate(
List<OverwriteSelectionSection<T>> sections,
T? selected,
) {
if (selected == null) {
return null;
}
for (final (sectionIndex, section) in sections.indexed) {
final index = section.items.indexOf(selected);
if (index != -1) {
return (section: sectionIndex, index: index);
}
final itemContext = _selectedKey.currentContext;
if (itemContext != null && itemContext.mounted) {
_alignSelected(itemContext);
return;
}
final position = _controller.position;
final target = (position.maxScrollExtent * index / count).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if ((target - position.pixels).abs() < 1) {
return;
}
_controller.jumpTo(target);
await WidgetsBinding.instance.endOfFrame;
}
return null;
}
void _revealSelected(int index) {
if (!mounted || !_controller.hasClients) {
return;
}
final itemContext = _selectedKey.currentContext;
if (itemContext != null && itemContext.mounted) {
_alignSelected(itemContext);
return;
}
if (!_revealController.isAttached) {
return;
}
_revealController.jumpToItem(
index: index,
scrollController: _controller,
alignment: 0.5,
);
final position = _controller.position;
if (position.pixels > position.maxScrollExtent) {
_controller.jumpTo(position.maxScrollExtent);
}
}
@@ -137,14 +159,13 @@ class _OverwriteSelectionSheetState<T>
required bool isRevealTarget,
}) {
final position = ItemPosition.get(index, section.items.length);
final subtitle = section.subtitleBuilder?.call(context, item);
return ItemPositionProvider(
key: isRevealTarget ? _selectedKey : null,
position: position,
child: DecorationListItem(
onPressed: () => widget.onSelected(item),
subtitle: section.subtitleBuilder != null
? TooltipLabel(section.subtitleBuilder!(context, item))
: null,
subtitle: subtitle != null ? TooltipLabel(subtitle) : null,
title: TooltipText(
text: Text(
widget.labelBuilder(item),
@@ -153,7 +174,7 @@ class _OverwriteSelectionSheetState<T>
),
),
isSelected: isSelected,
trailing: isSelected ? const Icon(Icons.check) : null,
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
),
);
}
@@ -161,66 +182,79 @@ class _OverwriteSelectionSheetState<T>
@override
Widget build(BuildContext context) {
final height = ref.sheetHeight(context, (widget.bottomHeightFactor ?? 1));
final count = _itemCount;
final sections = _matchingSections(context);
final count = _countOf(sections);
final selected = widget.selectedOf(ref);
final selectedIndex = _indexOf(selected);
final sectionOffsets = _sectionOffsets;
if (!_revealRequested && selectedIndex > 0) {
final location = _locate(sections, selected);
if (!_revealRequested && location != null) {
_revealRequested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => _revealSelected(selectedIndex, count),
(_) => _revealSelected(location.index),
);
}
return AdaptiveSheetScaffold(
sheetTransparentToolBar: true,
final searchable =
_query.isNotEmpty ||
_countOf(widget.sections) >= sheetSearchMinItemCount;
return CommonScaffold(
title: widget.title,
searchState: searchable
? AppBarSearchState(onSearch: _handleSearch)
: null,
body: SizedBox(
height: height,
child: NullStatusSwitcher(
isEmpty: count == 0 && widget.emptyLabel != null,
nullStatus: NullStatus(label: widget.emptyLabel ?? ''),
child: CustomScrollView(
controller: _controller,
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.sheetTopPadding),
),
for (final (sectionIndex, section)
in widget.sections.indexed) ...[
if (section.label != null) ...[
child: Builder(
builder: (context) => NullStatusSwitcher(
isEmpty:
count == 0 && (widget.emptyLabel != null || _query.isNotEmpty),
isSearching: _query.isNotEmpty,
nullStatus: NullStatus(label: widget.emptyLabel ?? ''),
child: CustomScrollView(
controller: _controller,
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.contentTopPadding),
),
for (final (sectionIndex, section) in sections.indexed) ...[
if (section.label != null) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: InfoHeader(info: Info(label: section.label!)),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 4)),
],
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: InfoHeader(info: Info(label: section.label!)),
sliver: SuperSliverList.builder(
listController: sectionIndex == location?.section
? _revealController
: null,
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return _buildItem(
context,
section,
item,
index,
isSelected: item == selected,
isRevealTarget:
sectionIndex == location?.section &&
index == location?.index,
);
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: 4)),
],
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return _buildItem(
context,
section,
item,
index,
isSelected: item == selected,
isRevealTarget:
sectionOffsets[sectionIndex] + index ==
selectedIndex,
);
},
),
SliverToBoxAdapter(
child: SizedBox(height: 20 + BottomInsetScope.of(context)),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 20)),
],
),
),
),
),
title: widget.title,
);
}
}
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
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/widgets/widgets.dart';
@@ -56,11 +57,13 @@ class OverwriteDismissItem extends ConsumerWidget {
subtitle: subtitle != null ? TooltipLabel(subtitle!) : null,
contentPadding: const EdgeInsets.only(left: 16, right: 0),
leading: CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.remove,
onPressed: onRemove,
icon: const Icon(Icons.remove, size: 18),
padding: EdgeInsets.zero,
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.remove,
onPressed: onRemove,
icon: const GlyphIcon(AppGlyphs.remove, size: 18, fill: 1),
padding: EdgeInsets.zero,
),
),
),
trailing: Row(
@@ -73,7 +76,7 @@ class OverwriteDismissItem extends ConsumerWidget {
child: Container(
color: Colors.transparent,
padding: EdgeInsets.all(dragIconPadding),
child: const Icon(Icons.drag_handle),
child: const GlyphIcon(AppGlyphs.dragHandle),
),
),
],
@@ -0,0 +1,10 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
extension ProviderSourceExt on ProviderSource {
String label(AppLocalizations appLocalizations) => switch (this) {
ProviderSource.subscription => appLocalizations.providerSourceSubscription,
ProviderSource.profile => appLocalizations.profile,
ProviderSource.app => appLocalizations.app,
};
}
+58 -46
View File
@@ -1,9 +1,9 @@
library;
import 'package:collection/collection.dart';
import 'package:dynamic_color/dynamic_color.dart';
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/clash_config.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -23,7 +23,7 @@ class RuleItem extends StatelessWidget {
final bool hasMatch;
final void Function() onSelected;
final void Function(Rule rule) onEdit;
final bool Function(Rule rule)? checkInvalidHandler;
final String? Function(Rule rule)? invalidMessageOf;
const RuleItem({
super.key,
@@ -31,53 +31,59 @@ class RuleItem extends StatelessWidget {
required this.rule,
required this.onSelected,
required this.onEdit,
this.checkInvalidHandler,
this.invalidMessageOf,
this.isEditing = false,
this.hasMatch = false,
});
({bool invalid, Color? color}) _checkInvalid(BuildContext context) {
if (rule.ruleAction != RuleAction.SUB_RULE) {
final ruleTarget = rule.ruleTarget ?? '';
if (ruleTarget.toUpperCase() == 'DIRECT') {
return (
invalid: false,
color: Colors.green.harmonizeWith(context.colorScheme.primary),
);
} else if (ruleTarget.toUpperCase() == 'REJECT') {
return (
invalid: false,
color: Colors.orange.harmonizeWith(context.colorScheme.primary),
);
} else if (hasMatch && ruleTarget.toUpperCase() == 'MATCH') {
return (invalid: false, color: context.colorScheme.tertiary);
({bool invalid, Color? color, String? message}) _checkInvalid(
BuildContext context,
) {
final message = invalidMessageOf?.call(rule);
if (message == null && rule.ruleAction != RuleAction.SUB_RULE) {
final ruleTarget = (rule.ruleTarget ?? '').toUpperCase();
final color = switch (ruleTarget) {
'DIRECT' => context.colorScheme.success,
'REJECT' || 'REJECT-DROP' => context.colorScheme.warning,
'MATCH' when hasMatch => context.colorScheme.tertiary,
_ => null,
};
if (color != null) {
return (invalid: false, color: color, message: null);
}
}
bool invalid = true;
if (checkInvalidHandler != null) {
invalid = checkInvalidHandler!(rule);
}
final invalid = invalidMessageOf == null || message != null;
return (
invalid: invalid,
color: invalid ? context.colorScheme.error : context.colorScheme.tertiary,
message: message,
);
}
Widget _buildInfoWidget(BuildContext context) {
Widget _buildInfoWidget(BuildContext context, String? message) {
return CommonMinIconButtonTheme(
child: IconButton(
tooltip: context.appLocalizations.tip,
onPressed: () {
dialogs.showMessage(
message: TextSpan(
text: rule.targetErrorTip(
context.appLocalizations.invalidSubRule(rule.subRule ?? ''),
context.appLocalizations.invalidPolicy(rule.ruleTarget ?? ''),
),
text:
message ??
rule.targetErrorTip(
context.appLocalizations.invalidSubRule(rule.subRule ?? ''),
context.appLocalizations.invalidPolicy(
rule.ruleTarget ?? '',
),
),
),
);
},
icon: Icon(Icons.info, size: 16.ap, color: context.colorScheme.error),
icon: GlyphIcon(
AppGlyphs.info,
fill: 1,
size: 16.ap,
color: context.colorScheme.error,
),
),
);
}
@@ -104,6 +110,7 @@ class RuleItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _RuleItemLabel(rule: rule)),
const SizedBox(width: 16),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * _targetMaxWidthFactor,
@@ -111,7 +118,8 @@ class RuleItem extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (invalid) _buildInfoWidget(context),
if (invalid)
_buildInfoWidget(context, checkResult.message),
if (rule.realTarget != null)
Flexible(
child: TooltipText(
@@ -248,7 +256,7 @@ class _AddOrEditRuleDialogState extends State<AddOrEditRuleDialog> {
void _initState() {
_targetItems = [
...RuleTarget.values.map(
(item) => DropdownMenuEntry(value: item.name, label: item.name),
(item) => DropdownMenuEntry(value: item.value, label: item.value),
),
DropdownMenuEntry(
value: RuleAction.MATCH.value,
@@ -337,22 +345,24 @@ class _AddOrEditRuleDialogState extends State<AddOrEditRuleDialog> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton.tonal(
onPressed: () async {
_ruleAction =
await dialogs.showCommonDialog<RuleAction>(
filter: false,
child: OptionsDialog<RuleAction>(
title: appLocalizations.ruleName,
options: RuleAction.addedRuleActions,
textBuilder: (item) => item.value,
value: _ruleAction,
),
) ??
_ruleAction;
setState(() {});
},
child: Text(_ruleAction.value),
ElasticButton(
child: FilledButton.tonal(
onPressed: () async {
_ruleAction =
await dialogs.showCommonDialog<RuleAction>(
context: context,
child: OptionsDialog<RuleAction>(
title: appLocalizations.ruleName,
options: RuleAction.addedRuleActions,
textBuilder: (item) => item.value,
value: _ruleAction,
),
) ??
_ruleAction;
setState(() {});
},
child: Text(_ruleAction.value),
),
),
const SizedBox(height: 24),
_RuleContentField(
@@ -465,6 +475,8 @@ class _RuleTargetField extends StatelessWidget {
menuHeight: 250,
enableFilter: false,
enableSearch: false,
trailingIcon: const GlyphIcon(AppGlyphs.chevronDown, size: 20),
selectedTrailingIcon: const GlyphIcon(AppGlyphs.chevronUp, size: 20),
dropdownMenuEntries: entries,
onSelected: (value) => onSelected(value as String?),
errorText: filed.errorText,
+142
View File
@@ -0,0 +1,142 @@
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/l10n/l10n.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
enum RulePreset {
blockQuic(AppGlyphs.block, [
'AND,((NETWORK,UDP),(DST-PORT,443)),REJECT-DROP',
]),
blockStun(AppGlyphs.eyeOff, [
'AND,((NETWORK,UDP),(DST-PORT,3478/19302)),REJECT-DROP',
]),
blockDot(AppGlyphs.dns, ['DST-PORT,853,REJECT']),
lanDirect(AppGlyphs.wifi, [
'GEOSITE,private,DIRECT',
'GEOIP,LAN,DIRECT,no-resolve',
]),
systemServicesDirect(AppGlyphs.devices, [
'GEOSITE,apple,DIRECT',
'GEOSITE,microsoft,DIRECT',
]),
bittorrentDirect(AppGlyphs.cloudDownload, [
'GEOSITE,category-public-tracker,DIRECT',
'GEOSITE,category-pt,DIRECT',
r'PROCESS-NAME-REGEX,(?i)^(qbittorrent|transmission.*|deluge.*|aria2c|motrix|utorrent|bitcomet)(\.exe)?$,DIRECT',
]);
final Glyph glyph;
final List<String> rawRules;
const RulePreset(this.glyph, this.rawRules);
String label(AppLocalizations appLocalizations) => switch (this) {
RulePreset.blockQuic => appLocalizations.rulePresetBlockQuic,
RulePreset.blockStun => appLocalizations.rulePresetBlockStun,
RulePreset.blockDot => appLocalizations.rulePresetBlockDot,
RulePreset.lanDirect => appLocalizations.rulePresetLanDirect,
RulePreset.systemServicesDirect =>
appLocalizations.rulePresetSystemServicesDirect,
RulePreset.bittorrentDirect => appLocalizations.rulePresetBittorrentDirect,
};
List<Rule> get rules => rawRules.map(Rule.parse).toList();
}
class RulePresetSheet extends ConsumerStatefulWidget {
final ValueChanged<List<Rule>> onAdd;
const RulePresetSheet({super.key, required this.onAdd});
@override
ConsumerState<RulePresetSheet> createState() => _RulePresetSheetState();
}
class _RulePresetSheetState extends ConsumerState<RulePresetSheet> {
final _selected = <RulePreset>{};
void _handleToggle(RulePreset preset) {
setState(() {
_selected.addOrRemove(preset);
});
}
void _handleConfirm() {
final rules = [
for (final preset in RulePreset.values)
if (_selected.contains(preset)) ...preset.rules,
];
Navigator.of(context).pop();
widget.onAdd(rules);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
const presets = RulePreset.values;
return CommonScaffold(
title: appLocalizations.quickAdd,
iconActions: [
if (_selected.isNotEmpty)
IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleConfirm,
tooltip: appLocalizations.confirm,
),
],
body: SizedBox(
height: ref.sheetHeight(context, 0.6),
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 20),
itemCount: presets.length,
itemBuilder: (context, index) {
final preset = presets[index];
final isSelected = _selected.contains(preset);
return ItemPositionProvider(
position: ItemPosition.get(index, presets.length),
child: DecorationListItem(
leading: GlyphIcon(preset.glyph),
title: Text(preset.label(appLocalizations)),
subtitle: _PresetSubtitle(preset),
isSelected: isSelected,
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
onPressed: () => _handleToggle(preset),
),
);
},
),
),
);
}
}
class _PresetSubtitle extends StatelessWidget {
final RulePreset preset;
const _PresetSubtitle(this.preset);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final raw in preset.rawRules)
Text(
raw,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.toJetBrainsMono.copyWith(
color: context.colorScheme.tertiary,
),
),
],
);
}
}
-3
View File
@@ -339,9 +339,6 @@ sealed class OverwriteIssue with _$OverwriteIssue {
const factory OverwriteIssue.subscriptionGroupMissingProxies(
List<String> names,
) = SubscriptionGroupMissingProxiesIssue;
const factory OverwriteIssue.providerShadowed(String name) =
ProviderShadowedIssue;
}
@freezed
+12 -86
View File
@@ -972,7 +972,7 @@ extension OverwriteIssuePatterns on OverwriteIssue {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( EmptyNameIssue value)? emptyName,TResult Function( ReservedNameIssue value)? reservedName,TResult Function( DuplicateNameIssue value)? duplicateName,TResult Function( CoreRejectedIssue value)? coreRejected,TResult Function( MissingProxiesIssue value)? missingProxies,TResult Function( MissingProvidersIssue value)? missingProviders,TResult Function( NoProxySourceIssue value)? noProxySource,TResult Function( GroupLoopIssue value)? groupLoop,TResult Function( InvalidPayloadIssue value)? invalidPayload,TResult Function( MissingRuleSetIssue value)? missingRuleSet,TResult Function( MissingSubRuleIssue value)? missingSubRule,TResult Function( MissingTargetIssue value)? missingTarget,TResult Function( SubscriptionGroupMissingProxiesIssue value)? subscriptionGroupMissingProxies,TResult Function( ProviderShadowedIssue value)? providerShadowed,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( EmptyNameIssue value)? emptyName,TResult Function( ReservedNameIssue value)? reservedName,TResult Function( DuplicateNameIssue value)? duplicateName,TResult Function( CoreRejectedIssue value)? coreRejected,TResult Function( MissingProxiesIssue value)? missingProxies,TResult Function( MissingProvidersIssue value)? missingProviders,TResult Function( NoProxySourceIssue value)? noProxySource,TResult Function( GroupLoopIssue value)? groupLoop,TResult Function( InvalidPayloadIssue value)? invalidPayload,TResult Function( MissingRuleSetIssue value)? missingRuleSet,TResult Function( MissingSubRuleIssue value)? missingSubRule,TResult Function( MissingTargetIssue value)? missingTarget,TResult Function( SubscriptionGroupMissingProxiesIssue value)? subscriptionGroupMissingProxies,required TResult orElse(),}){
final _that = this;
switch (_that) {
case EmptyNameIssue() when emptyName != null:
@@ -988,8 +988,7 @@ return invalidPayload(_that);case MissingRuleSetIssue() when missingRuleSet != n
return missingRuleSet(_that);case MissingSubRuleIssue() when missingSubRule != null:
return missingSubRule(_that);case MissingTargetIssue() when missingTarget != null:
return missingTarget(_that);case SubscriptionGroupMissingProxiesIssue() when subscriptionGroupMissingProxies != null:
return subscriptionGroupMissingProxies(_that);case ProviderShadowedIssue() when providerShadowed != null:
return providerShadowed(_that);case _:
return subscriptionGroupMissingProxies(_that);case _:
return orElse();
}
@@ -1007,7 +1006,7 @@ return providerShadowed(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( EmptyNameIssue value) emptyName,required TResult Function( ReservedNameIssue value) reservedName,required TResult Function( DuplicateNameIssue value) duplicateName,required TResult Function( CoreRejectedIssue value) coreRejected,required TResult Function( MissingProxiesIssue value) missingProxies,required TResult Function( MissingProvidersIssue value) missingProviders,required TResult Function( NoProxySourceIssue value) noProxySource,required TResult Function( GroupLoopIssue value) groupLoop,required TResult Function( InvalidPayloadIssue value) invalidPayload,required TResult Function( MissingRuleSetIssue value) missingRuleSet,required TResult Function( MissingSubRuleIssue value) missingSubRule,required TResult Function( MissingTargetIssue value) missingTarget,required TResult Function( SubscriptionGroupMissingProxiesIssue value) subscriptionGroupMissingProxies,required TResult Function( ProviderShadowedIssue value) providerShadowed,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( EmptyNameIssue value) emptyName,required TResult Function( ReservedNameIssue value) reservedName,required TResult Function( DuplicateNameIssue value) duplicateName,required TResult Function( CoreRejectedIssue value) coreRejected,required TResult Function( MissingProxiesIssue value) missingProxies,required TResult Function( MissingProvidersIssue value) missingProviders,required TResult Function( NoProxySourceIssue value) noProxySource,required TResult Function( GroupLoopIssue value) groupLoop,required TResult Function( InvalidPayloadIssue value) invalidPayload,required TResult Function( MissingRuleSetIssue value) missingRuleSet,required TResult Function( MissingSubRuleIssue value) missingSubRule,required TResult Function( MissingTargetIssue value) missingTarget,required TResult Function( SubscriptionGroupMissingProxiesIssue value) subscriptionGroupMissingProxies,}){
final _that = this;
switch (_that) {
case EmptyNameIssue():
@@ -1023,8 +1022,7 @@ return invalidPayload(_that);case MissingRuleSetIssue():
return missingRuleSet(_that);case MissingSubRuleIssue():
return missingSubRule(_that);case MissingTargetIssue():
return missingTarget(_that);case SubscriptionGroupMissingProxiesIssue():
return subscriptionGroupMissingProxies(_that);case ProviderShadowedIssue():
return providerShadowed(_that);}
return subscriptionGroupMissingProxies(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -1038,7 +1036,7 @@ return providerShadowed(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( EmptyNameIssue value)? emptyName,TResult? Function( ReservedNameIssue value)? reservedName,TResult? Function( DuplicateNameIssue value)? duplicateName,TResult? Function( CoreRejectedIssue value)? coreRejected,TResult? Function( MissingProxiesIssue value)? missingProxies,TResult? Function( MissingProvidersIssue value)? missingProviders,TResult? Function( NoProxySourceIssue value)? noProxySource,TResult? Function( GroupLoopIssue value)? groupLoop,TResult? Function( InvalidPayloadIssue value)? invalidPayload,TResult? Function( MissingRuleSetIssue value)? missingRuleSet,TResult? Function( MissingSubRuleIssue value)? missingSubRule,TResult? Function( MissingTargetIssue value)? missingTarget,TResult? Function( SubscriptionGroupMissingProxiesIssue value)? subscriptionGroupMissingProxies,TResult? Function( ProviderShadowedIssue value)? providerShadowed,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( EmptyNameIssue value)? emptyName,TResult? Function( ReservedNameIssue value)? reservedName,TResult? Function( DuplicateNameIssue value)? duplicateName,TResult? Function( CoreRejectedIssue value)? coreRejected,TResult? Function( MissingProxiesIssue value)? missingProxies,TResult? Function( MissingProvidersIssue value)? missingProviders,TResult? Function( NoProxySourceIssue value)? noProxySource,TResult? Function( GroupLoopIssue value)? groupLoop,TResult? Function( InvalidPayloadIssue value)? invalidPayload,TResult? Function( MissingRuleSetIssue value)? missingRuleSet,TResult? Function( MissingSubRuleIssue value)? missingSubRule,TResult? Function( MissingTargetIssue value)? missingTarget,TResult? Function( SubscriptionGroupMissingProxiesIssue value)? subscriptionGroupMissingProxies,}){
final _that = this;
switch (_that) {
case EmptyNameIssue() when emptyName != null:
@@ -1054,8 +1052,7 @@ return invalidPayload(_that);case MissingRuleSetIssue() when missingRuleSet != n
return missingRuleSet(_that);case MissingSubRuleIssue() when missingSubRule != null:
return missingSubRule(_that);case MissingTargetIssue() when missingTarget != null:
return missingTarget(_that);case SubscriptionGroupMissingProxiesIssue() when subscriptionGroupMissingProxies != null:
return subscriptionGroupMissingProxies(_that);case ProviderShadowedIssue() when providerShadowed != null:
return providerShadowed(_that);case _:
return subscriptionGroupMissingProxies(_that);case _:
return null;
}
@@ -1072,7 +1069,7 @@ return providerShadowed(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? emptyName,TResult Function( String name)? reservedName,TResult Function( String name)? duplicateName,TResult Function( String message)? coreRejected,TResult Function( List<String> names)? missingProxies,TResult Function( List<String> names)? missingProviders,TResult Function()? noProxySource,TResult Function( List<String> names)? groupLoop,TResult Function( RulePayloadError error)? invalidPayload,TResult Function( String name)? missingRuleSet,TResult Function( String name)? missingSubRule,TResult Function( String name)? missingTarget,TResult Function( List<String> names)? subscriptionGroupMissingProxies,TResult Function( String name)? providerShadowed,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? emptyName,TResult Function( String name)? reservedName,TResult Function( String name)? duplicateName,TResult Function( String message)? coreRejected,TResult Function( List<String> names)? missingProxies,TResult Function( List<String> names)? missingProviders,TResult Function()? noProxySource,TResult Function( List<String> names)? groupLoop,TResult Function( RulePayloadError error)? invalidPayload,TResult Function( String name)? missingRuleSet,TResult Function( String name)? missingSubRule,TResult Function( String name)? missingTarget,TResult Function( List<String> names)? subscriptionGroupMissingProxies,required TResult orElse(),}) {final _that = this;
switch (_that) {
case EmptyNameIssue() when emptyName != null:
return emptyName();case ReservedNameIssue() when reservedName != null:
@@ -1087,8 +1084,7 @@ return invalidPayload(_that.error);case MissingRuleSetIssue() when missingRuleSe
return missingRuleSet(_that.name);case MissingSubRuleIssue() when missingSubRule != null:
return missingSubRule(_that.name);case MissingTargetIssue() when missingTarget != null:
return missingTarget(_that.name);case SubscriptionGroupMissingProxiesIssue() when subscriptionGroupMissingProxies != null:
return subscriptionGroupMissingProxies(_that.names);case ProviderShadowedIssue() when providerShadowed != null:
return providerShadowed(_that.name);case _:
return subscriptionGroupMissingProxies(_that.names);case _:
return orElse();
}
@@ -1106,7 +1102,7 @@ return providerShadowed(_that.name);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() emptyName,required TResult Function( String name) reservedName,required TResult Function( String name) duplicateName,required TResult Function( String message) coreRejected,required TResult Function( List<String> names) missingProxies,required TResult Function( List<String> names) missingProviders,required TResult Function() noProxySource,required TResult Function( List<String> names) groupLoop,required TResult Function( RulePayloadError error) invalidPayload,required TResult Function( String name) missingRuleSet,required TResult Function( String name) missingSubRule,required TResult Function( String name) missingTarget,required TResult Function( List<String> names) subscriptionGroupMissingProxies,required TResult Function( String name) providerShadowed,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() emptyName,required TResult Function( String name) reservedName,required TResult Function( String name) duplicateName,required TResult Function( String message) coreRejected,required TResult Function( List<String> names) missingProxies,required TResult Function( List<String> names) missingProviders,required TResult Function() noProxySource,required TResult Function( List<String> names) groupLoop,required TResult Function( RulePayloadError error) invalidPayload,required TResult Function( String name) missingRuleSet,required TResult Function( String name) missingSubRule,required TResult Function( String name) missingTarget,required TResult Function( List<String> names) subscriptionGroupMissingProxies,}) {final _that = this;
switch (_that) {
case EmptyNameIssue():
return emptyName();case ReservedNameIssue():
@@ -1121,8 +1117,7 @@ return invalidPayload(_that.error);case MissingRuleSetIssue():
return missingRuleSet(_that.name);case MissingSubRuleIssue():
return missingSubRule(_that.name);case MissingTargetIssue():
return missingTarget(_that.name);case SubscriptionGroupMissingProxiesIssue():
return subscriptionGroupMissingProxies(_that.names);case ProviderShadowedIssue():
return providerShadowed(_that.name);}
return subscriptionGroupMissingProxies(_that.names);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -1136,7 +1131,7 @@ return providerShadowed(_that.name);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? emptyName,TResult? Function( String name)? reservedName,TResult? Function( String name)? duplicateName,TResult? Function( String message)? coreRejected,TResult? Function( List<String> names)? missingProxies,TResult? Function( List<String> names)? missingProviders,TResult? Function()? noProxySource,TResult? Function( List<String> names)? groupLoop,TResult? Function( RulePayloadError error)? invalidPayload,TResult? Function( String name)? missingRuleSet,TResult? Function( String name)? missingSubRule,TResult? Function( String name)? missingTarget,TResult? Function( List<String> names)? subscriptionGroupMissingProxies,TResult? Function( String name)? providerShadowed,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? emptyName,TResult? Function( String name)? reservedName,TResult? Function( String name)? duplicateName,TResult? Function( String message)? coreRejected,TResult? Function( List<String> names)? missingProxies,TResult? Function( List<String> names)? missingProviders,TResult? Function()? noProxySource,TResult? Function( List<String> names)? groupLoop,TResult? Function( RulePayloadError error)? invalidPayload,TResult? Function( String name)? missingRuleSet,TResult? Function( String name)? missingSubRule,TResult? Function( String name)? missingTarget,TResult? Function( List<String> names)? subscriptionGroupMissingProxies,}) {final _that = this;
switch (_that) {
case EmptyNameIssue() when emptyName != null:
return emptyName();case ReservedNameIssue() when reservedName != null:
@@ -1151,8 +1146,7 @@ return invalidPayload(_that.error);case MissingRuleSetIssue() when missingRuleSe
return missingRuleSet(_that.name);case MissingSubRuleIssue() when missingSubRule != null:
return missingSubRule(_that.name);case MissingTargetIssue() when missingTarget != null:
return missingTarget(_that.name);case SubscriptionGroupMissingProxiesIssue() when subscriptionGroupMissingProxies != null:
return subscriptionGroupMissingProxies(_that.names);case ProviderShadowedIssue() when providerShadowed != null:
return providerShadowed(_that.name);case _:
return subscriptionGroupMissingProxies(_that.names);case _:
return null;
}
@@ -1994,74 +1988,6 @@ as List<String>,
}
}
/// @nodoc
class ProviderShadowedIssue implements OverwriteIssue {
const ProviderShadowedIssue(this.name);
final String name;
/// Create a copy of OverwriteIssue
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProviderShadowedIssueCopyWith<ProviderShadowedIssue> get copyWith => _$ProviderShadowedIssueCopyWithImpl<ProviderShadowedIssue>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProviderShadowedIssue&&(identical(other.name, name) || other.name == name));
}
@override
int get hashCode {
return Object.hash(runtimeType,name);
}
@override
String toString() {
return 'OverwriteIssue.providerShadowed(name: $name)';
}
}
/// @nodoc
abstract mixin class $ProviderShadowedIssueCopyWith<$Res> implements $OverwriteIssueCopyWith<$Res> {
factory $ProviderShadowedIssueCopyWith(ProviderShadowedIssue value, $Res Function(ProviderShadowedIssue) _then) = _$ProviderShadowedIssueCopyWithImpl;
@useResult
$Res call({
String name
});
}
/// @nodoc
class _$ProviderShadowedIssueCopyWithImpl<$Res>
implements $ProviderShadowedIssueCopyWith<$Res> {
_$ProviderShadowedIssueCopyWithImpl(this._self, this._then);
final ProviderShadowedIssue _self;
final $Res Function(ProviderShadowedIssue) _then;
/// Create a copy of OverwriteIssue
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? name = null,}) {
return _then(ProviderShadowedIssue(
null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
+12 -16
View File
@@ -120,22 +120,18 @@ extension ProfilesExt on List<Profile> {
return index == -1 ? null : this[index];
}
String _getLabel(String label, int id) {
final realLabel = label.takeFirstValid([id.toString()]);
final hasDup =
indexWhere(
(element) => element.label == realLabel && element.id != id,
) !=
-1;
if (hasDup) {
return _getLabel(getOverwriteLabel(realLabel), id);
} else {
return realLabel;
}
}
Profile optimizeLabel(Profile profile) {
return profile.copyWith(label: _getLabel(profile.label, profile.id));
/// A profile doubles as a proxy provider, so its label must also stay
/// clear of the app-level ones.
Profile optimizeLabel(Profile profile, {Set<String> reserved = const {}}) {
return profile.copyWith(
label: uniqueLabelFor(
profile.label,
fallback: profile.id.toString(),
taken: (label) =>
reserved.contains(label) ||
any((item) => item.label == label && item.id != profile.id),
),
);
}
}
+152
View File
@@ -0,0 +1,152 @@
part of '../action.dart';
@Riverpod(keepAlive: true)
class ClashProvidersAction extends _$ClashProvidersAction {
@override
void build() {}
/// Returns who blocks a rename: users whose subscription has the new label.
Future<List<Profile>> putProvider(
ClashProvider provider, {
ClashProvider? previous,
List<int>? content,
}) async {
var renameIn = const <Profile>[];
if (previous != null && previous.label != provider.label) {
renameIn = await profilesUsing(previous);
final conflicts = await profilesDefining(
provider.kind,
provider.label,
renameIn,
);
if (conflicts.isNotEmpty) {
return conflicts;
}
}
if (content != null) {
await provider.saveContent(content);
}
ref
.read(clashProvidersProvider(provider.kind).notifier)
.put(provider, renameIn: renameIn.map((profile) => profile.id));
if (previous != null && previous.fileName != provider.fileName) {
unawaited(_clearCache(previous));
}
unawaited(
_applyIfReferenced(provider.kind, {provider.label, ?previous?.label}),
);
return const [];
}
Future<List<Profile>> delProvider(ClashProvider provider) async {
final users = await profilesUsing(provider);
if (users.isEmpty) {
ref.read(clashProvidersProvider(provider.kind).notifier).del(provider.id);
unawaited(_clearCache(provider));
}
return users;
}
/// Any overwrite counts: a profile back on custom uses its groups again.
Future<List<Profile>> profilesUsing(ClashProvider provider) async {
final shadowedByProfile =
provider.kind == ProviderKind.proxy &&
ref.read(profileProvidersProvider).containsKey(provider.label);
if (shadowedByProfile) {
return const [];
}
return profilesReferencing(provider.kind, provider.label);
}
/// A name a profile's own subscription defines resolves there, so only the
/// rest reach a profile or an app-level provider of that name.
Future<List<Profile>> profilesReferencing(
ProviderKind kind,
String name,
) async {
final ids = switch (kind) {
ProviderKind.proxy => await database.proxyGroupsDao.profileIdsUsing(name),
ProviderKind.rule =>
await database.rulesDao.profileIdsUsingCustomRuleProvider(name),
};
final candidates = [
for (final profile in ref.read(profilesProvider))
if (ids.contains(profile.id)) profile,
];
final defining = await profilesDefining(kind, name, candidates);
return [
for (final profile in candidates)
if (!defining.contains(profile)) profile,
];
}
Future<List<Profile>> profilesDefining(
ProviderKind kind,
String name,
Iterable<Profile> profiles,
) async {
final defining = <Profile>[];
for (final profile in profiles) {
if ((await _subscriptionProviders(profile.id, kind)).contains(name)) {
defining.add(profile);
}
}
return defining;
}
/// Unreadable counts as defining nothing, keeping deletes on the safe side.
Future<Set<String>> _subscriptionProviders(
int profileId,
ProviderKind kind,
) async {
final Map<String, dynamic> config;
try {
config = await ref.read(coreHandlerProvider).getConfig(profileId);
} catch (e) {
commonPrint.log(
'read providers of profile $profileId: $e',
logLevel: LogLevel.warning,
);
return const {};
}
final section =
config[switch (kind) {
ProviderKind.proxy => 'proxy-providers',
ProviderKind.rule => 'rule-providers',
}];
return section is Map ? {for (final key in section.keys) '$key'} : const {};
}
Future<void> _clearCache(ClashProvider provider) async {
await File(await provider.path).safeDelete();
}
Future<bool> _isReferenced(
int profileId,
ProviderKind kind,
Set<String> labels,
) async {
return switch (kind) {
ProviderKind.proxy =>
(await database.proxyGroupsDao.query(profileId).get()).any(
(group) => group.use?.any(labels.contains) ?? false,
),
ProviderKind.rule =>
(await database.rulesDao.queryProfileCustomRules(profileId).get()).any(
(rule) =>
rule.ruleAction == RuleAction.RULE_SET &&
labels.contains(rule.ruleProvider),
),
};
}
Future<void> _applyIfReferenced(ProviderKind kind, Set<String> labels) async {
final profile = ref.read(currentProfileProvider);
if (profile == null ||
profile.overwriteType != OverwriteType.custom ||
!await _isReferenced(profile.id, kind, labels)) {
return;
}
ref.read(setupActionProvider.notifier).applyProfileDebounce(silence: true);
}
}
+36 -5
View File
@@ -56,28 +56,58 @@ class ProfilesAction extends _$ProfilesAction {
}
}
void putProfile(Profile profile) {
ref.read(profilesProvider.notifier).put(profile);
void putProfile(Profile profile, {Iterable<int> renameIn = const []}) {
ref.read(profilesProvider.notifier).put(profile, renameIn: renameIn);
if (ref.read(currentProfileIdProvider) != null) return;
ref.read(currentProfileIdProvider.notifier).value = profile.id;
}
Future<List<Profile>> providerUsers(Profile profile) async {
if (!feature.customProviders) {
return const [];
}
final users = await ref
.read(clashProvidersActionProvider.notifier)
.profilesReferencing(ProviderKind.proxy, profile.realLabel);
return [
for (final user in users)
if (user.id != profile.id) user,
];
}
/// [conflicts] would move to their subscription's provider of the new name.
Future<({List<Profile> renameIn, List<Profile> conflicts})> providerRename(
Profile previous,
Profile next,
) async {
final label = ref.read(profilesProvider.notifier).labeled(next).realLabel;
if (!feature.customProviders || label == previous.realLabel) {
return (renameIn: const <Profile>[], conflicts: const <Profile>[]);
}
final renameIn = await providerUsers(previous);
final conflicts = await ref
.read(clashProvidersActionProvider.notifier)
.profilesDefining(ProviderKind.proxy, label, renameIn);
return (renameIn: renameIn, conflicts: conflicts);
}
Future<void> updateProfiles() async {
for (final profile in ref.read(profilesProvider)) {
if (profile.type == ProfileType.file) continue;
await updateProfile(profile);
await updateProfile(profile, showLoading: true);
}
}
Future<void> updateProfile(
Profile profile, {
bool showLoading = false,
Iterable<int> renameIn = const [],
}) async {
final operation = showLoading
? ref.read(updatingKeysProvider.notifier).start(profile.updatingKey)
: null;
try {
ref.read(profilesProvider.notifier).put(profile);
ref.read(profilesProvider.notifier).put(profile, renameIn: renameIn);
final newProfile = await profile.update(
validate: (path) => _core.validateConfig(path),
);
@@ -116,7 +146,7 @@ class ProfilesAction extends _$ProfilesAction {
}
}
Future<void> addProfileFormURL(String url) async {
Future<void> addProfileFormURL(String url, {String? label}) async {
if (globalState.navigatorKey.currentState?.canPop() ?? false) {
globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
@@ -125,6 +155,7 @@ class ProfilesAction extends _$ProfilesAction {
tag: LoadingTag.profiles,
() async {
return Profile.normal(
label: label,
url: url,
).update(validate: (path) => _core.validateConfig(path));
},
+31
View File
@@ -0,0 +1,31 @@
part of '../action.dart';
@Riverpod(keepAlive: true)
class ScriptsAction extends _$ScriptsAction {
@override
void build() {}
void putScript(Script script) {
ref.read(scriptsProvider.notifier).put(script);
_applyIfInUse(script.id);
}
Future<void> updateScript(Script script) async {
final updatingKeys = ref.read(updatingKeysProvider.notifier);
final operation = updatingKeys.start(script.updatingKey);
try {
putScript(await script.update());
} finally {
updatingKeys.stop(script.updatingKey, operation);
}
}
void _applyIfInUse(int scriptId) {
final profile = ref.read(currentProfileProvider);
if (profile?.overwriteType != OverwriteType.script ||
profile?.scriptId != scriptId) {
return;
}
ref.read(setupActionProvider.notifier).applyProfileDebounce(silence: true);
}
}
+4
View File
@@ -11,9 +11,13 @@ class StoreAction extends _$StoreAction {
final profileIds = ref.read(profilesProvider).map((item) => item.id);
final scripts = await ref.read(scriptsProvider.future);
final scriptIds = scripts.map((item) => item.id);
final providerFileNames = await database.clashProvidersDao
.fileNames()
.get();
final pathsToDelete = await shakingProfileTask((
profileIds: profileIds,
scriptIds: scriptIds,
providerFileNames: providerFileNames,
));
await Future.wait(pathsToDelete.map(safeDeletePath));
}
+31 -9
View File
@@ -99,15 +99,18 @@ Stream<int> customProxiesCount(Ref ref, int profileId) {
class Profiles extends _$Profiles {
@override
List<Profile> build() {
ref.listen(clashProvidersProvider(ProviderKind.proxy), (_, _) {});
if (feature.customProviders) {
ref.listen(clashProvidersProvider(ProviderKind.proxy), (_, _) {});
}
return ref.watch(profilesStreamProvider).value ?? [];
}
Set<String> get _reservedLabels => {
for (final provider
in ref.read(clashProvidersProvider(ProviderKind.proxy)).value ??
const <ClashProvider>[])
provider.label,
if (feature.customProviders)
for (final provider
in ref.read(clashProvidersProvider(ProviderKind.proxy)).value ??
const <ClashProvider>[])
provider.label,
};
void _optimistic(List<Profile> next, FutureOr<void> Function() action) {
@@ -129,11 +132,28 @@ class Profiles extends _$Profiles {
);
}
void put(Profile profile) {
final newProfile = state.optimizeLabel(profile, reserved: _reservedLabels);
Profile labeled(Profile profile) =>
state.optimizeLabel(profile, reserved: _reservedLabels);
void put(Profile profile, {Iterable<int> renameIn = const []}) {
final newProfile = labeled(profile);
final previousLabel = state.getProfile(profile.id)?.realLabel;
final renamedFrom =
renameIn.isNotEmpty && previousLabel != newProfile.realLabel
? previousLabel
: null;
_optimistic(
state.copyAndPut(newProfile, (item) => item.id == newProfile.id),
() => database.profiles.put(newProfile.toCompanion()),
renamedFrom == null
? () => database.profiles.put(newProfile.toCompanion())
: () => database.transaction(() async {
await database.proxyGroupsDao.renameUse(
renameIn,
oldName: renamedFrom,
newName: newProfile.realLabel,
);
await database.profiles.put(newProfile.toCompanion());
}),
);
}
@@ -238,7 +258,7 @@ class ClashProviders extends _$ClashProviders
@override
List<ClashProvider> get value => state.value ?? [];
void put(ClashProvider provider) {
void put(ClashProvider provider, {Iterable<int> renameIn = const []}) {
final next = List<ClashProvider>.from(value);
final index = next.indexWhere((item) => item.id == provider.id);
final renamedFrom = index != -1 && next[index].label != provider.label
@@ -256,11 +276,13 @@ class ClashProviders extends _$ClashProviders
switch (provider.kind) {
case ProviderKind.proxy:
await database.proxyGroupsDao.renameUse(
renameIn,
oldName: renamedFrom,
newName: provider.label,
);
case ProviderKind.rule:
await database.rulesDao.renameCustomRuleProvider(
renameIn,
oldName: renamedFrom,
newName: provider.label,
);
+2 -2
View File
@@ -362,7 +362,7 @@ final class ProfilesProvider
}
}
String _$profilesHash() => r'e36801d7209492d633d0ef0114240088905ea5ae';
String _$profilesHash() => r'30c2b73ecd127bd5c483e72ba9d6e3bb88c3647f';
abstract class _$Profiles extends $Notifier<List<Profile>> {
List<Profile> build();
@@ -467,7 +467,7 @@ final class ClashProvidersProvider
}
}
String _$clashProvidersHash() => r'3628f6bbed3b0e4dba71bcc126ea72f4192b860f';
String _$clashProvidersHash() => r'e2cd40bb0c849d300fdbc0243cc552349cf2b571';
final class ClashProvidersFamily extends $Family
with
+341 -26
View File
@@ -1,5 +1,67 @@
part of '../state.dart';
@riverpod
Set<String> appProviderLabels(Ref ref, ProviderKind kind) {
if (!feature.customProviders) {
return const {};
}
return ref
.watch(
clashProvidersProvider(kind).select(
(state) => SelectValue({
for (final provider in state.value ?? const <ClashProvider>[])
provider.label,
}),
),
)
.value;
}
/// Selectable in every profile, so valid next to the names its own config has.
@riverpod
Set<String> appProviderNames(Ref ref, ProviderKind kind) {
if (!feature.customProviders) {
return const {};
}
final labels = ref.watch(appProviderLabelsProvider(kind));
if (kind == ProviderKind.rule) {
return labels;
}
return {...ref.watch(profileProvidersProvider).keys, ...labels};
}
/// A name the subscription defines is its own, and a profile beats an app one.
@riverpod
Map<String, ProviderSource> providerSources(
Ref ref,
int profileId,
ProviderKind kind,
) {
if (!feature.customProviders) {
return const {};
}
final subscription = ref
.watch(
clashConfigProvider(profileId).select(
(state) => SelectValue(switch (kind) {
ProviderKind.proxy => state.value?.proxyProviders,
ProviderKind.rule => state.value?.ruleProviders,
}),
),
)
.value;
final profiles = kind == ProviderKind.proxy
? ref.watch(profileProvidersProvider).keys
: const <String>[];
return {
for (final name in ref.watch(appProviderLabelsProvider(kind)))
name: ProviderSource.app,
for (final name in profiles) name: ProviderSource.profile,
for (final name in subscription ?? const <String>[])
name: ProviderSource.subscription,
};
}
@riverpod
CustomOverwriteDate customOverwriteDate(Ref ref, int profileId) {
final overwrite = ref.watch(
@@ -10,6 +72,7 @@ CustomOverwriteDate customOverwriteDate(Ref ref, int profileId) {
proxies: clashConfig?.proxies ?? const [],
subRules: clashConfig?.subRules ?? const [],
proxyProviders: clashConfig?.proxyProviders ?? const [],
ruleProviders: clashConfig?.ruleProviders ?? const [],
);
}),
);
@@ -20,12 +83,28 @@ CustomOverwriteDate customOverwriteDate(Ref ref, int profileId) {
}),
)
.value;
final customProxies = feature.customProxies
? ref
.watch(
customProxiesProvider(profileId).select((state) {
return SelectValue(state.value);
}),
)
.value
: const <CustomProxy>[];
final proxyGroups = groups ?? const <ProxyGroup>[];
final proxyNames = <String>[];
final proxyTypes = <String, String>{};
for (final proxy in overwrite.proxies) {
proxyNames.add(proxy.name);
proxyTypes[proxy.name] = proxy.type;
if (customProxies?.isNotEmpty == true) {
for (final proxy in customProxies!) {
proxyNames.add(proxy.name);
proxyTypes[proxy.name] = proxy.type;
}
} else {
for (final proxy in overwrite.proxies) {
proxyNames.add(proxy.name);
proxyTypes[proxy.name] = proxy.type;
}
}
final ruleTargets = {
...RuleTarget.baseTargets,
@@ -33,8 +112,15 @@ CustomOverwriteDate customOverwriteDate(Ref ref, int profileId) {
...proxyGroups.map((item) => item.name),
};
return CustomOverwriteDate(
loaded: overwrite.loaded && groups != null,
proxyProviders: overwrite.proxyProviders.toSet(),
loaded: overwrite.loaded && groups != null && customProxies != null,
proxyProviders: {
...overwrite.proxyProviders,
...ref.watch(appProviderNamesProvider(ProviderKind.proxy)),
},
ruleProviders: {
...overwrite.ruleProviders,
...ref.watch(appProviderNamesProvider(ProviderKind.rule)),
},
proxyNames: proxyNames,
proxyTypes: proxyTypes,
proxyGroups: proxyGroups,
@@ -68,43 +154,261 @@ bool customOverwriteProxyProviderIsValid(
}
@riverpod
bool customOverwriteUseIsValid(Ref ref, int profileId, List<String> use) {
final valid = ref.watch(
customOverwriteDateProvider(
profileId,
).select((state) => !state.loaded || state.proxyProviders.containsAll(use)),
);
return valid;
}
@riverpod
bool customOverwriteProxiesIsValid(
bool customOverwriteRuleProviderIsValid(
Ref ref,
int profileId,
List<String> proxies,
String? providerName,
) {
final valid = ref.watch(
customOverwriteDateProvider(profileId).select(
(state) => !state.loaded || state.ruleTargets.containsAll(proxies),
(state) => !state.loaded || state.ruleProviders.contains(providerName),
),
);
return valid;
}
@riverpod
Set<int> invalidProxyGroupIds(Ref ref, int profileId) {
final overwrite = ref.watch(customOverwriteDateProvider(profileId));
if (!overwrite.loaded) {
/// Names the core registers before any proxy is parsed, so neither a proxy nor
/// a group can take one.
const reservedProxyNames = {
'DIRECT',
'REJECT',
'REJECT-DROP',
'COMPATIBLE',
'PASS',
'PASS-RULE',
};
Duration? _noRetry(int retryCount, Object error) => null;
@Riverpod(retry: _noRetry)
Future<Map<int, String>> customProxyCoreErrors(Ref ref, int profileId) async {
final proxies = ref
.watch(
customProxiesProvider(
profileId,
).select((state) => SelectValue(state.value ?? const <CustomProxy>[])),
)
.value;
if (proxies.isEmpty) {
return const {};
}
final results = await ref.read(coreHandlerProvider).validateProxies([
for (final proxy in proxies) proxy.definition,
]);
return {
for (final proxyGroup in overwrite.proxyGroups)
if (!overwrite.ruleTargets.containsAll(proxyGroup.proxies ?? const []) ||
!overwrite.proxyProviders.containsAll(proxyGroup.use ?? const []))
proxyGroup.id,
for (final (index, message) in results.indexed)
if (message.isNotEmpty) proxies[index].id: message,
};
}
@riverpod
List<ProxyGroup> effectiveProxyGroups(Ref ref, int profileId) {
final groups = ref.watch(
customOverwriteDateProvider(profileId).select((state) => state.proxyGroups),
);
if (groups.isNotEmpty) {
return groups;
}
return ref
.watch(
clashConfigProvider(profileId).select(
(state) =>
SelectValue(state.value?.proxyGroups ?? const <ProxyGroup>[]),
),
)
.value;
}
List<OverwriteIssue> customProxyIssues(
CustomProxy proxy, {
required Iterable<CustomProxy> proxies,
required Iterable<ProxyGroup> proxyGroups,
String? coreError,
}) {
final name = proxy.name;
return [
if (name.isEmpty)
const OverwriteIssue.emptyName()
else if (reservedProxyNames.contains(name))
OverwriteIssue.reservedName(name)
else if (proxies.any((item) => item.id != proxy.id && item.name == name) ||
proxyGroups.any((item) => item.name == name))
OverwriteIssue.duplicateName(name),
if (coreError != null) OverwriteIssue.coreRejected(coreError),
];
}
/// The groups [group] reaches back to itself through, or null when it is not
/// on a loop; mihomo refuses the whole config for one.
List<String>? proxyGroupLoop(ProxyGroup group, Iterable<ProxyGroup> groups) {
final members = {
for (final item in groups)
if (item.id != group.id) item.name: item.proxies ?? const <String>[],
group.name: group.proxies ?? const <String>[],
};
final visited = <String>{};
List<String>? walk(String name, List<String> path) {
for (final next in members[name] ?? const <String>[]) {
if (next == group.name) {
return [...path, next];
}
if (!members.containsKey(next) || !visited.add(next)) {
continue;
}
final loop = walk(next, [...path, next]);
if (loop != null) {
return loop;
}
}
return null;
}
return walk(group.name, [group.name]);
}
List<OverwriteIssue> proxyGroupIssues(
ProxyGroup group,
CustomOverwriteDate overwrite,
) {
final name = group.name;
final proxies = group.proxies ?? const <String>[];
final use = group.use ?? const <String>[];
final hasSource =
proxies.isNotEmpty ||
use.isNotEmpty ||
group.includeAll == true ||
group.includeAllProxies == true ||
group.includeAllProviders == true;
final missingProxies = overwrite.loaded
? proxies.where((item) => !overwrite.ruleTargets.contains(item)).toList()
: const <String>[];
final missingProviders = overwrite.loaded
? use.where((item) => !overwrite.proxyProviders.contains(item)).toList()
: const <String>[];
final loop = proxyGroupLoop(group, overwrite.proxyGroups);
return [
if (name.isEmpty)
const OverwriteIssue.emptyName()
else if (reservedProxyNames.contains(name))
OverwriteIssue.reservedName(name)
else if (overwrite.proxyNames.contains(name) ||
overwrite.proxyGroups.any(
(item) => item.id != group.id && item.name == name,
))
OverwriteIssue.duplicateName(name),
if (!hasSource) const OverwriteIssue.noProxySource(),
if (missingProxies.isNotEmpty)
OverwriteIssue.missingProxies(missingProxies),
if (missingProviders.isNotEmpty)
OverwriteIssue.missingProviders(missingProviders),
if (loop != null) OverwriteIssue.groupLoop(loop),
];
}
List<OverwriteIssue> customRuleIssues(
Rule rule,
CustomOverwriteDate overwrite,
) {
final payloadError = rule.payloadError;
if (payloadError != null) {
return [OverwriteIssue.invalidPayload(payloadError)];
}
if (!overwrite.loaded) {
return const [];
}
final ruleProvider = rule.ruleProvider;
if (rule.ruleAction == RuleAction.RULE_SET &&
!overwrite.ruleProviders.contains(ruleProvider)) {
return [OverwriteIssue.missingRuleSet(ruleProvider ?? '')];
}
final target = rule.realTarget;
if (rule.ruleAction == RuleAction.SUB_RULE) {
return overwrite.subRules.contains(target)
? const []
: [OverwriteIssue.missingSubRule(target ?? '')];
}
return overwrite.ruleTargets.contains(target)
? const []
: [OverwriteIssue.missingTarget(target ?? '')];
}
@riverpod
CustomOverwriteIssues customOverwriteIssues(Ref ref, int profileId) {
final overwrite = ref.watch(customOverwriteDateProvider(profileId));
final proxies = feature.customProxies
? ref.watch(customProxiesProvider(profileId)).value ??
const <CustomProxy>[]
: const <CustomProxy>[];
final coreErrors = feature.customProxies
? ref.watch(customProxyCoreErrorsProvider(profileId)).value ??
const <int, String>{}
: const <int, String>{};
final rules =
ref.watch(profileCustomRulesProvider(profileId)).value ?? const <Rule>[];
final proxyGroups = ref.watch(effectiveProxyGroupsProvider(profileId));
final subscriptionGroups = proxies.isNotEmpty && overwrite.proxyGroups.isEmpty
? proxyGroups
: const <ProxyGroup>[];
Map<int, List<OverwriteIssue>> collect<T>(
Iterable<T> items,
int Function(T item) idOf,
List<OverwriteIssue> Function(T item) issuesOf,
) {
return {
for (final item in items)
if (issuesOf(item) case final issues when issues.isNotEmpty)
idOf(item): issues,
};
}
return CustomOverwriteIssues(
proxies: collect(
proxies,
(item) => item.id,
(item) => customProxyIssues(
item,
proxies: proxies,
proxyGroups: proxyGroups,
coreError: coreErrors[item.id],
),
),
proxyGroups: collect(
overwrite.proxyGroups,
(item) => item.id,
(item) => proxyGroupIssues(item, overwrite),
),
rules: collect(
rules,
(item) => item.id,
(item) => customRuleIssues(item, overwrite),
),
general: [
?subscriptionGroupsIssue(
subscriptionGroups,
targets: overwrite.ruleTargets,
),
],
);
}
/// Custom proxies replace the profile's own, while an empty custom group list
/// keeps the profile's groups, which may still name the proxies left out.
OverwriteIssue? subscriptionGroupsIssue(
Iterable<ProxyGroup> groups, {
required Set<String> targets,
}) {
final groupNames = {for (final group in groups) group.name};
final missing = {
for (final group in groups)
for (final name in group.proxies ?? const <String>[])
if (!targets.contains(name) && !groupNames.contains(name)) name,
};
if (missing.isEmpty) {
return null;
}
return OverwriteIssue.subscriptionGroupMissingProxies(missing.toList());
}
@Riverpod(name: 'proxyGroupProvider')
class ProxyGroupProvider extends _$ProxyGroupProvider
with AutoDisposeNotifierMixin {
@@ -114,6 +418,17 @@ class ProxyGroupProvider extends _$ProxyGroupProvider
}
}
@Riverpod(name: 'customProxyProvider')
class CustomProxyProvider extends _$CustomProxyProvider
with AutoDisposeNotifierMixin {
@override
CustomProxy build() {
throw StateError(
'customProxyProvider must be overridden before it is read',
);
}
}
@Riverpod(name: 'ruleProvider')
class RuleProvider extends _$RuleProvider with AutoDisposeNotifierMixin {
@override
+27
View File
@@ -44,10 +44,19 @@ Future<SetupState> setupState(Ref ref, int? profileId) async {
final profileLastUpdateDate = profile?.lastUpdateDate?.millisecondsSinceEpoch;
final overwriteType = profile?.overwriteType ?? OverwriteType.standard;
final dns = ref.watch(patchClashConfigProvider.select((state) => state.dns));
final dnsOverrideKeys = ref.watch(
patchClashConfigProvider.select((state) => state.dnsOverrideKeys),
);
final overrideDns = ref.watch(overrideDnsProvider);
final allProfileProviders = feature.customProviders
? ref.watch(profileProvidersProvider)
: const <String, int>{};
List<CustomProxy> customProxies = [];
List<ProxyGroup> proxyGroups = [];
List<Rule> rules = [];
List<Rule> addedRules = [];
List<ClashProvider> clashProviders = [];
Map<String, int> profileProviders = const {};
Script? script;
if (profileId != null) {
if (overwriteType == OverwriteType.standard) {
@@ -59,11 +68,21 @@ Future<SetupState> setupState(Ref ref, int? profileId) async {
} else {
rules = await database.rulesDao.queryProfileCustomRules(profileId).get();
proxyGroups = await database.proxyGroupsDao.query(profileId).get();
if (feature.customProxies) {
customProxies = await database.customProxiesDao.query(profileId).get();
}
if (feature.customProviders) {
clashProviders = await database.clashProvidersDao.queryAll().get();
profileProviders = allProfileProviders;
}
}
}
return SetupState(
clashProviders: clashProviders,
profileProviders: profileProviders,
rules: rules,
proxyGroups: proxyGroups,
customProxies: customProxies,
profileId: profileId,
profileLastUpdateDate: profileLastUpdateDate,
overwriteType: overwriteType,
@@ -71,8 +90,16 @@ Future<SetupState> setupState(Ref ref, int? profileId) async {
script: script,
overrideDns: overrideDns,
dns: dns,
dnsOverrideKeys: dnsOverrideKeys,
matchTarget: overwriteType == OverwriteType.standard
? profile?.matchTarget
: null,
);
}
/// Every profile doubles as a proxy provider another profile's groups can use.
@riverpod
Map<String, int> profileProviders(Ref ref) {
final profiles = ref.watch(profilesProvider);
return {for (final profile in profiles) profile.realLabel: profile.id};
}
+702
View File
@@ -0,0 +1,702 @@
import 'dart:async';
import 'dart:convert';
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/pages/editor.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ClashProvidersView extends ConsumerStatefulWidget {
const ClashProvidersView({super.key, required this.kind});
final ProviderKind kind;
@override
ConsumerState<ClashProvidersView> createState() => _ClashProvidersViewState();
}
class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
late final ClashProvidersAction _providersAction;
ProviderKind get _kind => widget.kind;
@override
void initState() {
super.initState();
_providersAction = ref.read(clashProvidersActionProvider.notifier);
}
String get _template => switch (_kind) {
ProviderKind.proxy => proxyProviderTemplate,
ProviderKind.rule => ruleProviderTemplate,
};
String _sectionLabel(BuildContext context) {
final appLocalizations = context.appLocalizations;
return switch (_kind) {
ProviderKind.proxy => appLocalizations.proxyProviders,
ProviderKind.rule => appLocalizations.ruleProviders,
};
}
List<ClashProvider> get _providers =>
ref.read(clashProvidersProvider(_kind)).value ?? [];
Set<String> _reservedLabels({ClashProvider? except}) {
return {
for (final item in _providers)
if (item.id != except?.id) item.label,
if (_kind == ProviderKind.proxy)
...ref.read(profileProvidersProvider).keys,
};
}
String? _validateLabel(
String? value, {
ClashProvider? except,
bool optional = false,
}) {
final appLocalizations = context.appLocalizations;
final label = value?.trim() ?? '';
if (label.isEmpty) {
return optional ? null : appLocalizations.emptyTip(appLocalizations.name);
}
if (_reservedLabels(except: except).contains(label)) {
return appLocalizations.existsTip(appLocalizations.name);
}
return null;
}
String _uniqueLabel(String name) {
final reserved = _reservedLabels();
return uniqueLabelFor(
name,
fallback: _sectionLabel(context),
taken: reserved.contains,
);
}
Future<bool> _put(
ClashProvider provider, {
ClashProvider? previous,
List<int>? content,
}) async {
final appLocalizations = context.appLocalizations;
final conflicts = await _providersAction.putProvider(
provider,
previous: previous,
content: content,
);
if (conflicts.isEmpty) {
return true;
}
await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: appLocalizations.providerRenameShadowed(
conflicts.map((profile) => profile.realLabel).join(', '),
provider.label,
),
),
cancelable: false,
);
return false;
}
/// The core has no default for behavior and refuses the whole config without
/// one, so a new rule set names it before anything is written.
Future<ClashProvider?> _resolveBehavior(ClashProvider provider) async {
if (provider.kind != ProviderKind.rule) {
return provider;
}
final res = await _showBehaviorDialog(provider);
return res == null ? null : provider.copyWith(behavior: res);
}
Future<RuleProviderBehavior?> _showBehaviorDialog(
ClashProvider provider,
) async {
final options = provider.format.behaviors;
return dialogs.showCommonDialog<RuleProviderBehavior>(
child: OptionsDialog<RuleProviderBehavior>(
title: context.appLocalizations.behavior,
options: options,
textBuilder: (item) => item.name,
value: options.contains(provider.behavior)
? provider.behavior!
: options.first,
),
);
}
Future<void> _handleImportFromUrl() async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showNamedUrlInput(
title: appLocalizations.importUrl,
labelValidator: (value) => _validateLabel(value, optional: true),
urlValidator: _validateProviderUrl,
);
if (res == null || !mounted) {
return;
}
final fileName = res.url.urlFileName;
final label = res.label.isNotEmpty
? res.label
: _uniqueLabel(fileName.fileStem);
final provider = await _resolveBehavior(
ClashProvider.create(
kind: _kind,
label: label,
url: res.url,
).withFileFormat(fileName),
);
if (provider == null) {
return;
}
await _put(provider);
}
Future<void> _handleImportFromFile() async {
final file = await globalState.safeRun(picker.pickerFile);
if (file == null) {
return;
}
final bytes = await file.readBytes();
if (!mounted) {
return;
}
final provider = await _resolveBehavior(
ClashProvider.create(
kind: _kind,
label: _uniqueLabel(file.name.fileStem),
).withFileFormat(file.name),
);
if (provider == null) {
return;
}
await _put(provider, content: bytes);
}
Future<String?> _showNameDialog({ClashProvider? except}) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showCommonDialog<String>(
child: InputDialog(
title: appLocalizations.save,
value: '',
labelText: appLocalizations.name,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
validator: (value) => _validateLabel(value, except: except),
),
);
return res?.trim().value;
}
Future<void> _handleEditorSave(
String title,
String content, {
ClashProvider? provider,
}) async {
final appLocalizations = context.appLocalizations;
var label = title.trim();
if (label.isEmpty) {
final res = await _showNameDialog(except: provider);
if (res == null) {
return;
}
label = res;
}
if (_reservedLabels(except: provider).contains(label)) {
unawaited(
dialogs.showMessage(
message: TextSpan(
text: appLocalizations.existsTip(appLocalizations.name),
),
),
);
return;
}
var next = provider?.copyWith(label: label);
if (next == null) {
next = await _resolveBehavior(
ClashProvider.create(kind: _kind, label: label),
);
if (next == null) {
return;
}
}
if (!await _put(next, previous: provider, content: utf8.encode(content))) {
return;
}
if (mounted) {
Navigator.of(context).pop();
}
}
Future<bool> _handleEditorPop(
String title,
String content,
String raw, {
ClashProvider? provider,
}) async {
if (content == raw) {
return true;
}
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.saveChanges),
);
if (res == null) {
return false;
}
if (!res || !mounted) {
return true;
}
unawaited(_handleEditorSave(title, content, provider: provider));
return false;
}
void _handleToEditor([ClashProvider? provider]) {
late final String raw;
unawaited(
BaseNavigator.push(
context,
EditorPage(
titleEditable: true,
title: provider?.label ?? '',
load: () async => raw = (await provider?.content) ?? _template,
onSave: (_, title, content) {
_handleEditorSave(title, content, provider: provider);
},
onPop: (_, title, content) =>
_handleEditorPop(title, content, raw, provider: provider),
),
),
);
}
void _handlePreview(ClashProvider provider) {
final core = ref.read(coreHandlerProvider);
unawaited(
BaseNavigator.push(
context,
EditorPage(
title: provider.label,
load: () async => core.dumpRuleSet(await provider.path),
schema: EditorSchema.provider,
),
),
);
}
Future<void> _handleEditOptions(ClashProvider provider) async {
final res = await dialogs.showCommonDialog<ClashProvider>(
child: _ProviderDialog(
provider: provider,
reservedLabels: _reservedLabels(except: provider),
),
);
if (res == null) {
return;
}
await _put(res, previous: provider);
}
Future<void> _handleDelete(ClashProvider provider) async {
final appLocalizations = context.appLocalizations;
var users = await _providersAction.profilesUsing(provider);
if (users.isEmpty) {
final res = await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.deleteTip(provider.label)),
);
if (res != true) {
return;
}
users = await _providersAction.delProvider(provider);
if (users.isEmpty) {
return;
}
}
await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: appLocalizations.providerInUse(
provider.label,
users.map((profile) => profile.realLabel).join(', '),
),
),
cancelable: false,
);
}
bool _isEditable(ClashProvider provider) =>
!provider.isRemote && provider.isTextContent;
Widget _buildItem(ClashProvider provider, int index, int length) {
final appLocalizations = context.appLocalizations;
final editable = _isEditable(provider);
return ItemPositionProvider(
position: ItemPosition.get(index, length),
child: DecorationListItem(
contentPadding: const EdgeInsets.only(left: 16, right: 6),
title: Text(
provider.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
provider.isRemote ? provider.url : appLocalizations.file,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: CommonPopupBox(
popupBuilder: (_) => CommonPopupMenu(
items: [
if (editable)
CommonPopupMenuItem(
glyph: AppGlyphs.compose,
label: appLocalizations.edit,
onPressed: () {
_handleToEditor(provider);
},
),
if (!provider.isTextContent)
CommonPopupMenuItem(
glyph: AppGlyphs.eye,
label: appLocalizations.preview,
onPressed: () {
_handlePreview(provider);
},
),
CommonPopupMenuItem(
glyph: AppGlyphs.settings,
label: appLocalizations.options,
onPressed: () {
_handleEditOptions(provider);
},
),
CommonPopupMenuItem(
danger: true,
glyph: AppGlyphs.delete,
label: appLocalizations.delete,
onPressed: () {
_handleDelete(provider);
},
),
],
),
targetBuilder: (open) {
return IconButton(
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
tooltip: appLocalizations.more,
onPressed: () {
open();
},
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
onPressed: () {
if (editable) {
_handleToEditor(provider);
} else {
_handleEditOptions(provider);
}
},
),
);
}
Widget _buildContent(List<ClashProvider> providers, {required bool loading}) {
final appLocalizations = context.appLocalizations;
return NullStatusSwitcher(
isLoading: loading,
isEmpty: providers.isEmpty,
nullStatus: NullStatus(
illustration: switch (_kind) {
ProviderKind.proxy => NullStatusIllustration.proxies,
ProviderKind.rule => NullStatusIllustration.rules,
},
label: appLocalizations.nullTip(_sectionLabel(context)),
),
child: ReorderableListView.builder(
padding: const EdgeInsets.all(
16,
).copyWith(top: context.contentTopPadding),
buildDefaultDragHandles: false,
itemCount: providers.length,
itemBuilder: (_, index) {
return ReorderableDelayedDragStartListener(
key: ValueKey(providers[index].id),
index: index,
child: _buildItem(providers[index], index, providers.length),
);
},
proxyDecorator: (_, index, animation) {
return commonProxyDecorator(
_buildItem(providers[index], index, providers.length),
index,
animation,
);
},
onReorderItem: ref.read(clashProvidersProvider(_kind).notifier).order,
),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final state = ref.watch(clashProvidersProvider(_kind));
return CommonScaffold(
actions: [
CommonPopupBox(
targetBuilder: (open) {
return FilledButton.tonal(
onPressed: () {
final isMobile = ref.read(isMobileViewProvider);
open(offset: Offset(0, isMobile ? 0 : 20));
},
child: Text(appLocalizations.add),
);
},
popupBuilder: (_) => CommonPopupMenu(
items: [
CommonPopupMenuItem(
glyph: AppGlyphs.compose,
label: appLocalizations.startFromScratch,
onPressed: _handleToEditor,
),
CommonPopupMenuItem(
glyph: AppGlyphs.cloudDownload,
label: appLocalizations.importUrl,
onPressed: _handleImportFromUrl,
),
CommonPopupMenuItem(
glyph: AppGlyphs.importFile,
label: appLocalizations.importFile,
onPressed: _handleImportFromFile,
),
],
),
),
],
body: _buildContent(state.value ?? [], loading: state.isLoading),
title: _sectionLabel(context),
);
}
}
String? _validateProviderUrl(String? value) {
final appLocalizations = currentAppLocalizations;
final url = value?.trim() ?? '';
if (url.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.url);
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.isScheme('http') && !uri.isScheme('https')) {
return appLocalizations.providerUrlTip;
}
return null;
}
class _ProviderDialog extends StatefulWidget {
const _ProviderDialog({required this.provider, required this.reservedLabels});
final ClashProvider provider;
final Set<String> reservedLabels;
@override
State<_ProviderDialog> createState() => _ProviderDialogState();
}
class _ProviderDialogState extends State<_ProviderDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _labelController;
late final TextEditingController _urlController;
late ClashProvider _draft;
ClashProvider get _provider => widget.provider;
ProviderKind get _kind => _provider.kind;
@override
void initState() {
super.initState();
_labelController = TextEditingController(text: _provider.label);
_urlController = TextEditingController(text: _provider.url)
..addListener(_followUrlFormat);
_draft = _provider.withFormat(_provider.format ?? RuleProviderFormat.yaml);
}
@override
void dispose() {
_labelController.dispose();
_urlController.dispose();
super.dispose();
}
void _setFormat(RuleProviderFormat format) {
if (format == _draft.format) {
return;
}
setState(() {
_draft = _draft.withFormat(format);
});
}
/// A url whose extension names a format follows it, as an import does.
void _followUrlFormat() {
final format = ruleProviderFormatOf(_urlController.text.trim().urlFileName);
if (format != null) {
_setFormat(format);
}
}
String? _validateLabel(String? value) {
final appLocalizations = context.appLocalizations;
final label = value?.trim() ?? '';
if (label.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.name);
}
if (widget.reservedLabels.contains(label)) {
return appLocalizations.existsTip(appLocalizations.name);
}
return null;
}
void _handleSubmit() {
if (_formKey.currentState?.validate() == false) {
return;
}
Navigator.of(context).pop(
_draft.copyWith(
label: _labelController.text.trim(),
url: _urlController.text.trim(),
),
);
}
Future<void> _handleSelectFormat() async {
final res = await dialogs.showCommonDialog<RuleProviderFormat>(
context: context,
child: OptionsDialog<RuleProviderFormat>(
title: context.appLocalizations.format,
options: RuleProviderFormat.values,
textBuilder: (item) => item.name,
value: _draft.format!,
),
);
if (res == null) {
return;
}
_setFormat(res);
}
Future<void> _handleSelectBehavior() async {
final res = await dialogs.showCommonDialog<RuleProviderBehavior>(
context: context,
child: OptionsDialog<RuleProviderBehavior>(
title: context.appLocalizations.behavior,
options: _draft.format.behaviors,
textBuilder: (item) => item.name,
value: _draft.behavior!,
),
);
if (res == null) {
return;
}
setState(() {
_draft = _draft.copyWith(behavior: res);
});
}
Widget _buildField({
required TextEditingController controller,
required String label,
FormFieldValidator<String>? validator,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
}) {
return TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
decoration: InputDecoration(border: AppShape.input, labelText: label),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: appLocalizations.options,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: _handleSubmit,
child: Text(appLocalizations.confirm),
),
],
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Wrap(
runSpacing: 16,
children: [
_buildField(
controller: _labelController,
label: appLocalizations.name,
validator: _validateLabel,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
),
if (_provider.isRemote)
_buildField(
controller: _urlController,
label: appLocalizations.url,
validator: _validateProviderUrl,
keyboardType: TextInputType.url,
),
if (_kind == ProviderKind.rule) ...[
ListItem(
padding: EdgeInsets.zero,
title: Text(appLocalizations.format),
trailing: ElasticButton(
child: FilledButton(
onPressed: _handleSelectFormat,
child: Text(_draft.format!.name),
),
),
),
ListItem(
padding: EdgeInsets.zero,
title: Text(appLocalizations.behavior),
trailing: ElasticButton(
child: FilledButton(
onPressed: _handleSelectBehavior,
child: Text(_draft.behavior!.name),
),
),
),
],
],
),
),
);
}
}
+115 -60
View File
@@ -1,7 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/models/state.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -16,17 +19,40 @@ class AddedRulesView extends ConsumerStatefulWidget {
class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
final _key = uniqueId;
var _query = SearchQuery('');
final _searchTexts = Expando<String>();
List<Rule> _visibleRules(List<Rule> rules) {
return rules
.whereMatches(_query, (rule) => rule.searchFields, texts: _searchTexts)
.toList();
}
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
Future<void> _handleAddOrUpdate([Rule? rule]) async {
final res = await dialogs.showCommonDialog<Rule>(
child: AddOrEditRuleDialog(rule: rule),
);
if (res == null) {
if (res == null || !mounted) {
return;
}
ref.read(globalRulesProvider.notifier).put(res);
}
void _handleQuickAdd() {
showSheet<void>(
context: context,
props: const SheetProps(isScrollControlled: true),
builder: (_) =>
RulePresetSheet(onAdd: ref.read(globalRulesProvider.notifier).putAll),
);
}
void _handleSelected(int ruleId) {
ref.read(itemsProvider(_key).notifier).update((selectedRules) {
final newSelectedRules = Set<int>.from(selectedRules)
@@ -36,9 +62,9 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
}
void _handleSelectAll() {
final ids =
ref.read(globalRulesProvider).value?.map((item) => item.id).toSet() ??
{};
final ids = _visibleRules(
ref.read(globalRulesProvider).value ?? [],
).map((item) => item.id).toSet();
ref.read(itemsProvider(_key).notifier).update((selected) {
return selected.containsAll(ids) ? {} : ids;
});
@@ -52,19 +78,39 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
text: appLocalizations.deleteMultipTip(appLocalizations.rule),
),
);
if (res != true) {
if (res != true || !mounted) {
return;
}
final selectedRules = ref.read(itemsProvider(_key));
ref.read(globalRulesProvider.notifier).delAll(selectedRules.cast<int>());
ref.read(itemsProvider(_key).notifier).value = {};
final deletedIds = _visibleRules(
ref.read(globalRulesProvider).value ?? [],
).map((item) => item.id).where(selectedRules.contains).toSet();
ref.read(globalRulesProvider.notifier).delAll(deletedIds);
ref.read(itemsProvider(_key).notifier).value = selectedRules.difference(
deletedIds,
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final rules = ref.watch(globalRulesProvider).value ?? [];
final rulesState = ref.watch(globalRulesProvider);
final rules = _visibleRules(rulesState.value ?? []);
final isSearching = _query.isNotEmpty;
final selectedRules = ref.watch(itemsProvider(_key));
final isSelecting = selectedRules.isNotEmpty;
final selectionActions = [
IconButtonData(
glyph: AppGlyphs.delete,
onPressed: _handleDelete,
tooltip: appLocalizations.delete,
),
IconButtonData(
glyph: AppGlyphs.selectAll,
onPressed: _handleSelectAll,
tooltip: appLocalizations.selectAll,
),
];
return CommonPopScope(
onPop: (_) {
if (selectedRules.isNotEmpty) {
@@ -75,71 +121,80 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
return false;
},
child: BaseScaffold(
child: CommonScaffold(
title: appLocalizations.addedRules,
searchState: AppBarSearchState(onSearch: _handleSearch),
actions: [
if (selectedRules.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.delete,
onPressed: _handleDelete,
icon: const Icon(Icons.delete),
),
if (!isSelecting)
FilledButton.tonal(
onPressed: () {
_handleAddOrUpdate();
},
child: Text(appLocalizations.add),
),
const SizedBox(width: 2),
],
CommonMinFilledButtonTheme(
child: selectedRules.isNotEmpty
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleAddOrUpdate();
},
child: Text(appLocalizations.add),
),
),
const SizedBox(width: 8),
],
iconActions: [
if (!isSelecting)
IconButtonData(
glyph: AppGlyphs.bolt,
onPressed: _handleQuickAdd,
tooltip: appLocalizations.quickAdd,
),
],
selectionActions: isSelecting ? selectionActions : const [],
body: NullStatusSwitcher(
isLoading: rulesState.isLoading,
isEmpty: rules.isEmpty,
isSearching: isSearching,
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.rule),
illustration: NullStatusIllustration.rules,
),
child: ReorderableList(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
itemBuilder: (context, index) {
final rule = rules[index];
final position = ItemPosition.get(index, rules.length);
return ReorderableDelayedDragStartListener(
key: ObjectKey(rule),
index: index,
child: ItemPositionProvider(
position: position,
child: RuleItem(
hasMatch: true,
isEditing: selectedRules.isNotEmpty,
rule: rule,
isSelected: selectedRules.contains(rule.id),
onSelected: () {
_handleSelected(rule.id);
},
onEdit: (Rule rule) {
_handleAddOrUpdate(rule);
},
),
child: isSearching
? ListView.builder(
padding: _listPadding,
itemBuilder: (_, index) =>
_buildRuleItem(rules, index, selectedRules),
itemExtent: ruleItemHeight,
itemCount: rules.length,
)
: ReorderableList(
padding: _listPadding,
itemBuilder: (_, index) =>
ReorderableDelayedDragStartListener(
key: ObjectKey(rules[index]),
index: index,
child: _buildRuleItem(rules, index, selectedRules),
),
itemExtent: ruleItemHeight,
itemCount: rules.length,
onReorderItem: ref.read(globalRulesProvider.notifier).order,
),
);
},
itemExtent: ruleItemHeight,
itemCount: rules.length,
onReorderItem: ref.read(globalRulesProvider.notifier).order,
),
),
),
);
}
EdgeInsets get _listPadding =>
const EdgeInsets.all(16).copyWith(top: context.contentTopPadding);
Widget _buildRuleItem(List<Rule> rules, int index, Set<dynamic> selected) {
final rule = rules[index];
return ItemPositionProvider(
key: ObjectKey(rule),
position: ItemPosition.get(index, rules.length),
child: RuleItem(
hasMatch: true,
isEditing: selected.isNotEmpty,
rule: rule,
isSelected: selected.contains(rule.id),
onSelected: () {
_handleSelected(rule.id);
},
onEdit: (Rule rule) {
_handleAddOrUpdate(rule);
},
),
);
}
}
+337 -135
View File
@@ -1,13 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
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/pages/editor.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:collection/collection.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -19,25 +24,27 @@ class ScriptsView extends ConsumerStatefulWidget {
}
class _ScriptsViewState extends ConsumerState<ScriptsView> {
final _key = uniqueId;
late final ScriptsAction _scriptsAction;
Future<void> _handleDelete() async {
@override
void initState() {
super.initState();
_scriptsAction = ref.read(scriptsActionProvider.notifier);
}
Future<void> _handleDelete(Script script) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: appLocalizations.deleteMultipTip(appLocalizations.script),
text: appLocalizations.deleteTip(appLocalizations.script),
),
);
if (res != true) {
if (res != true || !mounted) {
return;
}
final selectedScriptIds = ref.read(itemsProvider(_key)).cast<int>();
ref.read(scriptsProvider.notifier).delAll(selectedScriptIds);
ref.read(itemsProvider(_key).notifier).value = {};
for (final id in selectedScriptIds) {
unawaited(_clearEffect(id));
}
ref.read(scriptsProvider.notifier).del(script.id);
unawaited(_clearEffect(script.id));
}
Future<void> _clearEffect(int id) async {
@@ -45,56 +52,116 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
await File(path).safeDelete();
}
void _handleSelected(int id) {
ref.read(itemsProvider(_key).notifier).update((selectedScriptIds) {
return Set<int>.from(selectedScriptIds)..addOrRemove(id);
});
}
void _handleSelectAll() {
final ids =
ref.read(scriptsProvider).value?.map((item) => item.id).toSet() ?? {};
ref.read(itemsProvider(_key).notifier).update((selected) {
return selected.containsAll(ids) ? {} : ids;
});
}
Widget _buildContent(List<Script> scripts, Set<dynamic> selectedScriptIds) {
Widget _buildContent(List<Script> scripts, {required bool isLoading}) {
final appLocalizations = context.appLocalizations;
return NullStatusSwitcher(
isLoading: isLoading,
isEmpty: scripts.isEmpty,
nullStatus: NullStatus(
illustration: NullStatusIllustration.scripts,
label: appLocalizations.nullTip(appLocalizations.script),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: ReorderableListView.builder(
padding: const EdgeInsets.all(
16,
).copyWith(top: context.contentTopPadding),
buildDefaultDragHandles: false,
itemCount: scripts.length,
itemBuilder: (_, index) {
final script = scripts[index];
return ItemPositionProvider(
position: ItemPosition.get(index, scripts.length),
child: SelectedDecorationListItem(
isSelected: selectedScriptIds.contains(script.id),
isEditing: selectedScriptIds.isNotEmpty,
title: Text(
script.label,
style: context.textTheme.bodyLarge,
maxLines: 3,
),
onSelected: () {
_handleSelected(script.id);
},
onPressed: () {
_handleToEditor(script.id);
},
),
return ReorderableDelayedDragStartListener(
key: ValueKey(script.id),
index: index,
child: _buildItem(script, index, scripts.length),
);
},
proxyDecorator: (_, index, animation) {
return commonProxyDecorator(
_buildItem(scripts[index], index, scripts.length),
index,
animation,
);
},
onReorderItem: ref.read(scriptsProvider.notifier).order,
),
);
}
Widget _buildItem(Script script, int index, int length) {
return ItemPositionProvider(
position: ItemPosition.get(index, length),
child: DecorationListItem(
minVerticalPadding: 0,
contentPadding: const EdgeInsets.only(left: 16, right: 6),
title: _ScriptItemTitle(script: script),
trailing: _ScriptItemMenu(
script: script,
onEdit: () {
_handleToEditor(script);
},
onEditUrl: () {
_handleEditUrl(script);
},
onUpdate: () {
_handleUpdate(script);
},
onDelete: () {
_handleDelete(script);
},
),
onPressed: () {
_handleToEditor(script);
},
),
);
}
Future<void> _handleEditUrl(Script script) async {
final url = await dialogs.showUrlInput(
title: context.appLocalizations.url,
value: script.url ?? '',
);
if (url == null || url == script.url) {
return;
}
await _handleUpdate(script.copyWith(url: url));
}
Future<void> _handleUpdate(Script script) async {
await globalState.safeRun(
() => _scriptsAction.updateScript(script),
title: script.label,
);
}
List<Script> get _scripts => ref.read(scriptsProvider).value ?? [];
String? _validateName(String? value, {Script? script}) {
final appLocalizations = context.appLocalizations;
final label = value?.trim() ?? '';
if (label.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.name);
}
if (_scripts.hasLabel(label, except: script)) {
return appLocalizations.existsTip(appLocalizations.name);
}
return null;
}
Future<String?> _showNameDialog({Script? script}) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showCommonDialog<String>(
child: InputDialog(
title: appLocalizations.save,
value: '',
hintText: appLocalizations.pleaseEnterScriptName,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
validator: (value) => _validateName(value, script: script),
),
);
return res?.trim().value;
}
Future<void> _handleEditorSave(
BuildContext _,
String title,
@@ -102,51 +169,28 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
Script? script,
}) async {
final appLocalizations = context.appLocalizations;
Script newScript =
(script?.copyWith(label: title) ?? Script.create(label: title));
newScript = await newScript.save(content);
if (newScript.label.isEmpty) {
final res = await dialogs.showCommonDialog<String>(
child: InputDialog(
title: appLocalizations.save,
value: '',
hintText: appLocalizations.pleaseEnterScriptName,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
validator: (value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.name);
}
if (value != script?.label) {
final isExits = ref.read(scriptsProvider.notifier).isExits(value);
if (isExits) {
return appLocalizations.existsTip(appLocalizations.name);
}
}
return null;
},
var label = title.trim();
if (label.isEmpty) {
final res = await _showNameDialog(script: script);
if (res == null) {
return;
}
label = res;
}
if (_scripts.hasLabel(label, except: script)) {
unawaited(
dialogs.showMessage(
message: TextSpan(
text: appLocalizations.existsTip(appLocalizations.name),
),
),
);
if (res == null || res.isEmpty) {
return;
}
newScript = newScript.copyWith(label: res);
return;
}
if (newScript.label != script?.label) {
final isExits = ref
.read(scriptsProvider.notifier)
.isExits(newScript.label);
if (isExits) {
unawaited(
dialogs.showMessage(
message: TextSpan(
text: appLocalizations.existsTip(appLocalizations.name),
),
),
);
return;
}
}
ref.read(scriptsProvider.notifier).put(newScript);
final newScript =
await (script?.copyWith(label: label) ?? Script.create(label: label))
.save(content);
_scriptsAction.putScript(newScript);
if (mounted) {
Navigator.of(context).pop();
}
@@ -166,7 +210,10 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
final res = await dialogs.showMessage(
message: TextSpan(text: appLocalizations.saveChanges),
);
if (res == true && mounted) {
if (res == null) {
return false;
}
if (res && mounted) {
unawaited(_handleEditorSave(context, title, content, script: script));
} else {
return true;
@@ -174,20 +221,14 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
return false;
}
void _handleToEditor([int? id]) async {
final script = await ref.read(scriptProvider(id).future);
final title = script?.label ?? '';
final raw = (await script?.content) ?? scriptTemplate;
if (!mounted) {
return;
}
void _handleToEditor([Script? script]) {
late final String raw;
unawaited(
BaseNavigator.push(
context,
EditorPage(
titleEditable: true,
title: title,
supportRemoteDownload: true,
title: script?.label ?? '',
onSave: (context, title, content) {
_handleEditorSave(context, title, content, script: script);
},
@@ -200,56 +241,217 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
script: script,
);
},
languages: const [Language.javaScript],
content: raw,
language: Language.javaScript,
load: () async => raw = (await script?.content) ?? scriptTemplate,
),
),
);
}
Future<void> _handleImport(
String content, {
String? url,
required String fileName,
}) async {
final label = _scripts.uniqueLabel(
fileName.fileStem,
fallback: context.appLocalizations.script,
);
final script = await Script.create(label: label, url: url).save(content);
_scriptsAction.putScript(script);
}
Future<void> _handleImportFromUrl() async {
final appLocalizations = context.appLocalizations;
final url = await dialogs.showUrlInput(title: appLocalizations.importUrl);
if (url == null) {
return;
}
final res = await globalState.loadingRun(
() => request.getTextResponseForUrl(url),
title: appLocalizations.importUrl,
tag: LoadingTag.scripts,
);
if (res == null || !mounted) {
return;
}
final fileName =
Uri.tryParse(
url,
)?.pathSegments.lastWhereOrNull((it) => it.isNotEmpty) ??
'';
await _handleImport(res.data ?? '', url: url, fileName: fileName);
}
Future<void> _handleImportFromFile() async {
final file = await globalState.safeRun(picker.pickerFile);
if (file == null) {
return;
}
final content = await globalState.safeRun(
() async => utf8.decode(await file.readBytes()),
);
if (content == null || !mounted) {
return;
}
await _handleImport(content, fileName: file.name);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final scripts = ref.watch(scriptsProvider).value ?? [];
final selectedScriptIds = ref.watch(itemsProvider(_key));
return CommonPopScope(
onPop: (_) {
if (selectedScriptIds.isNotEmpty) {
ref.read(itemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop();
return false;
},
child: CommonScaffold(
actions: [
if (selectedScriptIds.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.delete,
onPressed: _handleDelete,
icon: const Icon(Icons.delete),
final scriptsState = ref.watch(scriptsProvider);
final scripts = scriptsState.value ?? [];
final isLoading = ref.watch(loadingProvider(LoadingTag.scripts));
return CommonScaffold(
isLoading: isLoading,
actions: [
CommonPopupBox(
targetBuilder: (open) {
return FilledButton.tonal(
onPressed: () {
final isMobile = ref.read(isMobileViewProvider);
open(offset: Offset(0, isMobile ? 0 : 20));
},
child: Text(appLocalizations.add),
);
},
popupBuilder: (_) => CommonPopupMenu(
items: [
CommonPopupMenuItem(
glyph: AppGlyphs.compose,
label: appLocalizations.startFromScratch,
onPressed: _handleToEditor,
),
CommonPopupMenuItem(
glyph: AppGlyphs.cloudDownload,
label: appLocalizations.importUrl,
onPressed: _handleImportFromUrl,
),
CommonPopupMenuItem(
glyph: AppGlyphs.importFile,
label: appLocalizations.importFile,
onPressed: _handleImportFromFile,
),
],
),
),
],
body: _buildContent(scripts, isLoading: scriptsState.isLoading),
title: appLocalizations.script,
);
}
}
class _ScriptItemTitle extends StatelessWidget {
const _ScriptItemTitle({required this.script});
final Script script;
@override
Widget build(BuildContext context) {
final style = DefaultTextStyle.of(context).style;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
script.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(
fontSize: context.textTheme.bodyLarge?.fontSize,
),
),
if (script.url != null)
LastUpdateTimeText(
lastUpdateDate: script.lastUpdateTime,
style: style.copyWith(
fontSize: context.textTheme.bodySmall?.fontSize,
color: style.color?.opacity60,
),
),
const SizedBox(width: 2),
],
CommonMinFilledButtonTheme(
child: selectedScriptIds.isNotEmpty
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleToEditor();
},
child: Text(appLocalizations.add),
),
),
const SizedBox(width: 8),
],
body: _buildContent(scripts, selectedScriptIds),
title: appLocalizations.script,
),
);
}
}
class _ScriptItemMenu extends ConsumerWidget {
const _ScriptItemMenu({
required this.script,
required this.onEdit,
required this.onEditUrl,
required this.onUpdate,
required this.onDelete,
});
final Script script;
final VoidCallback onEdit;
final VoidCallback onEditUrl;
final VoidCallback onUpdate;
final VoidCallback onDelete;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final isUpdating = ref.watch(isUpdatingProvider(script.updatingKey));
return SizedBox.square(
dimension: 40,
child: FadeThroughBox(
alignment: Alignment.center,
child: isUpdating
? const Padding(
key: ValueKey('loading'),
padding: EdgeInsets.all(8),
child: CommonCircleLoading(),
)
: CommonPopupBox(
key: const ValueKey('menu'),
popupBuilder: (_) => CommonPopupMenu(
items: [
CommonPopupMenuItem(
glyph: AppGlyphs.edit,
label: appLocalizations.edit,
onPressed: onEdit,
),
if (script.url != null) ...[
CommonPopupMenuItem(
glyph: AppGlyphs.link,
label: appLocalizations.url,
onPressed: onEditUrl,
),
CommonPopupMenuItem(
glyph: AppGlyphs.sync,
label: appLocalizations.sync,
onPressed: onUpdate,
),
],
CommonPopupMenuItem(
danger: true,
glyph: AppGlyphs.delete,
label: appLocalizations.delete,
onPressed: onDelete,
),
],
),
targetBuilder: (open) {
return IconButton(
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
tooltip: appLocalizations.more,
onPressed: () {
open();
},
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
),
);
}
+121 -17
View File
@@ -1,11 +1,16 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'custom_proxies.dart';
import 'groups.dart';
import 'rules.dart';
@@ -19,12 +24,38 @@ class CustomContent extends ConsumerWidget {
if (res != true) {
return;
}
final clashConfig = await ref.read(clashConfigProvider(profileId).future);
await database.setProfileCustomData(
profileId,
clashConfig.proxyGroups,
clashConfig.rules,
);
await globalState.safeRun(() async {
final clashConfig = await ref.read(clashConfigProvider(profileId).future);
final proxies = feature.customProxies
? await _readConfigProxies(ref, profileId)
: null;
await database.setProfileCustomData(
profileId,
proxies,
clashConfig.proxyGroups,
clashConfig.rules,
);
});
}
Future<List<CustomProxy>> _readConfigProxies(
WidgetRef ref,
int profileId,
) async {
final rawProxies = (await ref
.read(coreHandlerProvider)
.getConfig(profileId))['proxies'];
if (rawProxies != null && rawProxies is! List) {
throw const FormatException('proxies');
}
return [
for (final item in rawProxies ?? const [])
if (item is Map) CustomProxy.fromDefinition(item),
];
}
void _handleToProxiesView(BuildContext context, int profileId) {
BaseNavigator.push(context, CustomProxiesView(profileId));
}
void _handleToProxyGroupsView(BuildContext context, int profileId) {
@@ -40,20 +71,47 @@ class CustomContent extends ConsumerWidget {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
ref.listen(proxyGroupsProvider(profileId), (_, _) {});
ref.listen(customProxiesProvider(profileId), (_, _) {});
ref.listen(profileCustomRulesProvider(profileId), (_, _) {});
ref.listen(customOverwriteDateProvider(profileId), (_, _) {});
final proxyNum =
ref.watch(customProxiesCountProvider(profileId)).value ?? -1;
final proxyGroupNum =
ref.watch(proxyGroupsCountProvider(profileId)).value ?? -1;
final ruleNum = ref.watch(customRulesCountProvider(profileId)).value ?? -1;
final issueCounts = ref.watch(
customOverwriteIssuesProvider(profileId).select(
(state) => (
proxies: state.proxies.length,
proxyGroups: state.proxyGroups.length,
rules: state.rules.length,
),
),
);
final issueCount =
issueCounts.proxies + issueCounts.proxyGroups + issueCounts.rules;
final generalIssues = ref
.watch(
customOverwriteIssuesProvider(
profileId,
).select((state) => SelectValue(state.general)),
)
.value;
final issueMessages = [
if (issueCount > 0) appLocalizations.overwriteIssuesSummary(issueCount),
for (final issue in generalIssues) issue.getMessage(context),
];
final defaults = ref.watch(
clashConfigProvider(profileId).select((state) {
final clashConfig = state.value;
return (
hasProxies: clashConfig?.proxies.isNotEmpty ?? false,
hasGroups: clashConfig?.proxyGroups.isNotEmpty ?? false,
hasRules: clashConfig?.rules.isNotEmpty ?? false,
);
}),
);
final hasDefaultProxies = defaults.hasProxies;
final hasDefaultGroups = defaults.hasGroups;
final hasDefaultRules = defaults.hasRules;
return SliverMainAxisGroup(
@@ -65,13 +123,39 @@ class CustomContent extends ConsumerWidget {
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: OverwriteErrorBanner(
message: issueMessages.isEmpty ? null : issueMessages.join('\n'),
),
),
),
if (feature.customProxies) ...[
SliverToBoxAdapter(
child: MoreActionButton(
label: appLocalizations.proxies,
onPressed: () {
_handleToProxiesView(context, profileId);
},
trailing: _CountBadge(
count: proxyNum,
issueCount: issueCounts.proxies,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 4)),
],
SliverToBoxAdapter(
child: MoreActionButton(
label: appLocalizations.proxyGroup,
onPressed: () {
_handleToProxyGroupsView(context, profileId);
},
trailing: _CountBadge(count: proxyGroupNum),
trailing: _CountBadge(
count: proxyGroupNum,
issueCount: issueCounts.proxyGroups,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 4)),
@@ -81,11 +165,15 @@ class CustomContent extends ConsumerWidget {
onPressed: () {
_handleToRulesView(context, profileId);
},
trailing: _CountBadge(count: ruleNum),
trailing: _CountBadge(
count: ruleNum,
issueCount: issueCounts.rules,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
if ((proxyGroupNum == 0 && hasDefaultGroups) ||
if ((feature.customProxies && proxyNum == 0 && hasDefaultProxies) ||
(proxyGroupNum == 0 && hasDefaultGroups) ||
(ruleNum == 0 && hasDefaultRules) ||
kDebugMode)
SliverFillRemaining(
@@ -102,21 +190,35 @@ class CustomContent extends ConsumerWidget {
}
class _CountBadge extends StatelessWidget {
const _CountBadge({required this.count});
const _CountBadge({required this.count, this.issueCount = 0});
final int count;
final int issueCount;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final invalid = issueCount > 0;
final foreground = invalid ? colorScheme.onErrorContainer : null;
return Card.filled(
shape: AppShape.md,
color: invalid ? colorScheme.errorContainer : null,
child: Container(
constraints: const BoxConstraints(minWidth: 44),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(
'$count',
style: context.textTheme.bodySmall,
textAlign: TextAlign.center,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
if (invalid)
GlyphIcon(AppGlyphs.error, size: 14, color: foreground),
Text(
invalid ? '$issueCount / $count' : '$count',
style: context.textTheme.bodySmall?.copyWith(color: foreground),
textAlign: TextAlign.center,
),
],
),
),
);
@@ -143,9 +245,11 @@ class _QuickFillBanner extends StatelessWidget {
content: Text(appLocalizations.configDataDetected),
actions: [
CommonMinFilledButtonTheme(
child: FilledButton.tonal(
onPressed: onPressed,
child: Text(appLocalizations.quickFill),
child: ElasticButton(
child: FilledButton.tonal(
onPressed: onPressed,
child: Text(appLocalizations.quickFill),
),
),
),
],
@@ -0,0 +1,515 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/features/overwrite/overwrite.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/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CustomProxiesView extends ConsumerStatefulWidget {
final int profileId;
const CustomProxiesView(this.profileId, {super.key});
@override
ConsumerState createState() => _CustomProxiesViewState();
}
class _CustomProxiesViewState extends ConsumerState<CustomProxiesView> {
void _handleReorder(int oldIndex, int newIndex) {
ref
.read(customProxiesProvider(widget.profileId).notifier)
.order(oldIndex, newIndex);
}
void _handleDelete(Set<int> ids) {
ref.read(customProxiesProvider(widget.profileId).notifier).delAll(ids);
}
void _handleAddOrUpdate({CustomProxy? proxy}) {
showOverwriteNestedSheet<CustomProxy>(
context: context,
profileId: widget.profileId,
overrides: [
customProxyProvider.overrideWithBuild(
(_, _) =>
proxy ??
const CustomProxy(id: -1, definition: {'name': '', 'type': 'ss'}),
),
],
currentOf: (ref) => ref.read(customProxyProvider),
save: _handleSaveCustomProxy,
formBuilder: (_) => const _EditCustomProxyView(),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return OverwriteEditorPage<CustomProxy, int>(
title: appLocalizations.proxies,
selectionEnabled: true,
dragFromRow: true,
idOf: (proxy) => proxy.id,
itemsOf: (ref) {
return ref.watch(customProxiesProvider(widget.profileId)).value;
},
itemBuilder:
(
context,
ref,
proxy,
index,
isEditing,
isSelected,
onToggleSelected,
) {
return _CustomProxyItem(
profileId: widget.profileId,
proxy: proxy,
isEditing: isEditing,
isSelected: isSelected,
onSelected: onToggleSelected,
onPressed: () {
_handleAddOrUpdate(proxy: proxy);
},
);
},
onReorder: _handleReorder,
onAdd: _handleAddOrUpdate,
onDelete: _handleDelete,
searchFieldsOf: (proxy) => proxy.searchFields,
emptyLabel: appLocalizations.customProxiesEmpty,
itemExtent:
globalState.measure.bodyLargeHeight +
globalState.measure.bodyMediumHeight +
16,
);
}
}
class _CustomProxyItem extends ConsumerWidget {
final int profileId;
final CustomProxy proxy;
final bool isEditing;
final bool isSelected;
final VoidCallback onSelected;
final VoidCallback onPressed;
const _CustomProxyItem({
required this.profileId,
required this.proxy,
required this.isEditing,
required this.isSelected,
required this.onSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final issues = ref
.watch(
customOverwriteIssuesProvider(profileId).select(
(state) => SelectValue(
state.proxies[proxy.id] ?? const <OverwriteIssue>[],
),
),
)
.value;
final address = proxy.address;
return DecorationListItem(
invalid: issues.isNotEmpty,
isSelected: isSelected,
onPressed: isEditing ? onSelected : onPressed,
contentPadding: const EdgeInsets.only(left: 16),
minVerticalPadding: 8,
title: TooltipText(
text: Text(
proxy.name.isEmpty ? '-' : proxy.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Text(
address == null ? proxy.type : '${proxy.type} · $address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (issues.isNotEmpty) OverwriteIssueButton(issues: issues),
CommonCheckBox(
value: isSelected,
isCircle: true,
onChanged: (_) => onSelected(),
),
],
),
);
}
}
Future<bool> _handleSaveCustomProxy(BuildContext context, WidgetRef ref) async {
final profileId = ProfileIdProvider.of(context)!.profileId;
final proxy = ref.read(customProxyProvider);
// A source still loading reads as empty below and would pass any name.
try {
await Future.wait([
ref.read(customProxiesProvider(profileId).future),
ref.read(proxyGroupsProvider(profileId).future),
ref.read(clashConfigProvider(profileId).future),
]);
} catch (_) {}
if (!context.mounted) {
return false;
}
final issues = customProxyIssues(
proxy,
proxies: ref.read(customProxiesProvider(profileId)).value ?? const [],
proxyGroups: ref.read(effectiveProxyGroupsProvider(profileId)),
);
if (issues.isEmpty) {
try {
final errors = await ref.read(coreHandlerProvider).validateProxies([
proxy.definition,
]);
if (errors.first.isNotEmpty) {
issues.add(OverwriteIssue.coreRejected(errors.first));
}
} catch (error) {
issues.add(OverwriteIssue.coreRejected(compactError(error)));
}
}
if (!context.mounted) {
return false;
}
if (issues.isNotEmpty) {
await showOverwriteIssues(context, issues);
return false;
}
ref
.read(customProxiesProvider(profileId).notifier)
.put(proxy.id == -1 ? proxy.copyWith(id: snowflake.id) : proxy);
return true;
}
class _EditCustomProxyView extends ConsumerStatefulWidget {
const _EditCustomProxyView();
@override
ConsumerState<_EditCustomProxyView> createState() =>
_EditCustomProxyViewState();
}
class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
static const _validateDelay = Duration(milliseconds: 400);
Timer? _validateTimer;
int _validateRequest = 0;
String? _coreError;
bool _saving = false;
/// Bumped when the whole mapping is replaced, so the fields drop the text
/// they were initialized with.
int _revision = 0;
@override
void initState() {
super.initState();
ref.listenManual(
customProxyProvider.select((state) => state.definition),
(_, _) => _scheduleValidate(),
fireImmediately: true,
);
}
@override
void dispose() {
_validateTimer?.cancel();
super.dispose();
}
void _scheduleValidate() {
_validateTimer?.cancel();
_validateTimer = Timer(_validateDelay, _validate);
}
Future<void> _validate() async {
final request = ++_validateRequest;
final definition = ref.read(customProxyProvider).definition;
String? error;
try {
final errors = await ref.read(coreHandlerProvider).validateProxies([
definition,
]);
error = errors.first.isEmpty ? null : errors.first;
} catch (_) {
error = null;
}
if (!mounted || request != _validateRequest || error == _coreError) {
return;
}
setState(() {
_coreError = error;
});
}
void _update(String key, Object? value) {
ref
.read(customProxyProvider.notifier)
.update((state) => state.withValue(key, value));
}
Widget _buildTextItem({
required String title,
required String key,
required String? value,
required int maxLength,
String? hintText,
bool digitsOnly = false,
bool invalid = false,
}) {
return OverwriteFormRow(
title: title,
invalid: invalid,
trailing: TextFormField(
key: ValueKey('$key-$_revision'),
initialValue: value,
textAlign: TextAlign.end,
keyboardType: digitsOnly ? TextInputType.number : TextInputType.text,
inputFormatters: digitsOnly
? TextInputLimits.digitsOnly(maxLength)
: TextInputLimits.limit(maxLength),
onChanged: (value) {
_update(key, digitsOnly ? int.tryParse(value) : value);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: hintText ?? context.appLocalizations.optional,
),
),
);
}
Future<void> _handleSelectType() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => OverwriteSelectionSheet<String>(
title: context.appLocalizations.proxyType,
sections: const [OverwriteSelectionSection(items: customProxyTypes)],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(customProxyProvider.select((state) => state.type)),
onSelected: (item) => Navigator.of(context).pop(item),
),
),
);
if (res == null) {
return;
}
_update('type', res);
}
Widget _buildTypeItem(String type) {
return OverwriteFormRow(
title: context.appLocalizations.proxyType,
onPressed: _handleSelectType,
trailing: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
type,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildUdpItem(bool udp) {
return OverwriteFormRow(
title: 'UDP',
onPressed: () => _update('udp', !udp),
trailing: Switch(value: udp, onChanged: (value) => _update('udp', value)),
);
}
Future<void> _handleEditDefinition() async {
final raw = ref.read(customProxyProvider).definitionYaml;
final page = EditorPage(
title: context.appLocalizations.proxyDefinition,
content: raw,
readOnly: false,
onPop: (_, _, content) => _handleDefinitionPop(content, raw),
);
await Navigator.of(context, rootNavigator: true).push(
context.isMobileView
? CommonRoute(builder: (_) => page)
: CommonDesktopRoute(builder: (_) => page),
);
}
Future<bool> _handleDefinitionPop(String content, String raw) async {
if (content == raw) {
return true;
}
final CustomProxy next;
try {
next = ref.read(customProxyProvider).withDefinitionYaml(content);
} catch (error) {
final res = await dialogs.showMessage(
message: TextSpan(
text:
'${currentAppLocalizations.proxyDefinitionNotMap}\n\n'
'${currentAppLocalizations.discardChanges}',
),
);
return res == true;
}
ref.read(customProxyProvider.notifier).value = next;
if (mounted) {
setState(() {
_revision++;
});
}
return true;
}
Future<void> _handleDelete(int profileId) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.proxies),
),
);
if (res == true && mounted) {
final id = ref.read(customProxyProvider).id;
ref.read(customProxiesProvider(profileId).notifier).delAll([id]);
context.safeNestedPop();
}
}
Future<void> _handleSave() async {
if (_saving) return;
_saving = true;
try {
if (await _handleSaveCustomProxy(context, ref) && mounted) {
context.safeNestedPop();
}
} finally {
_saving = false;
}
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final proxy = ref.watch(customProxyProvider);
final issues = [
...customProxyIssues(
proxy,
proxies: ref.watch(customProxiesProvider(profileId)).value ?? const [],
proxyGroups: ref.watch(effectiveProxyGroupsProvider(profileId)),
),
if (_coreError != null) OverwriteIssue.coreRejected(_coreError!),
];
final nameInvalid = issues.any(
(issue) =>
issue is EmptyNameIssue ||
issue is ReservedNameIssue ||
issue is DuplicateNameIssue,
);
final height = ref.sheetHeight(context, 0.65);
return CommonScaffold(
actions: [
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: appLocalizations.save,
),
),
],
body: SizedBox(
height: height,
child: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
OverwriteIssuesBanner(issues: issues),
generateSectionV3(
title: appLocalizations.basicInfo,
items: [
_buildTextItem(
title: appLocalizations.name,
key: 'name',
value: proxy.name,
maxLength: TextInputLimits.proxyName,
hintText: appLocalizations.name,
invalid: nameInvalid,
),
_buildTypeItem(proxy.type),
_buildTextItem(
title: appLocalizations.server,
key: 'server',
value: proxy.server,
maxLength: TextInputLimits.domain,
),
_buildTextItem(
title: appLocalizations.port,
key: 'port',
value: proxy.port?.toString(),
maxLength: TextInputLimits.port,
digitsOnly: true,
),
_buildUdpItem(proxy.definition['udp'] == true),
],
),
generateSectionV3(
title: appLocalizations.other,
items: [
OverwriteFormRow(
title: appLocalizations.proxyDefinition,
invalid: _coreError != null,
onPressed: _handleEditDefinition,
trailing: const GlyphIcon(AppGlyphs.chevronForward),
),
],
),
generateSectionV3(
title: appLocalizations.action,
items: [
if (proxy.id != -1)
OverwriteFormRow(
title: appLocalizations.delete,
titleStyle: TextStyle(color: context.colorScheme.error),
onPressed: () {
_handleDelete(profileId);
},
),
],
),
],
),
),
title: proxy.id == -1
? appLocalizations.addCustomProxy
: appLocalizations.editProxy,
);
}
}
+215 -120
View File
@@ -1,10 +1,10 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/proxy_providers.dart';
@@ -44,24 +44,26 @@ class _CustomProxyGroupsViewState extends ConsumerState<CustomProxyGroupsView> {
],
currentOf: (ref) => ref.read(proxyGroupProvider),
save: _handleSaveProxyGroup,
formBuilder: (_) => const _EditProxyGroupView(),
formBuilder: (_) => const EditProxyGroupView(),
);
}
void _handleDelete(Set<int> proxyGroupIds) {
ref
.read(proxyGroupsProvider(widget.profileId).notifier)
.delAll(proxyGroupIds);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return OverwriteEditorPage<ProxyGroup>(
return OverwriteEditorPage<ProxyGroup, int>(
title: appLocalizations.proxyGroup,
selectionEnabled: true,
dragFromRow: true,
idOf: (proxyGroup) => proxyGroup.id,
itemsOf: (ref) {
return ref
.watch(
customOverwriteDateProvider(
widget.profileId,
).select((state) => SelectValue(state.proxyGroups)),
)
.value;
return ref.watch(proxyGroupsProvider(widget.profileId)).value;
},
itemBuilder:
(
@@ -74,10 +76,11 @@ class _CustomProxyGroupsViewState extends ConsumerState<CustomProxyGroupsView> {
onToggleSelected,
) {
return _ProxyGroupItem(
key: ValueKey(proxyGroup.id),
profileId: widget.profileId,
proxyGroup: proxyGroup,
index: index,
isEditing: isEditing,
isSelected: isSelected,
onSelected: onToggleSelected,
onPressed: () {
_handleAddOrUpdate(proxyGroup: proxyGroup);
},
@@ -85,6 +88,8 @@ class _CustomProxyGroupsViewState extends ConsumerState<CustomProxyGroupsView> {
},
onReorder: _handleReorder,
onAdd: _handleAddOrUpdate,
onDelete: _handleDelete,
searchFieldsOf: (proxyGroup) => [proxyGroup.name, proxyGroup.type.name],
emptyLabel: appLocalizations.proxyGroupEmpty,
itemExtent:
globalState.measure.bodyLargeHeight +
@@ -97,29 +102,36 @@ class _CustomProxyGroupsViewState extends ConsumerState<CustomProxyGroupsView> {
class _ProxyGroupItem extends ConsumerWidget {
final int profileId;
final ProxyGroup proxyGroup;
final int index;
final bool isEditing;
final bool isSelected;
final VoidCallback onSelected;
final VoidCallback onPressed;
const _ProxyGroupItem({
super.key,
required this.profileId,
required this.proxyGroup,
required this.index,
required this.isEditing,
required this.isSelected,
required this.onSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context, ref) {
final appLocalizations = context.appLocalizations;
final isValid = !ref.watch(
invalidProxyGroupIdsProvider(
profileId,
).select((state) => state.contains(proxyGroup.id)),
);
final issues = ref
.watch(
customOverwriteIssuesProvider(profileId).select(
(state) => SelectValue(
state.proxyGroups[proxyGroup.id] ?? const <OverwriteIssue>[],
),
),
)
.value;
return DecorationListItem(
invalid: !isValid,
onPressed: onPressed,
contentPadding: const EdgeInsets.only(left: 16, right: 0),
invalid: issues.isNotEmpty,
isSelected: isSelected,
onPressed: isEditing ? onSelected : onPressed,
contentPadding: const EdgeInsets.only(left: 16),
minVerticalPadding: 8,
leading: SizedBox.square(
dimension: 32,
@@ -140,17 +152,11 @@ class _ProxyGroupItem extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!isValid)
InfoMessageButton(
message: appLocalizations.proxyGroupDetectedAbnormal,
),
ReorderableDelayedDragStartListener(
index: index,
child: Container(
padding: const EdgeInsets.all(12),
color: Colors.transparent,
child: const Icon(Icons.drag_handle),
),
if (issues.isNotEmpty) OverwriteIssueButton(issues: issues),
CommonCheckBox(
value: isSelected,
isCircle: true,
onChanged: (_) => onSelected(),
),
],
),
@@ -158,17 +164,29 @@ class _ProxyGroupItem extends ConsumerWidget {
}
}
/// A missing member is left to the list to flag, since it usually appears when
/// the profile updates; these the core refuses whatever the profile holds.
bool _blocksSave(OverwriteIssue issue) => switch (issue) {
EmptyNameIssue() ||
ReservedNameIssue() ||
DuplicateNameIssue() ||
NoProxySourceIssue() ||
GroupLoopIssue() => true,
_ => false,
};
bool _handleSaveProxyGroup(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final proxyGroup = ref.read(proxyGroupProvider);
if (proxyGroup.name.isEmpty) {
dialogs.showMessage(
message: TextSpan(text: appLocalizations.proxyGroupNameEmpty),
cancelable: false,
);
final profileId = ProfileIdProvider.of(context)!.profileId;
final blocking = proxyGroupIssues(
proxyGroup,
ref.read(customOverwriteDateProvider(profileId)),
).where(_blocksSave).toList();
if (blocking.isNotEmpty) {
showOverwriteIssues(context, blocking);
return false;
}
final profileId = ProfileIdProvider.of(context)!.profileId;
final ProxyGroup newProxyGroup;
if (proxyGroup.id == -1) {
newProxyGroup = proxyGroup.copyWith(id: snowflake.id);
@@ -189,19 +207,19 @@ bool _handleSaveProxyGroup(BuildContext context, WidgetRef ref) {
}
}
class _EditProxyGroupView extends ConsumerStatefulWidget {
const _EditProxyGroupView();
class EditProxyGroupView extends ConsumerStatefulWidget {
const EditProxyGroupView({super.key});
@override
ConsumerState createState() => _EditProxyGroupViewState();
}
class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
Future<void> _showTypeOptions(GroupType type) async {
final value = await dialogs.showCommonDialog<GroupType>(
child: OptionsDialog<GroupType>(
title: context.appLocalizations.proxyType,
options: GroupType.values,
options: GroupType.selectableValues,
textBuilder: (item) => item.name,
value: type,
),
@@ -214,6 +232,23 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
.update((state) => state.copyWith(type: value));
}
Future<void> _showStrategyOptions(LoadBalanceStrategy? strategy) async {
final value = await dialogs.showCommonDialog<LoadBalanceStrategy>(
child: OptionsDialog<LoadBalanceStrategy>(
title: context.appLocalizations.strategy,
options: LoadBalanceStrategy.values,
textBuilder: (item) => item.value,
value: strategy ?? LoadBalanceStrategy.consistentHashing,
),
);
if (value == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(strategy: value));
}
Future<void> _showIconEdit(String? icon) async {
final value = await Navigator.of(
context,
@@ -254,14 +289,15 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
);
}
Widget _buildProvidersItem(bool includeAllProviders, List<String> use) {
Widget _buildProvidersItem(
bool includeAllProviders,
List<String> use,
List<OverwriteIssue> issues,
) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final invalid = issues.isNotEmpty;
return Consumer(
builder: (_, ref, _) {
final invalid = !ref.watch(
customOverwriteUseIsValidProvider(profileId, use),
);
return _buildItem(
invalid: invalid,
title: appLocalizations.selectProxyProviders,
@@ -270,13 +306,11 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
spacing: 2,
children: [
invalid
? InfoMessageButton(
message: appLocalizations.proxyProviderDetectedAbnormal,
)
? OverwriteIssueButton(issues: issues)
: (!includeAllProviders
? _NumberCard(number: use.length)
: const _CheckIcon()),
const Icon(Icons.arrow_forward_ios),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
onPressed: _handleToProvidersView,
@@ -306,28 +340,41 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
);
}
Widget _buildMaxFailedTimesItem(int? maxFailedTimes) {
Widget _buildNumberItem({
required String title,
required int? value,
required ProxyGroup Function(ProxyGroup state, int? value) apply,
String? suffix,
}) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.maxFailedTimes,
trailing: TextFormField(
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.number),
textAlign: TextAlign.end,
initialValue: maxFailedTimes?.toString(),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(maxFailedTimes: int.tryParse(value)),
);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.optional,
),
final field = TextFormField(
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,
),
);
return _buildItem(
title: title,
trailing: suffix == null
? field
: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(child: field),
Text(suffix, style: context.textTheme.bodyMedium),
],
),
);
}
Widget _buildUrlItem(String? url) {
@@ -352,25 +399,13 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
);
}
Widget _buildIntervalItem(int? interval) {
final appLocalizations = context.appLocalizations;
Widget _buildStrategyItem(LoadBalanceStrategy? strategy) {
return _buildItem(
title: appLocalizations.testInterval,
trailing: TextFormField(
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.interval),
textAlign: TextAlign.end,
initialValue: interval?.toString(),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(interval: int.tryParse(value)));
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.optional,
),
),
title: context.appLocalizations.strategy,
onPressed: () {
_showStrategyOptions(strategy);
},
trailing: Text((strategy ?? LoadBalanceStrategy.consistentHashing).value),
);
}
@@ -437,14 +472,15 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
);
}
Widget _buildProxiesItem(bool includeAllProxies, List<String> proxies) {
Widget _buildProxiesItem(
bool includeAllProxies,
List<String> proxies,
List<OverwriteIssue> issues,
) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final invalid = issues.isNotEmpty;
return Consumer(
builder: (_, ref, _) {
final invalid = !ref.watch(
customOverwriteProxiesIsValidProvider(profileId, proxies),
);
return _buildItem(
invalid: invalid,
title: appLocalizations.selectProxies,
@@ -453,13 +489,11 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
mainAxisSize: MainAxisSize.min,
children: [
invalid
? InfoMessageButton(
message: appLocalizations.proxyDetectedAbnormal,
)
? OverwriteIssueButton(issues: issues)
: (!includeAllProxies
? _NumberCard(number: proxies.length)
: const _CheckIcon()),
const Icon(Icons.arrow_forward_ios),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
onPressed: _handleToProxiesView,
@@ -499,9 +533,10 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
);
}
Widget _buildNameItem(String name) {
Widget _buildNameItem(String name, {bool invalid = false}) {
final appLocalizations = context.appLocalizations;
return _buildItem(
invalid: invalid,
title: appLocalizations.name,
trailing: TextFormField(
initialValue: name,
@@ -546,17 +581,19 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
Widget _buildLazyItem(bool? lazy) {
final appLocalizations = context.appLocalizations;
// The core defaults lazy to true, so an untouched group already tests lazily.
final value = lazy ?? true;
void handleChangeLazy() {
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(lazy: !(lazy ?? false)));
.update((state) => state.copyWith(lazy: !value));
}
return _buildItem(
title: appLocalizations.testWhenUsed,
onPressed: handleChangeLazy,
trailing: Switch(
value: lazy ?? false,
value: value,
onChanged: (_) {
handleChangeLazy();
},
@@ -601,8 +638,8 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
message: TextSpan(text: context.appLocalizations.confirmDeleteProxyGroup),
);
if (res == true && mounted) {
final name = ref.read(proxyGroupProvider).name;
ref.read(proxyGroupsProvider(profileId).notifier).del(name);
final id = ref.read(proxyGroupProvider).id;
ref.read(proxyGroupsProvider(profileId).notifier).delAll([id]);
context.safeNestedPop();
}
}
@@ -618,14 +655,32 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final id = ref.watch(proxyGroupProvider.select((state) => state.id));
final type = ref.watch(proxyGroupProvider.select((state) => state.type));
final overwrite = ref.watch(customOverwriteDateProvider(profileId));
final issues = ref
.watch(
proxyGroupProvider.select(
(state) => SelectValue(proxyGroupIssues(state, overwrite)),
),
)
.value;
final nameInvalid = issues.any(
(issue) =>
issue is EmptyNameIssue ||
issue is ReservedNameIssue ||
issue is DuplicateNameIssue,
);
final proxiesIssues = issues.whereType<MissingProxiesIssue>().toList();
final providersIssues = issues.whereType<MissingProvidersIssue>().toList();
final height = ref.sheetHeight(context, 0.65);
return AdaptiveSheetScaffold(
sheetTransparentToolBar: true,
return CommonScaffold(
actions: [
IconButtonData(
icon: Icons.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
),
),
],
body: SizedBox(
@@ -633,12 +688,16 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
child: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.sheetTopPadding),
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
OverwriteIssuesBanner(issues: issues),
generateSectionV3(
title: appLocalizations.general,
items: [
_field((state) => state.name, _buildNameItem),
_field(
(state) => state.name,
(value) => _buildNameItem(value, invalid: nameInvalid),
),
_field((state) => state.type, _buildTypeItem),
_field((state) => state.icon, _buildIconItem),
_field((state) => state.hidden, _buildHiddenItem),
@@ -653,14 +712,16 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
state.includeAllProxies ?? false,
state.proxies ?? const <String>[],
),
(value) => _buildProxiesItem(value.$1, value.$2),
(value) =>
_buildProxiesItem(value.$1, value.$2, proxiesIssues),
),
_field(
(state) => (
state.includeAllProviders ?? false,
state.use ?? const <String>[],
),
(value) => _buildProvidersItem(value.$1, value.$2),
(value) =>
_buildProvidersItem(value.$1, value.$2, providersIssues),
),
_field((state) => state.filter, _buildFilterItem),
_field((state) => state.excludeFilter, _buildExcludeFilterItem),
@@ -675,12 +736,46 @@ class _EditProxyGroupViewState extends ConsumerState<_EditProxyGroupView> {
title: appLocalizations.other,
items: [
_field((state) => state.url, _buildUrlItem),
_field(
(state) => state.interval,
(value) => _buildNumberItem(
title: appLocalizations.testInterval,
value: value,
suffix: 's',
apply: (state, value) => state.copyWith(interval: value),
),
),
_field(
(state) => state.timeout,
(value) => _buildNumberItem(
title: appLocalizations.timeout,
value: value,
suffix: 'ms',
apply: (state, value) => state.copyWith(timeout: value),
),
),
_field(
(state) => state.maxFailedTimes,
_buildMaxFailedTimesItem,
(value) => _buildNumberItem(
title: appLocalizations.maxFailedTimes,
value: value,
apply: (state, value) =>
state.copyWith(maxFailedTimes: value),
),
),
_field((state) => state.lazy, _buildLazyItem),
_field((state) => state.interval, _buildIntervalItem),
if (type == GroupType.URLTest)
_field(
(state) => state.tolerance,
(value) => _buildNumberItem(
title: appLocalizations.tolerance,
value: value,
suffix: 'ms',
apply: (state, value) => state.copyWith(tolerance: value),
),
),
if (type == GroupType.LoadBalance)
_field((state) => state.strategy, _buildStrategyItem),
],
),
generateSectionV3(
@@ -713,10 +808,10 @@ class _CheckIcon extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.check_circle_outline,
child: GlyphIcon(
AppGlyphs.checkCircle,
size: 20.ap,
color: Colors.greenAccent.harmonizeWith(context.colorScheme.primary),
color: context.colorScheme.success,
),
);
}
@@ -80,7 +80,7 @@ class IconEditView extends ConsumerStatefulWidget {
class _IconEditViewState extends ConsumerState<IconEditView>
with TickerProviderStateMixin {
late final TextEditingController _srcController;
late final ValueNotifier<List<IconRecord>> _recordsNotifier;
late final ValueNotifier<List<IconRecord>?> _recordsNotifier;
StreamSubscription? _streamSubscription;
late final _IconEditStateNotifier<File?> _state;
@@ -88,7 +88,7 @@ class _IconEditViewState extends ConsumerState<IconEditView>
void initState() {
super.initState();
_srcController = TextEditingController(text: widget.value);
_recordsNotifier = ValueNotifier([]);
_recordsNotifier = ValueNotifier(null);
_state = _IconEditStateNotifier<File?>(
vsync: this,
duration: commonDuration * 2,
@@ -108,7 +108,6 @@ class _IconEditViewState extends ConsumerState<IconEditView>
}
Future<void> _handleUpdateIconRecords() async {
_recordsNotifier.value = [];
final text = _srcController.text;
final res = await database.iconRecordsDao.query(text);
if (mounted) {
@@ -144,7 +143,7 @@ class _IconEditViewState extends ConsumerState<IconEditView>
final appLocalizations = context.appLocalizations;
final dimension = globalState.measure.bodyLargeHeight + 28;
final height = ref.sheetHeight(context, 0.5);
return AdaptiveSheetScaffold(
return CommonScaffold(
backAction: () {
Navigator.of(context).pop(_srcController.text);
},
@@ -157,7 +156,7 @@ class _IconEditViewState extends ConsumerState<IconEditView>
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
SizedBox(height: context.contentTopPadding),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _IconSrcRow(
@@ -169,7 +168,8 @@ class _IconEditViewState extends ConsumerState<IconEditView>
),
Expanded(
child: NullStatusSwitcher(
isEmpty: records.isEmpty,
isLoading: records == null,
isEmpty: records?.isEmpty ?? true,
nullStatus: NullStatus(
label: appLocalizations.noRecords,
illustration: NullStatusIllustration.history,
@@ -184,7 +184,7 @@ class _IconEditViewState extends ConsumerState<IconEditView>
),
Expanded(
child: _IconRecordList(
records: records,
records: records ?? const [],
onSelected: _handleSelectRecord,
),
),
@@ -2,10 +2,12 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class NameAddEntry {
final String title;
@@ -55,6 +57,14 @@ class NameAddPicker extends ConsumerStatefulWidget {
class _NameAddPickerState extends ConsumerState<NameAddPicker>
with UniqueKeyStateMixin, OverwriteStageFlowMixin<NameAddPicker> {
var _query = SearchQuery('');
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
String _tagOf(String? scene) =>
scene == null ? widget.stageTagPrefix : '${widget.stageTagPrefix}_$scene';
@@ -97,10 +107,12 @@ class _NameAddPickerState extends ConsumerState<NameAddPicker>
),
subtitle: subtitle == null ? null : Text(subtitle),
trailing: CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.add,
onPressed: onAdd,
icon: const Icon(Icons.add, size: 18),
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.add,
onPressed: onAdd,
icon: const GlyphIcon(AppGlyphs.add, size: 18, fill: 1),
),
),
),
),
@@ -122,8 +134,9 @@ class _NameAddPickerState extends ConsumerState<NameAddPicker>
child: InfoHeader(info: Info(label: section.label)),
),
),
SliverList(
delegate: SliverChildBuilderDelegate((_, index) {
SuperSliverList.builder(
itemCount: entries.length,
itemBuilder: (_, index) {
final entry = entries[index];
return _buildItem(
entry: entry,
@@ -139,7 +152,7 @@ class _NameAddPickerState extends ConsumerState<NameAddPicker>
handleStage(entry.title, section.scene);
},
);
}, childCount: entries.length),
},
),
if (!isLast) const SliverToBoxAdapter(child: SizedBox(height: 8)),
];
@@ -149,27 +162,45 @@ class _NameAddPickerState extends ConsumerState<NameAddPicker>
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final height = ref.sheetHeight(context, widget.heightFactor);
final sections = widget
.sectionsBuilder(context, ref)
.where((section) => section.entries.isNotEmpty)
.toList();
final allSections = widget.sectionsBuilder(context, ref);
final sections = [
for (final section in allSections)
NameAddSection(
label: section.label,
scene: section.scene,
entries: section.entries
.whereMatches(_query, (entry) => [entry.title, entry.subtitle])
.toList(),
),
].where((section) => section.entries.isNotEmpty).toList();
final entryCount = allSections.fold(
0,
(count, section) => count + section.entries.length,
);
return SizedBox(
height: height,
child: AdaptiveSheetScaffold(
sheetTransparentToolBar: true,
child: CommonScaffold(
title: widget.title,
body: NullStatusSwitcher(
isEmpty: sections.isEmpty,
nullStatus: NullStatus(label: appLocalizations.noData),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.sheetTopPadding),
),
for (var i = 0; i < sections.length; i++)
..._buildSection(sections[i], i == sections.length - 1),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
searchState: _query.isNotEmpty || entryCount >= sheetSearchMinItemCount
? AppBarSearchState(onSearch: _handleSearch)
: null,
body: Builder(
builder: (context) => NullStatusSwitcher(
isEmpty: sections.isEmpty,
isSearching: _query.isNotEmpty,
nullStatus: NullStatus(label: appLocalizations.noData),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.contentTopPadding),
),
for (var i = 0; i < sections.length; i++)
..._buildSection(sections[i], i == sections.length - 1),
SliverToBoxAdapter(
child: SizedBox(height: 16 + BottomInsetScope.of(context)),
),
],
),
),
),
),
@@ -2,6 +2,7 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -155,13 +156,12 @@ class _NameListEditorState extends ConsumerState<NameListEditor>
return SizedBox(
height: height,
child: AdaptiveSheetScaffold(
child: CommonScaffold(
title: widget.labels.title,
sheetTransparentToolBar: true,
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.sheetTopPadding + 8),
child: SizedBox(height: context.contentTopPadding),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -182,9 +182,11 @@ class _NameListEditorState extends ConsumerState<NameListEditor>
info: Info(label: widget.labels.section),
actions: [
CommonMinFilledButtonTheme(
child: FilledButton.tonal(
onPressed: _handleToAddView,
child: Text(appLocalizations.add),
child: ElasticButton(
child: FilledButton.tonal(
onPressed: _handleToAddView,
child: Text(appLocalizations.add),
),
),
),
],
@@ -192,7 +194,7 @@ class _NameListEditorState extends ConsumerState<NameListEditor>
),
),
if (names.isNotEmpty)
SliverReorderableList(
SuperSliverReorderableList(
itemBuilder: (_, index) => itemAt(index),
itemCount: names.length,
proxyDecorator: (child, index, animation) =>
@@ -247,9 +249,9 @@ class _IncludeAllCard extends StatelessWidget {
cancelable: false,
);
},
icon: Icon(
icon: GlyphIcon(
size: 16.ap,
Icons.info_outline,
AppGlyphs.info,
color: context.colorScheme.onSurfaceVariant,
),
),
@@ -1,4 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/views/profiles/overwrite/custom/name_add_picker.dart';
@@ -13,6 +15,10 @@ class EditProxyProvidersView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final sources = ref.watch(
providerSourcesProvider(profileId, ProviderKind.proxy),
);
return NameListEditor(
stageTag: 'EditProxyProvidersViewState_handleRealRemove',
dragIconPadding: 16,
@@ -30,18 +36,19 @@ class EditProxyProvidersView extends ConsumerWidget {
withIncludeAll: (state, includeAll) =>
state.copyWith(includeAllProviders: includeAll),
),
addViewBuilder: (_) => const _AddProxyProvidersView(),
addViewBuilder: (_) => const AddProxyProvidersView(),
isValidOf: (ref, profileId, title) => ref.watch(
customOverwriteProxyProviderIsValidProvider(profileId, title),
),
invalidMessageOf: (context, title) =>
context.appLocalizations.invalidProxyProvider(title),
subtitleOf: (name) => sources[name]?.label(appLocalizations),
);
}
}
class _AddProxyProvidersView extends ConsumerWidget {
const _AddProxyProvidersView();
class AddProxyProvidersView extends ConsumerWidget {
const AddProxyProvidersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -67,11 +74,14 @@ class _AddProxyProvidersView extends ConsumerWidget {
),
)
.value;
final appProxyProviders = ref.watch(
appProviderNamesProvider(ProviderKind.proxy),
);
return [
NameAddSection(
label: appLocalizations.proxyProviders,
entries: [
for (final name in allProxyProviders)
for (final name in {...allProxyProviders, ...appProxyProviders})
if (!excluded.contains(name)) NameAddEntry(title: name),
],
),
+108 -91
View File
@@ -1,6 +1,7 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/clash_config.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/models/state.dart';
@@ -27,24 +28,8 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
.order(oldIndex, newIndex);
}
Future<bool> _handleDelete(Set<dynamic> selectedRules) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: appLocalizations.deleteMultipTip(appLocalizations.rule),
),
);
if (res != true) {
return false;
}
if (!mounted) {
return false;
}
ref
.read(profileCustomRulesProvider(_profileId).notifier)
.delAll(selectedRules.cast<int>());
return true;
void _handleDelete(Set<int> ruleIds) {
ref.read(profileCustomRulesProvider(_profileId).notifier).delAll(ruleIds);
}
void _handleAddOrUpdate({Rule? rule}) {
@@ -60,42 +45,24 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
);
}
bool _handleCheckInvalid(Rule rule, RuleTargetsSelectorState targets) {
if (!targets.loaded) {
return false;
}
final ruleTarget = rule.realTarget;
if (rule.ruleAction == RuleAction.SUB_RULE) {
return !targets.subRules.contains(ruleTarget);
}
return !targets.ruleTargets.contains(ruleTarget);
}
@override
Widget build(context) {
final appLocalizations = context.appLocalizations;
final ruleTargets = ref.watch(
customOverwriteDateProvider(_profileId).select(
(state) => RuleTargetsSelectorState(
loaded: state.loaded,
ruleTargets: state.ruleTargets,
subRules: state.subRules,
),
),
);
return OverwriteEditorPage<Rule>(
final overwrite = ref.watch(customOverwriteDateProvider(_profileId));
return OverwriteEditorPage<Rule, int>(
title: appLocalizations.rule,
selectionEnabled: true,
dragFromRow: true,
idOf: (rule) => rule.id,
itemsOf: (ref) {
return ref.watch(profileCustomRulesProvider(_profileId)).value ?? [];
return ref.watch(profileCustomRulesProvider(_profileId)).value;
},
itemBuilder:
(context, ref, rule, index, isEditing, isSelected, onToggleSelected) {
return RuleItem(
checkInvalidHandler: (target) {
return _handleCheckInvalid(target, ruleTargets);
invalidMessageOf: (target) {
final issues = customRuleIssues(target, overwrite);
return issues.isEmpty ? null : issues.getMessage(context);
},
isEditing: isEditing,
isSelected: isSelected,
@@ -109,6 +76,7 @@ class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
onReorder: _handleReorder,
onAdd: () => _handleAddOrUpdate(),
onDelete: _handleDelete,
searchFieldsOf: (rule) => rule.searchFields,
emptyLabel: appLocalizations.ruleEmpty,
itemExtent: ruleItemHeight,
);
@@ -187,7 +155,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
),
),
),
const Icon(Icons.arrow_forward_ios),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
@@ -195,22 +163,34 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
Widget _buildContentItem(String? content) {
final appLocalizations = context.appLocalizations;
final payloadError = ref.watch(
ruleProvider.select((state) => state.payloadError),
);
final field = TextFormField(
initialValue: content,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(content: value));
},
textAlign: TextAlign.end,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.inputRuleContent,
),
);
return _buildItem(
invalid: payloadError != null,
title: appLocalizations.content,
trailing: TextFormField(
initialValue: content,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(content: value));
},
textAlign: TextAlign.end,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: appLocalizations.inputRuleContent,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (payloadError != null)
InfoMessageButton(message: payloadError.getMessage(context)),
Flexible(child: field),
],
),
);
}
@@ -228,9 +208,16 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
),
)
.value;
final appRuleProviders = ref.watch(
appProviderNamesProvider(ProviderKind.rule),
);
return OverwriteSelectionSheet<String>(
title: context.appLocalizations.ruleSet,
sections: [OverwriteSelectionSection(items: ruleProviders)],
sections: [
OverwriteSelectionSection(
items: {...ruleProviders, ...appRuleProviders}.toList(),
),
],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.ruleProvider)),
@@ -249,15 +236,43 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
.update((state) => state.copyWith(ruleProvider: res));
}
Widget _buildRuleProviderItem(String? ruleProvider) {
Widget _buildRuleProviderItem(int profileId, String? ruleProvider) {
final appLocalizations = context.appLocalizations;
final invalid =
ruleProvider != null &&
!ref.watch(
customOverwriteRuleProviderIsValidProvider(profileId, ruleProvider),
);
final foregroundColor = invalid
? context.colorScheme.error
: context.colorScheme.onSurfaceVariant;
final source = ruleProvider == null
? null
: ref.watch(
providerSourcesProvider(
profileId,
ProviderKind.rule,
).select((state) => state[ruleProvider]),
);
return _buildItem(
invalid: invalid,
title: appLocalizations.ruleSet,
onPressed: _handleSelectedRuleProvider,
trailing: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
if (invalid)
InfoMessageButton(
message: appLocalizations.invalidRuleSet(ruleProvider),
),
if (source != null)
Text(
source.label(appLocalizations),
style: context.textTheme.bodySmall?.copyWith(
color: foregroundColor,
),
),
Flexible(
child: TooltipText(
text: Text(
@@ -265,12 +280,12 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colorScheme.onSurfaceVariant,
color: foregroundColor,
),
),
),
),
const Icon(Icons.arrow_forward_ios),
GlyphIcon(AppGlyphs.chevronForward, color: foregroundColor),
],
),
);
@@ -341,18 +356,8 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
mainAxisSize: MainAxisSize.min,
children: [
if (invalid && target != null)
CommonMinIconButtonTheme(
child: IconButton(
tooltip: appLocalizations.tip,
onPressed: () {
dialogs.showMessage(
message: TextSpan(
text: appLocalizations.invalidPolicy(target),
),
);
},
icon: Icon(Icons.info, size: 16.ap, color: foregroundColor),
),
InfoMessageButton(
message: appLocalizations.invalidPolicy(target),
),
Flexible(
flex: 1,
@@ -368,7 +373,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios, color: foregroundColor),
GlyphIcon(AppGlyphs.chevronForward, color: foregroundColor),
],
),
);
@@ -432,23 +437,26 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
),
),
),
const Icon(Icons.arrow_forward_ios),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildNoResolveItem(bool noResolve) {
Widget _buildNoResolveItem(bool noResolve, bool src) {
final appLocalizations = context.appLocalizations;
// The core turns no-resolve on with src, so the switch cannot disagree.
return _buildItem(
title: appLocalizations.noResolveHostname,
trailing: Switch(
value: noResolve,
onChanged: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(noResolve: value));
},
value: noResolve || src,
onChanged: src
? null
: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(noResolve: value));
},
),
);
}
@@ -494,22 +502,23 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
final profileId = ProfileIdProvider.of(context)!.profileId;
final rule = ref.watch(ruleProvider);
final height = ref.sheetHeight(context, 0.60);
return AdaptiveSheetScaffold(
return CommonScaffold(
actions: [
IconButtonData(
icon: Icons.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.check,
onPressed: _handleSave,
tooltip: context.appLocalizations.save,
),
),
],
sheetTransparentToolBar: true,
body: Container(
constraints: BoxConstraints(maxHeight: height),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.sheetTopPadding),
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
title: appLocalizations.basicInfo,
@@ -517,7 +526,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
_buildTypeItem(rule.ruleAction),
if (rule.ruleAction != RuleAction.MATCH)
rule.ruleAction == RuleAction.RULE_SET
? _buildRuleProviderItem(rule.ruleProvider)
? _buildRuleProviderItem(profileId, rule.ruleProvider)
: _buildContentItem(rule.content),
rule.ruleAction != RuleAction.SUB_RULE
? _buildTargetItem(profileId, rule.ruleTarget)
@@ -528,7 +537,7 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
generateSectionV3(
title: appLocalizations.additionalParameters,
items: [
_buildNoResolveItem(rule.noResolve),
_buildNoResolveItem(rule.noResolve, rule.src),
_buildSrcItem(rule.src),
],
),
@@ -558,6 +567,14 @@ class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
bool _handleSaveRule(BuildContext context, WidgetRef ref) {
final rule = ref.read(ruleProvider);
final appLocalizations = context.appLocalizations;
final payloadError = rule.payloadError;
if (payloadError != null) {
dialogs.showMessage(
cancelable: false,
message: TextSpan(text: payloadError.getMessage(context)),
);
return false;
}
if (rule.ruleAction != RuleAction.MATCH &&
rule.realContent?.isNotEmpty != true) {
dialogs.showMessage(
+18 -14
View File
@@ -2,6 +2,7 @@ import 'dart:async';
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/views/profiles/preview.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -49,17 +50,20 @@ class _OverwriteViewState extends ConsumerState<OverwriteView> {
child: CommonScaffold(
title: appLocalizations.override,
actions: [
CommonMinFilledButtonTheme(
child: FilledButton(
onPressed: _handlePreview,
child: Text(appLocalizations.preview),
),
FilledButton(
onPressed: _handlePreview,
child: Text(appLocalizations.preview),
),
const SizedBox(width: 8),
],
body: const ScrollConfiguration(
behavior: ShowBarScrollBehavior(),
child: CustomScrollView(slivers: [_Title(), _Content()]),
body: ScrollConfiguration(
behavior: const ShowBarScrollBehavior(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: SizedBox(height: context.appBarInset)),
const _Title(),
const _Content(),
],
),
),
),
);
@@ -83,11 +87,11 @@ class _Title extends ConsumerWidget {
};
}
IconData _getIcon(OverwriteType type) {
Glyph _getIcon(OverwriteType type) {
return switch (type) {
OverwriteType.standard => Icons.stars,
OverwriteType.script => Icons.rocket,
OverwriteType.custom => Icons.dashboard_customize,
OverwriteType.standard => AppGlyphs.star,
OverwriteType.script => AppGlyphs.code,
OverwriteType.custom => AppGlyphs.customize,
};
}
@@ -133,7 +137,7 @@ class _Title extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(_getIcon(type)),
GlyphIcon(_getIcon(type)),
const SizedBox(width: 8),
Flexible(child: Text(_getTitle(context, type))),
],
+82 -31
View File
@@ -1,7 +1,9 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/overwrite/overwrite.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/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
@@ -28,6 +30,16 @@ class _StandardContentState extends ConsumerState<StandardContent> {
ref.read(profileAddedRulesProvider(_profileId).notifier).put(res);
}
void _handleQuickAdd() {
showSheet<void>(
context: context,
props: const SheetProps(isScrollControlled: true),
builder: (_) => RulePresetSheet(
onAdd: ref.read(profileAddedRulesProvider(_profileId).notifier).putAll,
),
);
}
void _handleSelected(int ruleId) {
ref.read(itemsProvider(_key).notifier).update((selectedRules) {
final newSelectedRules = Set<int>.from(selectedRules)
@@ -104,30 +116,43 @@ class _StandardContentState extends ConsumerState<StandardContent> {
actions: [
if (selectedRules.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.delete,
onPressed: () {
_handleDelete();
},
icon: const Icon(Icons.delete),
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: context.appLocalizations.delete,
onPressed: () {
_handleDelete();
},
icon: const GlyphIcon(AppGlyphs.delete, fill: 1),
),
),
),
const SizedBox(width: 8),
],
if (selectedRules.isEmpty)
CommonMinIconButtonTheme(
child: ElasticButton(
child: IconButton.filledTonal(
tooltip: appLocalizations.quickAdd,
onPressed: _handleQuickAdd,
icon: const GlyphIcon(AppGlyphs.bolt, fill: 1),
),
),
),
CommonMinFilledButtonTheme(
child: selectedRules.isNotEmpty
? FilledButton(
onPressed: () {
_handleSelectAll();
},
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleAddOrUpdate();
},
child: Text(appLocalizations.add),
),
child: ElasticButton(
child: selectedRules.isNotEmpty
? FilledButton(
onPressed: () {
_handleSelectAll();
},
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleAddOrUpdate();
},
child: Text(appLocalizations.add),
),
),
),
],
),
@@ -270,12 +295,23 @@ class _MatchTargetItem extends ConsumerWidget {
}
}
class _EditGlobalAddedRules extends ConsumerWidget {
class _EditGlobalAddedRules extends ConsumerStatefulWidget {
final int profileId;
const _EditGlobalAddedRules(this.profileId);
void _handleChange(WidgetRef ref, int profileId, bool status, int ruleId) {
@override
ConsumerState<_EditGlobalAddedRules> createState() =>
_EditGlobalAddedRulesState();
}
class _EditGlobalAddedRulesState extends ConsumerState<_EditGlobalAddedRules> {
var _query = SearchQuery('');
final _searchTexts = Expando<String>();
int get profileId => widget.profileId;
void _handleChange(bool status, int ruleId) {
if (status) {
ref.read(profileDisabledRuleIdsProvider(profileId).notifier).put(ruleId);
} else {
@@ -283,18 +319,31 @@ class _EditGlobalAddedRules extends ConsumerWidget {
}
}
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final disabledRuleIds =
(ref.watch(profileDisabledRuleIdsProvider(profileId)).value ??
const <int>[])
.toSet();
final rules = ref.watch(globalRulesProvider).value ?? [];
return BaseScaffold(
final disabledRuleIdsState = ref.watch(
profileDisabledRuleIdsProvider(profileId),
);
final disabledRuleIds = (disabledRuleIdsState.value ?? const <int>[])
.toSet();
final rulesState = ref.watch(globalRulesProvider);
final rules = (rulesState.value ?? [])
.whereMatches(_query, (rule) => rule.searchFields, texts: _searchTexts)
.toList();
return CommonScaffold(
title: appLocalizations.editGlobalRules,
searchState: AppBarSearchState(onSearch: _handleSearch),
body: NullStatusSwitcher(
isLoading: rulesState.isLoading || disabledRuleIdsState.isLoading,
isEmpty: rules.isEmpty,
isSearching: _query.isNotEmpty,
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.rule),
illustration: NullStatusIllustration.rules,
@@ -302,7 +351,9 @@ class _EditGlobalAddedRules extends ConsumerWidget {
child: ScrollConfiguration(
behavior: const ShowBarScrollBehavior(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(
16,
).copyWith(top: context.contentTopPadding),
itemExtent: ruleItemHeight,
itemBuilder: (context, index) {
final rule = rules[index];
@@ -313,7 +364,7 @@ class _EditGlobalAddedRules extends ConsumerWidget {
status: !disabledRuleIds.contains(rule.id),
rule: rule,
onChange: (status) {
_handleChange(ref, profileId, !status, rule.id);
_handleChange(!status, rule.id);
},
),
);
+225 -26
View File
@@ -4,13 +4,17 @@ import 'dart:io';
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/common.dart';
import 'package:fl_clash/models/core.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProvidersView extends ConsumerStatefulWidget {
@@ -93,16 +97,21 @@ class _ProvidersViewState extends ConsumerState<ProvidersView> {
final ruleProviders = providers
.where((item) => item.type == 'Rule')
.toList();
return AdaptiveSheetScaffold(
return CommonScaffold(
actions: [
IconButtonData(
icon: Icons.sync,
onPressed: _updateProviders,
tooltip: appLocalizations.update,
AppBarActionButton(
data: IconButtonData(
glyph: AppGlyphs.sync,
onPressed: _updateProviders,
tooltip: appLocalizations.update,
),
),
],
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.contentTopPadding),
),
..._buildSection(
title: appLocalizations.proxies,
providers: proxyProviders,
@@ -119,12 +128,46 @@ class _ProvidersViewState extends ConsumerState<ProvidersView> {
}
}
class ProviderItem extends ConsumerWidget {
class ProviderItem extends ConsumerStatefulWidget {
final ExternalProvider provider;
const ProviderItem({super.key, required this.provider});
Future<void> _handleUpdateProvider(WidgetRef ref) async {
@override
ConsumerState<ProviderItem> createState() => _ProviderItemState();
}
class _ProviderItemState extends ConsumerState<ProviderItem> {
_ProviderFile _file = _ProviderFile.binary;
ExternalProvider get provider => widget.provider;
@override
void initState() {
super.initState();
unawaited(_probeFile());
}
@override
void didUpdateWidget(covariant ProviderItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.provider.path != provider.path ||
oldWidget.provider.updateAt != provider.updateAt) {
unawaited(_probeFile());
}
}
Future<void> _probeFile() async {
final file = await _probeProviderFile(provider.path);
if (!mounted || file == _file) {
return;
}
setState(() {
_file = file;
});
}
Future<void> _handleUpdateProvider() async {
if (provider.vehicleType != 'HTTP') return;
final proxiesAction = ref.read(proxiesActionProvider.notifier);
await globalState.safeRun(() async {
@@ -137,23 +180,43 @@ class ProviderItem extends ConsumerWidget {
proxiesAction.updateGroupsDebounce();
}
Future<void> _handleSideLoadProvider(WidgetRef ref) async {
Future<void> _handleSideLoadProvider() async {
final proxiesAction = ref.read(proxiesActionProvider.notifier);
await globalState.safeRun<void>(() async {
final platformFile = await picker.pickerFile();
if (platformFile == null || provider.path == null) return;
final bytes = await platformFile.readBytes();
await File(provider.path!).safeWriteAsBytes(bytes);
final message = await proxiesAction.sideLoadExternalProvider(
provider,
utf8.decode(bytes),
showLoading: true,
);
if (message.isNotEmpty) throw MessageException(message);
if (platformFile == null) return;
await _sideLoadProvider(ref, provider, await platformFile.readBytes());
});
proxiesAction.updateGroupsDebounce();
}
void _handleEditProvider(BuildContext context) {
final path = provider.path;
if (path == null || path.isEmpty) return;
unawaited(
BaseNavigator.push<void>(
context,
_EditProviderView(provider: provider, path: path),
),
);
}
void _handlePreviewProvider(BuildContext context) {
final path = provider.path;
if (path == null || path.isEmpty) return;
final core = ref.read(coreHandlerProvider);
unawaited(
BaseNavigator.push<void>(
context,
EditorPage(
title: provider.name,
load: () => core.dumpRuleSet(path),
schema: EditorSchema.provider,
),
),
);
}
void _handleShowSubscriptionInfo() {
unawaited(
dialogs.showCommonDialog<void>(
@@ -200,28 +263,44 @@ class ProviderItem extends ConsumerWidget {
);
}
List<CommonPopupMenuItem> _menuItems(BuildContext context, WidgetRef ref) {
List<CommonPopupMenuItem> _menuItems(BuildContext context) {
final appLocalizations = context.appLocalizations;
final subscriptionInfo = provider.subscriptionInfo;
return [
if (_file == _ProviderFile.text)
CommonPopupMenuItem(
glyph: AppGlyphs.edit,
label: appLocalizations.edit,
onPressed: () {
_handleEditProvider(context);
},
),
if (_file == _ProviderFile.ruleSet)
CommonPopupMenuItem(
glyph: AppGlyphs.eye,
label: appLocalizations.preview,
onPressed: () {
_handlePreviewProvider(context);
},
),
CommonPopupMenuItem(
icon: Icons.upload_outlined,
glyph: AppGlyphs.upload,
label: appLocalizations.upload,
onPressed: () {
_handleSideLoadProvider(ref);
_handleSideLoadProvider();
},
),
if (provider.vehicleType == 'HTTP')
CommonPopupMenuItem(
icon: Icons.sync,
glyph: AppGlyphs.sync,
label: appLocalizations.sync,
onPressed: () {
_handleUpdateProvider(ref);
_handleUpdateProvider();
},
),
if (subscriptionInfo != null && subscriptionInfo.total > 0)
CommonPopupMenuItem(
icon: Icons.data_usage_outlined,
glyph: AppGlyphs.dataUsage,
label: appLocalizations.subscriptionInfo,
onPressed: _handleShowSubscriptionInfo,
),
@@ -229,7 +308,7 @@ class ProviderItem extends ConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final isUpdating = ref.watch(isUpdatingProvider(provider.updatingKey));
return DecorationListItem(
minVerticalPadding: 8,
@@ -252,14 +331,14 @@ class ProviderItem extends ConsumerWidget {
: CommonPopupBox(
key: const ValueKey('menu'),
popupBuilder: (_) =>
CommonPopupMenu(items: _menuItems(context, ref)),
CommonPopupMenu(items: _menuItems(context)),
targetBuilder: (open) {
return IconButton(
tooltip: context.appLocalizations.more,
onPressed: () {
open();
},
icon: const Icon(Icons.more_vert),
icon: const GlyphIcon(AppGlyphs.more),
);
},
),
@@ -268,3 +347,123 @@ class ProviderItem extends ConsumerWidget {
);
}
}
const _zstdMagic = [0x28, 0xB5, 0x2F, 0xFD];
const _probeLength = 512;
enum _ProviderFile { text, ruleSet, binary }
// An .mrs rule set is a zstd stream and any other binary file gives itself
// away with a NUL byte; neither can go through the Core's string side-load,
// but the Core can dump an .mrs set back to text for reading.
Future<_ProviderFile> _probeProviderFile(String? path) async {
if (path == null || path.isEmpty) {
return _ProviderFile.binary;
}
try {
final file = File(path);
if (!await file.exists()) {
return _ProviderFile.text;
}
final head = await file
.openRead(0, _probeLength)
.fold<List<int>>(<int>[], (bytes, chunk) => bytes..addAll(chunk));
if (listEquals(head.take(_zstdMagic.length).toList(), _zstdMagic)) {
return _ProviderFile.ruleSet;
}
return head.contains(0) ? _ProviderFile.binary : _ProviderFile.text;
} catch (_) {
return _ProviderFile.binary;
}
}
String _decodeProviderFile(List<int> bytes) {
try {
return utf8.decode(bytes);
} on FormatException {
throw MessageException(currentAppLocalizations.nonTextProviderFile);
}
}
Future<void> _sideLoadProvider(
WidgetRef ref,
ExternalProvider provider,
List<int> bytes,
) async {
final path = provider.path;
if (path == null) return;
final data = _decodeProviderFile(bytes);
final message = await ref
.read(proxiesActionProvider.notifier)
.sideLoadExternalProvider(provider, data, showLoading: true);
if (message.isNotEmpty) throw MessageException(message);
await File(path).safeWriteAsBytes(bytes);
}
class _EditProviderView extends ConsumerStatefulWidget {
final ExternalProvider provider;
final String path;
const _EditProviderView({required this.provider, required this.path});
@override
ConsumerState<_EditProviderView> createState() => _EditProviderViewState();
}
class _EditProviderViewState extends ConsumerState<_EditProviderView> {
String? _raw;
Future<String> _load() async {
try {
return _raw = await readTextFileTask(widget.path) ?? '';
} on FormatException {
throw MessageException(currentAppLocalizations.nonTextProviderFile);
}
}
Future<void> _handleSave(BuildContext context, String content) async {
final proxiesAction = ref.read(proxiesActionProvider.notifier);
final saved = await globalState.safeRun<bool>(() async {
await _sideLoadProvider(ref, widget.provider, utf8.encode(content));
return true;
}, silence: false);
if (saved != true) {
return;
}
proxiesAction.updateGroupsDebounce();
if (context.mounted) {
Navigator.of(context).pop();
}
}
Future<bool> _handlePop(BuildContext context, String content) async {
final raw = _raw;
if (raw == null || content == raw) {
return true;
}
final res = await dialogs.showMessage(
title: widget.provider.name,
message: TextSpan(text: context.appLocalizations.saveChanges),
);
if (res == null) {
return false;
}
if (!res || !context.mounted) {
return true;
}
unawaited(_handleSave(context, content));
return false;
}
@override
Widget build(BuildContext context) {
return EditorPage(
title: widget.provider.name,
load: _load,
onSave: (context, _, content) {
_handleSave(context, content);
},
onPop: (context, _, content) => _handlePop(context, content),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:material_ui/material_ui.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
/// Estimates the rows a far jump skips; [SliverList] lays out every one.
class SuperSliverReorderableList extends SliverReorderableList {
const SuperSliverReorderableList({
super.key,
required super.itemBuilder,
required super.itemCount,
required super.onReorderItem,
super.proxyDecorator,
});
@override
SliverReorderableListState createState() =>
_SuperSliverReorderableListState();
}
class _SuperSliverReorderableListState extends SliverReorderableListState {
@override
Widget build(BuildContext context) {
final sliver = super.build(context);
if (sliver is! SliverList) {
return sliver;
}
return SuperSliverList(delegate: sliver.delegate);
}
}
+29
View File
@@ -1,8 +1,10 @@
import 'package:emoji_regex/emoji_regex.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:material_ui/material_ui.dart';
import '../state.dart';
import 'builder.dart';
class TooltipText extends StatelessWidget {
final Text text;
@@ -150,3 +152,30 @@ class EmojiText extends StatelessWidget {
);
}
}
class LastUpdateTimeText extends StatelessWidget {
final DateTime? lastUpdateDate;
final TextStyle? style;
const LastUpdateTimeText({
super.key,
required this.lastUpdateDate,
this.style,
});
@override
Widget build(BuildContext context) {
if (lastUpdateDate == null) {
return Text('', style: style);
}
return TickBuilder(
duration: const Duration(minutes: 1),
builder: (context, _) {
return Text(
lastUpdateDate!.getLastUpdateTimeDesc(context),
style: style,
);
},
);
}
}
+1
View File
@@ -39,6 +39,7 @@ export 'side_sheet.dart';
export 'skeleton.dart';
export 'subscription_info_view.dart';
export 'super_grid.dart';
export 'super_reorderable_list.dart';
export 'tab.dart';
export 'text.dart';
export 'theme.dart';