feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
This commit is contained in:
@@ -81,7 +81,10 @@ List<Group> computeHideTimeout({
|
||||
selectedMap[group.name] ?? '',
|
||||
);
|
||||
final visible = group.all.where((proxy) {
|
||||
if (proxy.name == selectedName) {
|
||||
// A group's delay is its pick's, and hiding it would take away the only
|
||||
// way to choose the group where the mode does not show it on its own.
|
||||
if (proxy.name == selectedName ||
|
||||
allGroups.getGroup(proxy.name) != null) {
|
||||
return true;
|
||||
}
|
||||
final state = realStates.putIfAbsent(
|
||||
@@ -109,10 +112,9 @@ SelectedProxyState getRealSelectedProxyState(
|
||||
required Map<String, String> selectedMap,
|
||||
}) {
|
||||
if (state.proxyName.isEmpty) return state;
|
||||
final index = groups.indexWhere((element) => element.name == state.proxyName);
|
||||
final group = groups.getGroup(state.proxyName);
|
||||
final newState = state.copyWith(group: true);
|
||||
if (index == -1) return newState;
|
||||
final group = groups[index];
|
||||
if (group == null) return newState;
|
||||
final currentSelectedName = group.getCurrentSelectedName(
|
||||
selectedMap[newState.proxyName] ?? '',
|
||||
);
|
||||
|
||||
@@ -58,9 +58,9 @@ extension ProxyCardTypeL10n on ProxyCardType {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
ProxyCardType.expand => appLocalizations.expand,
|
||||
ProxyCardType.shrink => appLocalizations.shrink,
|
||||
ProxyCardType.min => appLocalizations.min,
|
||||
ProxyCardType.loose => appLocalizations.loose,
|
||||
ProxyCardType.standard => appLocalizations.standard,
|
||||
ProxyCardType.compact => appLocalizations.compact,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -243,7 +243,7 @@ enum ProxiesType { tab, list }
|
||||
|
||||
enum ProxiesLayout { loose, standard, tight }
|
||||
|
||||
enum ProxyCardType { standard, compact }
|
||||
enum ProxyCardType { loose, standard, compact }
|
||||
|
||||
enum DnsMode {
|
||||
normal,
|
||||
|
||||
@@ -508,8 +508,12 @@ extension GroupExt on Group {
|
||||
if (type.isComputedSelected) {
|
||||
return realNow.isNotEmpty ? realNow : proxyName;
|
||||
}
|
||||
return proxyName.isNotEmpty ? proxyName : realNow;
|
||||
return proxyName.isNotEmpty && _isMember(proxyName) ? proxyName : realNow;
|
||||
}
|
||||
|
||||
/// A saved pick outlives a subscription update that renames or drops it.
|
||||
bool _isMember(String proxyName) =>
|
||||
all.isEmpty || all.any((proxy) => proxy.name == proxyName);
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -240,54 +240,6 @@ final class TrayTitleStateProvider
|
||||
|
||||
String _$trayTitleStateHash() => r'aacf3779c879f7f1144484a80043679020bf8424';
|
||||
|
||||
@ProviderFor(packageListSelectorState)
|
||||
final packageListSelectorStateProvider = PackageListSelectorStateProvider._();
|
||||
|
||||
final class PackageListSelectorStateProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
PackageListSelectorState,
|
||||
PackageListSelectorState,
|
||||
PackageListSelectorState
|
||||
>
|
||||
with $Provider<PackageListSelectorState> {
|
||||
PackageListSelectorStateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'packageListSelectorStateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$packageListSelectorStateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<PackageListSelectorState> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
PackageListSelectorState create(Ref ref) {
|
||||
return packageListSelectorState(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PackageListSelectorState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PackageListSelectorState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$packageListSelectorStateHash() =>
|
||||
r'1fa2bebbd8ee07910aa8d6e9c5d5d6128df5c13b';
|
||||
|
||||
@ProviderFor(getHotKeyAction)
|
||||
final getHotKeyActionProvider = GetHotKeyActionFamily._();
|
||||
|
||||
@@ -1804,54 +1756,6 @@ final class ProxyStateProvider
|
||||
|
||||
String _$proxyStateHash() => r'76a71ab5da07dca9aeb351282c5c03ab222d0760';
|
||||
|
||||
@ProviderFor(proxiesActionsState)
|
||||
final proxiesActionsStateProvider = ProxiesActionsStateProvider._();
|
||||
|
||||
final class ProxiesActionsStateProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProxiesActionsState,
|
||||
ProxiesActionsState,
|
||||
ProxiesActionsState
|
||||
>
|
||||
with $Provider<ProxiesActionsState> {
|
||||
ProxiesActionsStateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'proxiesActionsStateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$proxiesActionsStateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProxiesActionsState> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProxiesActionsState create(Ref ref) {
|
||||
return proxiesActionsState(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProxiesActionsState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProxiesActionsState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$proxiesActionsStateHash() =>
|
||||
r'84f8a94706233ff5d4b8a456291a4e66c1381c62';
|
||||
|
||||
/// Watching the delay map instead would drop nodes one probe at a time, and
|
||||
/// reading it on any other rebuild would drop them whenever something
|
||||
/// unrelated changed mid-test.
|
||||
@@ -2200,94 +2104,6 @@ final class ProxiesTabControllerStateProvider
|
||||
String _$proxiesTabControllerStateHash() =>
|
||||
r'548db61efef2c47c2694c855436276fcd3529956';
|
||||
|
||||
@ProviderFor(proxyGroupSelectorState)
|
||||
final proxyGroupSelectorStateProvider = ProxyGroupSelectorStateFamily._();
|
||||
|
||||
final class ProxyGroupSelectorStateProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProxyGroupSelectorState,
|
||||
ProxyGroupSelectorState,
|
||||
ProxyGroupSelectorState
|
||||
>
|
||||
with $Provider<ProxyGroupSelectorState> {
|
||||
ProxyGroupSelectorStateProvider._({
|
||||
required ProxyGroupSelectorStateFamily super.from,
|
||||
required (String, String) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'proxyGroupSelectorStateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$proxyGroupSelectorStateHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'proxyGroupSelectorStateProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProxyGroupSelectorState> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProxyGroupSelectorState create(Ref ref) {
|
||||
final argument = this.argument as (String, String);
|
||||
return proxyGroupSelectorState(ref, argument.$1, argument.$2);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProxyGroupSelectorState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProxyGroupSelectorState>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProxyGroupSelectorStateProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$proxyGroupSelectorStateHash() =>
|
||||
r'afa6c749b28aa2a2c4d3b120ab16e46c46306040';
|
||||
|
||||
final class ProxyGroupSelectorStateFamily extends $Family
|
||||
with $FunctionalFamilyOverride<ProxyGroupSelectorState, (String, String)> {
|
||||
ProxyGroupSelectorStateFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'proxyGroupSelectorStateProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ProxyGroupSelectorStateProvider call(String groupName, String query) =>
|
||||
ProxyGroupSelectorStateProvider._(
|
||||
argument: (groupName, query),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'proxyGroupSelectorStateProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(realTestUrl)
|
||||
final realTestUrlProvider = RealTestUrlFamily._();
|
||||
|
||||
@@ -2861,83 +2677,6 @@ final class SelectedProxyNameFamily extends $Family
|
||||
String toString() => r'selectedProxyNameProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(proxyDesc)
|
||||
final proxyDescProvider = ProxyDescFamily._();
|
||||
|
||||
final class ProxyDescProvider
|
||||
extends $FunctionalProvider<String, String, String>
|
||||
with $Provider<String> {
|
||||
ProxyDescProvider._({
|
||||
required ProxyDescFamily super.from,
|
||||
required Proxy super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'proxyDescProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$proxyDescHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'proxyDescProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<String> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
String create(Ref ref) {
|
||||
final argument = this.argument as Proxy;
|
||||
return proxyDesc(ref, argument);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProxyDescProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$proxyDescHash() => r'16dbf0d090ba4699b1a282d804d1e75a9910696f';
|
||||
|
||||
final class ProxyDescFamily extends $Family
|
||||
with $FunctionalFamilyOverride<String, Proxy> {
|
||||
ProxyDescFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'proxyDescProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ProxyDescProvider call(Proxy proxy) =>
|
||||
ProxyDescProvider._(argument: proxy, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'proxyDescProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(needUpdateGroups)
|
||||
final needUpdateGroupsProvider = NeedUpdateGroupsProvider._();
|
||||
|
||||
@@ -3258,103 +2997,6 @@ final class MoreToolsSelectorStateProvider
|
||||
String _$moreToolsSelectorStateHash() =>
|
||||
r'c47987547d0e59da10ec4bbd9c8cf1416f8477f3';
|
||||
|
||||
@ProviderFor(isCurrentPage)
|
||||
final isCurrentPageProvider = IsCurrentPageFamily._();
|
||||
|
||||
final class IsCurrentPageProvider extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
IsCurrentPageProvider._({
|
||||
required IsCurrentPageFamily super.from,
|
||||
required (
|
||||
PageLabel, {
|
||||
bool Function(PageLabel pageLabel, ViewMode viewMode)? handler,
|
||||
})
|
||||
super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'isCurrentPageProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isCurrentPageHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'isCurrentPageProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
final argument =
|
||||
this.argument
|
||||
as (
|
||||
PageLabel, {
|
||||
bool Function(PageLabel pageLabel, ViewMode viewMode)? handler,
|
||||
});
|
||||
return isCurrentPage(ref, argument.$1, handler: argument.handler);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is IsCurrentPageProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$isCurrentPageHash() => r'7c300770aef90da23109d9fcfc3bf26140d8cd08';
|
||||
|
||||
final class IsCurrentPageFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
bool,
|
||||
(
|
||||
PageLabel, {
|
||||
bool Function(PageLabel pageLabel, ViewMode viewMode)? handler,
|
||||
})
|
||||
> {
|
||||
IsCurrentPageFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'isCurrentPageProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
IsCurrentPageProvider call(
|
||||
PageLabel pageLabel, {
|
||||
bool Function(PageLabel pageLabel, ViewMode viewMode)? handler,
|
||||
}) => IsCurrentPageProvider._(
|
||||
argument: (pageLabel, handler: handler),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'isCurrentPageProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(overlayTopOffset)
|
||||
final overlayTopOffsetProvider = OverlayTopOffsetProvider._();
|
||||
|
||||
@@ -3687,7 +3329,7 @@ final class ClashConfigProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$clashConfigHash() => r'5380d3193f9b1fe8fd1f810babb92de70b2d2f89';
|
||||
String _$clashConfigHash() => r'ed8cfcf34801fae6f54b28ddcf5ea31bc49ad0ba';
|
||||
|
||||
final class ClashConfigFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<ClashConfig>, int> {
|
||||
|
||||
@@ -49,22 +49,6 @@ ProxyState proxyState(Ref ref) {
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
ProxiesActionsState proxiesActionsState(Ref ref) {
|
||||
final pageLabel = ref.watch(currentPageLabelProvider);
|
||||
final hasProviders = ref.watch(
|
||||
providersProvider.select((state) => state.isNotEmpty),
|
||||
);
|
||||
final type = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.type),
|
||||
);
|
||||
return ProxiesActionsState(
|
||||
pageLabel: pageLabel,
|
||||
hasProviders: hasProviders,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
/// Watching the delay map instead would drop nodes one probe at a time, and
|
||||
/// reading it on any other rebuild would drop them whenever something
|
||||
/// unrelated changed mid-test.
|
||||
@@ -85,16 +69,19 @@ DelayMap delaysAtLastTestBatch(Ref ref) {
|
||||
|
||||
@riverpod
|
||||
GroupsState visibleGroupsState(Ref ref) {
|
||||
final currentGroups = ref.watch(currentGroupsStateProvider);
|
||||
return _hideTimeoutProxies(ref, ref.watch(currentGroupsStateProvider));
|
||||
}
|
||||
|
||||
GroupsState _hideTimeoutProxies(Ref ref, GroupsState groups) {
|
||||
final hideTimeoutProxies = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.hideTimeoutProxies),
|
||||
);
|
||||
if (!hideTimeoutProxies) {
|
||||
return currentGroups;
|
||||
return groups;
|
||||
}
|
||||
return currentGroups.copyWith(
|
||||
return groups.copyWith(
|
||||
value: computeHideTimeout(
|
||||
groups: currentGroups.value,
|
||||
groups: groups.value,
|
||||
allGroups: ref.watch(groupsProvider),
|
||||
delayMap: ref.watch(delaysAtLastTestBatchProvider),
|
||||
selectedMap: ref.watch(selectedMapProvider),
|
||||
@@ -103,13 +90,15 @@ GroupsState visibleGroupsState(Ref ref) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
@riverpod
|
||||
GroupsState filterGroupsState(Ref ref, String query) {
|
||||
final currentGroups = ref.watch(visibleGroupsStateProvider);
|
||||
final searchQuery = SearchQuery(query);
|
||||
if (searchQuery.isEmpty) {
|
||||
return currentGroups;
|
||||
return ref.watch(visibleGroupsStateProvider);
|
||||
}
|
||||
final currentGroups = ref.watch(currentGroupsStateProvider);
|
||||
final matches = <Proxy, bool>{};
|
||||
final groups = currentGroups.value
|
||||
.map((group) {
|
||||
@@ -124,7 +113,7 @@ GroupsState filterGroupsState(Ref ref, String query) {
|
||||
})
|
||||
.where((group) => group.all.isNotEmpty)
|
||||
.toList();
|
||||
return currentGroups.copyWith(value: groups);
|
||||
return _hideTimeoutProxies(ref, currentGroups.copyWith(value: groups));
|
||||
}
|
||||
|
||||
@riverpod
|
||||
@@ -177,30 +166,14 @@ ProxiesTabControllerState proxiesTabControllerState(Ref ref) {
|
||||
}
|
||||
|
||||
@riverpod
|
||||
ProxyGroupSelectorState proxyGroupSelectorState(
|
||||
Ref ref,
|
||||
String groupName,
|
||||
String query,
|
||||
) {
|
||||
final proxiesStyle = ref.watch(proxiesStyleSettingProvider);
|
||||
final group = ref.watch(
|
||||
visibleGroupsStateProvider.select(
|
||||
(state) => state.value.getGroup(groupName),
|
||||
),
|
||||
bool currentGroupDelayTesting(Ref ref) {
|
||||
final currentGroupName = ref.watch(
|
||||
proxiesTabControllerStateProvider.select((state) => state.currentGroupName),
|
||||
);
|
||||
final sortNum = ref.watch(sortNumProvider);
|
||||
final proxies =
|
||||
group?.all
|
||||
.whereMatches(SearchQuery(query), (proxy) => proxy.searchFields)
|
||||
.toList() ??
|
||||
[];
|
||||
return ProxyGroupSelectorState(
|
||||
testUrl: group?.testUrl,
|
||||
proxiesSortType: proxiesStyle.sortType,
|
||||
proxyCardType: proxiesStyle.cardType,
|
||||
sortNum: sortNum,
|
||||
groupType: group?.type ?? GroupType.Selector,
|
||||
proxies: proxies,
|
||||
return ref.watch(
|
||||
delayTestingGroupsProvider.select(
|
||||
(state) => state.contains(currentGroupName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,20 +253,6 @@ String? selectedProxyName(Ref ref, String groupName) {
|
||||
return group?.getCurrentSelectedName(proxyName ?? '');
|
||||
}
|
||||
|
||||
@riverpod
|
||||
String proxyDesc(Ref ref, Proxy proxy) {
|
||||
final groupTypeNamesList = GroupType.values.map((e) => e.name).toList();
|
||||
if (!groupTypeNamesList.contains(proxy.type)) {
|
||||
return proxy.type;
|
||||
} else {
|
||||
final groups = ref.watch(groupsProvider);
|
||||
final index = groups.indexWhere((element) => element.name == proxy.name);
|
||||
if (index == -1) return proxy.type;
|
||||
final state = ref.watch(realSelectedProxyStateProvider(proxy.name));
|
||||
return "${proxy.type}(${state.proxyName.isNotEmpty ? state.proxyName : '*'})";
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
({bool isProxies, int sortNum, ProxiesSortType sortType}) needUpdateGroups(
|
||||
Ref ref,
|
||||
|
||||
+4
-20
@@ -1716,7 +1716,7 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
|
||||
return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context);
|
||||
}
|
||||
|
||||
static final _labelWidths = <(String, TextStyle, TextScaler), double>{};
|
||||
static final _labelWidths = <(String, TextStyle, TextScaler, bool), double>{};
|
||||
|
||||
/// What the rows would take at their intrinsic width, measured from the
|
||||
/// labels as the rows lay them out.
|
||||
@@ -1724,31 +1724,15 @@ class _CommonPopupMenuState extends State<CommonPopupMenu>
|
||||
final style = AppTextStyle.subheadline.of(context);
|
||||
final root = DefaultTextStyle.of(context).style.merge(style);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final textDirection = Directionality.of(context);
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final boldText = MediaQuery.boldTextOf(context);
|
||||
const limit = _menuMaxWidth - 2 * _cardInset;
|
||||
var width = 0.0;
|
||||
for (final item in items) {
|
||||
if (_labelWidths.length >= 1024) {
|
||||
_labelWidths.clear();
|
||||
}
|
||||
final label = _labelWidths[(item.label, root, textScaler)] ??= () {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
style: root,
|
||||
children: [EmojiText.spanOf(item.label, style)],
|
||||
),
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
locale: locale,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
try {
|
||||
return painter.maxIntrinsicWidth;
|
||||
} finally {
|
||||
painter.dispose();
|
||||
}
|
||||
}();
|
||||
final label = _labelWidths[(item.label, root, textScaler, boldText)] ??=
|
||||
EmojiText.widthOf(context, item.label, root);
|
||||
final checked = item.checked == true;
|
||||
final row =
|
||||
(checked ? _itemArrowPadding : _itemPadding).horizontal +
|
||||
|
||||
@@ -96,6 +96,27 @@ class EmojiText extends StatelessWidget {
|
||||
return TextSpan(children: spans);
|
||||
}
|
||||
|
||||
/// The width [EmojiText] lays [text] out at on one line under [style].
|
||||
static double widthOf(BuildContext context, String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
style: MediaQuery.boldTextOf(context)
|
||||
? style.merge(const TextStyle(fontWeight: FontWeight.bold))
|
||||
: style,
|
||||
children: [spanOf(text, null)],
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
locale: Localizations.maybeLocaleOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
try {
|
||||
return painter.maxIntrinsicWidth;
|
||||
} finally {
|
||||
painter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
|
||||
+135
-142
@@ -1,9 +1,9 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -51,24 +51,24 @@ class ProxyCard extends ConsumerWidget {
|
||||
Measure get measure => globalState.measure;
|
||||
|
||||
Widget _buildProxyNameText(BuildContext context) {
|
||||
if (type == ProxyCardType.min) {
|
||||
if (type == ProxyCardType.compact) {
|
||||
return SizedBox(
|
||||
height: measure.bodyMediumHeight * 1,
|
||||
height: measure.textHeight(AppTextStyle.subheadline) * 1,
|
||||
child: EmojiText(
|
||||
proxy.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium,
|
||||
style: AppTextStyle.subheadline.of(context),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SizedBox(
|
||||
height: measure.bodyMediumHeight * 2,
|
||||
height: measure.textHeight(AppTextStyle.subheadline) * 2,
|
||||
child: EmojiText(
|
||||
proxy.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium,
|
||||
style: AppTextStyle.subheadline.of(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -77,31 +77,72 @@ class ProxyCard extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final measure = globalState.measure;
|
||||
final delayText = _DelayText(proxy: proxy, testUrl: testUrl, type: type);
|
||||
final isLoose = type == ProxyCardType.loose;
|
||||
final delayText = _DelayText(
|
||||
proxy: proxy,
|
||||
testUrl: testUrl,
|
||||
alignment: isLoose ? Alignment.centerLeft : Alignment.centerRight,
|
||||
);
|
||||
final proxyNameText = _buildProxyNameText(context);
|
||||
return Stack(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (_, ref, child) {
|
||||
final selectedProxyName = ref.watch(
|
||||
selectedProxyNameProvider(groupName),
|
||||
final typeStyle = AppTextStyle.footnote
|
||||
.of(context)
|
||||
.copyWith(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: context.colorScheme.onSurface.opacity80,
|
||||
);
|
||||
final details = isLoose
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: measure.textHeight(AppTextStyle.footnote),
|
||||
child: _ProxyDesc(proxy: proxy, style: typeStyle),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
delayText,
|
||||
],
|
||||
)
|
||||
: SizedBox(
|
||||
height: measure.textHeight(AppTextStyle.footnote),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TooltipText(
|
||||
text: Text(proxy.type, maxLines: 1, style: typeStyle),
|
||||
),
|
||||
),
|
||||
delayText,
|
||||
],
|
||||
),
|
||||
);
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final inUse = ref.watch(
|
||||
selectedProxyNameProvider(
|
||||
groupName,
|
||||
).select((name) => name == proxy.name),
|
||||
);
|
||||
final pinned =
|
||||
groupType.isComputedSelected &&
|
||||
ref.watch(
|
||||
proxyNameProvider(groupName).select((name) => name == proxy.name),
|
||||
);
|
||||
return CommonCard(
|
||||
radius: AppCorner.lg,
|
||||
enterActionsOnRight: true,
|
||||
key: key,
|
||||
onPressed: () {
|
||||
selectGroupProxy(
|
||||
ref,
|
||||
groupName: groupName,
|
||||
groupType: groupType,
|
||||
proxyName: proxy.name,
|
||||
);
|
||||
},
|
||||
isSelected: selectedProxyName == proxy.name,
|
||||
child: child!,
|
||||
return CommonCard(
|
||||
radius: AppCorner.lg,
|
||||
enterActionsOnRight: true,
|
||||
key: key,
|
||||
onPressed: () {
|
||||
selectGroupProxy(
|
||||
ref,
|
||||
groupName: groupName,
|
||||
groupType: groupType,
|
||||
proxyName: proxy.name,
|
||||
);
|
||||
},
|
||||
isSelected: inUse,
|
||||
child: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
@@ -109,54 +150,49 @@ class ProxyCard extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
proxyNameText,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Expanded(child: proxyNameText),
|
||||
if (pinned)
|
||||
SelectionBadge.preferred(inUse: inUse)
|
||||
else if (inUse)
|
||||
const SelectionBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (type == ProxyCardType.expand) ...[
|
||||
SizedBox(
|
||||
height: measure.bodySmallHeight,
|
||||
child: _ProxyDesc(proxy: proxy),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
delayText,
|
||||
] else
|
||||
SizedBox(
|
||||
height: measure.bodySmallHeight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TooltipText(
|
||||
text: Text(
|
||||
proxy.type,
|
||||
maxLines: 1,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: context
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.opacity80,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
delayText,
|
||||
],
|
||||
),
|
||||
),
|
||||
details,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (groupType.isComputedSelected)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: _ProxyComputedMark(groupName: groupName, proxy: proxy),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxyDesc extends ConsumerWidget {
|
||||
const _ProxyDesc({required this.proxy, required this.style});
|
||||
|
||||
final Proxy proxy;
|
||||
final TextStyle style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isGroup = GroupType.values.any((type) => type.name == proxy.type);
|
||||
final nodeName = isGroup
|
||||
? ref.watch(
|
||||
realSelectedProxyStateProvider(
|
||||
proxy.name,
|
||||
).select((state) => state.proxyName),
|
||||
)
|
||||
: proxy.name;
|
||||
return EmojiText(
|
||||
nodeName == proxy.name ? proxy.type : '${proxy.type}($nodeName)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,12 +201,12 @@ class _DelayText extends ConsumerStatefulWidget {
|
||||
const _DelayText({
|
||||
required this.proxy,
|
||||
required this.testUrl,
|
||||
required this.type,
|
||||
required this.alignment,
|
||||
});
|
||||
|
||||
final Proxy proxy;
|
||||
final String? testUrl;
|
||||
final ProxyCardType type;
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
ConsumerState<_DelayText> createState() => _DelayTextState();
|
||||
@@ -225,12 +261,8 @@ class _DelayTextState extends ConsumerState<_DelayText> {
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.xs,
|
||||
side: BorderSide(
|
||||
color: context.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
shape: AppShape.xs.copyWith(
|
||||
side: focusRingSide(context.colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -264,28 +296,32 @@ class _DelayTextState extends ConsumerState<_DelayText> {
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
child: SizedBox(
|
||||
height: measure.labelSmallHeight,
|
||||
height: measure.textHeight(AppTextStyle.caption2),
|
||||
child: FadeBox(
|
||||
alignment: widget.type == ProxyCardType.expand
|
||||
? Alignment.centerLeft
|
||||
: Alignment.centerRight,
|
||||
alignment: widget.alignment,
|
||||
child: phase != null || delay == null
|
||||
? SizedBox(
|
||||
height: measure.labelSmallHeight,
|
||||
width: measure.labelSmallHeight,
|
||||
height: measure.textHeight(AppTextStyle.caption2),
|
||||
width: measure.textHeight(AppTextStyle.caption2),
|
||||
child: _withFocusRing(context, switch (phase) {
|
||||
DelayTestPhase.running => const CommonCircleLoading(),
|
||||
DelayTestPhase.running => CommonCircleLoading(
|
||||
size: measure.textHeight(AppTextStyle.caption2),
|
||||
),
|
||||
DelayTestPhase.queued => GlyphIcon(
|
||||
AppGlyphs.clock,
|
||||
size: measure.labelSmallHeight,
|
||||
size: measure.textHeight(AppTextStyle.caption2),
|
||||
color: context.colorScheme.onSurfaceVariant.opacity38,
|
||||
),
|
||||
null => IconButton(
|
||||
tooltip: context.appLocalizations.delayTest,
|
||||
icon: const GlyphIcon(AppGlyphs.bolt),
|
||||
iconSize: measure.labelSmallHeight,
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _handleTestCurrentDelay,
|
||||
null => Tooltip(
|
||||
message: context.appLocalizations.delayTest,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleTestCurrentDelay,
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.bolt,
|
||||
size: measure.textHeight(AppTextStyle.caption2),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -298,10 +334,12 @@ class _DelayTextState extends ConsumerState<_DelayText> {
|
||||
? '$delay ms'
|
||||
: delayFailureText(delay, context.appLocalizations),
|
||||
maxLines: 1,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: context.colorScheme.delayColor(delay),
|
||||
),
|
||||
style: AppTextStyle.caption2
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: context.colorScheme.delayColor(delay),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -311,48 +349,3 @@ class _DelayTextState extends ConsumerState<_DelayText> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxyDesc extends ConsumerWidget {
|
||||
final Proxy proxy;
|
||||
|
||||
const _ProxyDesc({required this.proxy});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final desc = ref.watch(proxyDescProvider(proxy));
|
||||
return EmojiText(
|
||||
desc,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.textTheme.bodySmall?.color?.opacity80,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxyComputedMark extends ConsumerWidget {
|
||||
final String groupName;
|
||||
final Proxy proxy;
|
||||
|
||||
const _ProxyComputedMark({required this.groupName, required this.proxy});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final proxyName = ref.watch(proxyNameProvider(groupName));
|
||||
if (proxyName != proxy.name) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
child: const SelectIcon(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/typography.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const proxyGridSpacing = 8.0;
|
||||
|
||||
double get listHeaderHeight {
|
||||
final measure = globalState.measure;
|
||||
return 20 + measure.titleSmallHeight + 2 + measure.labelSmallHeight + 2;
|
||||
return 20 +
|
||||
measure.textHeight(AppTextStyle.callout) +
|
||||
2 +
|
||||
measure.textHeight(AppTextStyle.caption1) +
|
||||
2;
|
||||
}
|
||||
|
||||
double getItemHeight(ProxyCardType proxyCardType) {
|
||||
final measure = globalState.measure;
|
||||
final baseHeight =
|
||||
16 + measure.bodyMediumHeight * 2 + measure.bodySmallHeight + 8 + 4;
|
||||
16 +
|
||||
measure.textHeight(AppTextStyle.subheadline) * 2 +
|
||||
measure.textHeight(AppTextStyle.footnote) +
|
||||
8 +
|
||||
4;
|
||||
return switch (proxyCardType) {
|
||||
ProxyCardType.expand => baseHeight + measure.labelSmallHeight + 6,
|
||||
ProxyCardType.shrink => baseHeight,
|
||||
ProxyCardType.min => baseHeight - measure.bodyMediumHeight,
|
||||
ProxyCardType.loose =>
|
||||
baseHeight + measure.textHeight(AppTextStyle.caption2) + 6,
|
||||
ProxyCardType.standard => baseHeight,
|
||||
ProxyCardType.compact =>
|
||||
baseHeight - measure.textHeight(AppTextStyle.subheadline),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +52,6 @@ class GroupOffsets {
|
||||
}
|
||||
return offsets[index];
|
||||
}
|
||||
|
||||
Group? groupOf(String groupName) => groups.getGroup(groupName);
|
||||
}
|
||||
|
||||
double? selectedRowOffset({
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _stripInset = 16.0;
|
||||
const _tabSpacing = 8.0;
|
||||
const _tabPadding = 16.0;
|
||||
|
||||
/// The group chips over the proxies tab view. Only the chips in view are
|
||||
/// built: each chip's width comes from its label, so the strip can scroll to
|
||||
/// one it has not built.
|
||||
class GroupTabs extends StatefulWidget {
|
||||
const GroupTabs({super.key, required this.controller, required this.groups});
|
||||
|
||||
final TabController controller;
|
||||
final List<Group> groups;
|
||||
|
||||
@override
|
||||
State<GroupTabs> createState() => _GroupTabsState();
|
||||
}
|
||||
|
||||
class _GroupTabsState extends State<GroupTabs> {
|
||||
static const _fadeExtent = 24.0;
|
||||
|
||||
// A sheet's controller would hand the strip's drags to the sheet.
|
||||
final _scrollController = ScrollController();
|
||||
final _hasMore = ValueNotifier(false);
|
||||
final _fade = ValueNotifier(0.0);
|
||||
late final _selected = ValueNotifier(widget.controller.index);
|
||||
_TabExtents? _extents;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_follow(widget.controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GroupTabs oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.animation?.removeListener(_handleAnimation);
|
||||
_follow(widget.controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.animation?.removeListener(_handleAnimation);
|
||||
_scrollController.dispose();
|
||||
_hasMore.dispose();
|
||||
_fade.dispose();
|
||||
_selected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _follow(TabController controller) {
|
||||
_selected.value = controller.index;
|
||||
controller.animation!.addListener(_handleAnimation);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
|
||||
// A tap animates across every tab between; only a swipe's passes count.
|
||||
void _handleAnimation() {
|
||||
final controller = widget.controller;
|
||||
final index = controller.indexIsChanging
|
||||
? controller.index
|
||||
: controller.animation!.value.round();
|
||||
if (index == _selected.value) {
|
||||
return;
|
||||
}
|
||||
_selected.value = index;
|
||||
_reveal();
|
||||
}
|
||||
|
||||
void _reveal({bool jump = false}) {
|
||||
final extents = _extents;
|
||||
if (!mounted || extents == null || !_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final position = _scrollController.position;
|
||||
final index = _selected.value;
|
||||
if (!position.hasContentDimensions ||
|
||||
index < 0 ||
|
||||
index >= extents.widths.length) {
|
||||
return;
|
||||
}
|
||||
final center =
|
||||
_stripInset + extents.starts[index] + extents.widths[index] / 2;
|
||||
final target = (center - position.viewportDimension / 2).clamp(
|
||||
position.minScrollExtent,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
if (jump) {
|
||||
position.jumpTo(target);
|
||||
} else {
|
||||
position.animateTo(
|
||||
target,
|
||||
duration: commonDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
widget.controller.animateTo(index);
|
||||
}
|
||||
|
||||
bool _handleMetrics(ScrollMetrics metrics) {
|
||||
_fade.value = (metrics.extentAfter / _fadeExtent).clamp(0.0, 1.0);
|
||||
final hasMore = metrics.maxScrollExtent > 0;
|
||||
if (hasMore != _hasMore.value) {
|
||||
_hasMore.value = hasMore;
|
||||
// The more button narrows the tabs it overflows.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Shader _buildEdgeFade(Rect bounds, double fade) {
|
||||
return LinearGradient(
|
||||
begin: AlignmentDirectional.centerStart,
|
||||
end: AlignmentDirectional.centerEnd,
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black.withValues(alpha: 1 - fade),
|
||||
],
|
||||
stops: [max(0.0, 1 - _fadeExtent / bounds.width), 1],
|
||||
).createShader(bounds, textDirection: Directionality.of(context));
|
||||
}
|
||||
|
||||
Widget _buildMoreButton() {
|
||||
return TonalButtonTheme(
|
||||
size: TonalButtonSize.section,
|
||||
child: ElasticPress(
|
||||
child: CommonPopupBox(
|
||||
targetBuilder: (open) => IconButton(
|
||||
tooltip: context.appLocalizations.proxyGroup,
|
||||
onPressed: open,
|
||||
icon: const GlyphIcon(AppGlyphs.chevronDown),
|
||||
),
|
||||
popupBuilder: (_) => CommonPopupMenu(
|
||||
maxVisibleItems: 7,
|
||||
items: [
|
||||
for (final (index, group) in widget.groups.indexed)
|
||||
CommonPopupMenuItem(
|
||||
label: group.name,
|
||||
checked: index == widget.controller.index,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabs(_TabExtents extents, bool hasMore) {
|
||||
final groups = widget.groups;
|
||||
final last = groups.length - 1;
|
||||
return ListView.custom(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: _stripInset,
|
||||
end: hasMore ? 0 : _stripInset,
|
||||
),
|
||||
itemExtentBuilder: (index, _) => index < extents.widths.length
|
||||
? extents.widths[index] + (index < last ? _tabSpacing : 0)
|
||||
: null,
|
||||
childrenDelegate: _TabsDelegate(
|
||||
(_, index) => Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: index < last ? _tabSpacing : 0,
|
||||
),
|
||||
child: _GroupTab(
|
||||
name: groups[index].name,
|
||||
index: index,
|
||||
selected: _selected,
|
||||
onPressed: () => _select(index),
|
||||
),
|
||||
),
|
||||
childCount: groups.length,
|
||||
extent: extents.total,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extents = _extents = _TabExtents.of(context, widget.groups, _extents);
|
||||
return SizedBox(
|
||||
height: TonalButtonSize.section.button,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _hasMore,
|
||||
builder: (_, hasMore, moreButton) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) =>
|
||||
_handleMetrics(notification.metrics),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _fade,
|
||||
builder: (_, fade, child) => ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) =>
|
||||
_buildEdgeFade(bounds, fade),
|
||||
child: child,
|
||||
),
|
||||
child: _buildTabs(extents, hasMore),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
|
||||
child: moreButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _buildMoreButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The width each chip lays its label out at, worked out the way a
|
||||
/// [CommonCard]'s button sizes itself.
|
||||
class _TabExtents {
|
||||
_TabExtents._(this._key, this.widths) : starts = _startsOf(widths);
|
||||
|
||||
final Object _key;
|
||||
final List<double> widths;
|
||||
final List<double> starts;
|
||||
|
||||
double get total => widths.isEmpty ? 0 : starts.last + widths.last;
|
||||
|
||||
static List<double> _startsOf(List<double> widths) {
|
||||
final starts = <double>[];
|
||||
var start = 0.0;
|
||||
for (final width in widths) {
|
||||
starts.add(start);
|
||||
start += width + _tabSpacing;
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
static _TabExtents of(
|
||||
BuildContext context,
|
||||
List<Group> groups,
|
||||
_TabExtents? previous,
|
||||
) {
|
||||
final style = AppTextStyle.subheadline.of(context, emphasized: true);
|
||||
final buttonStyle = FilledButtonTheme.of(context).style;
|
||||
final defaults = const FilledButton(
|
||||
onPressed: null,
|
||||
child: null,
|
||||
).defaultStyleOf(context);
|
||||
final minimumSize = (buttonStyle?.minimumSize ?? defaults.minimumSize)!
|
||||
.resolve(const {})!;
|
||||
final density = (buttonStyle?.visualDensity ?? defaults.visualDensity)!;
|
||||
final adjustment = density.baseSizeAdjustment.dx;
|
||||
final minWidth = max(0.0, minimumSize.width + adjustment);
|
||||
final padding = 2 * (_tabPadding + max(0.0, adjustment));
|
||||
final names = [for (final group in groups) group.name];
|
||||
final key = (
|
||||
_NamesKey(names),
|
||||
style,
|
||||
MediaQuery.boldTextOf(context),
|
||||
MediaQuery.textScalerOf(context),
|
||||
Directionality.of(context),
|
||||
Localizations.maybeLocaleOf(context),
|
||||
minWidth,
|
||||
padding,
|
||||
);
|
||||
if (previous != null && previous._key == key) {
|
||||
return previous;
|
||||
}
|
||||
return _TabExtents._(key, [
|
||||
for (final name in names)
|
||||
max(
|
||||
minWidth,
|
||||
EmojiText.widthOf(context, name, style).ceilToDouble() + padding,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports the strip's whole extent, which a list of varied extents would
|
||||
/// otherwise extrapolate from the chips it has built.
|
||||
class _TabsDelegate extends SliverChildBuilderDelegate {
|
||||
_TabsDelegate(
|
||||
super.builder, {
|
||||
required super.childCount,
|
||||
required this.extent,
|
||||
}) : super(addAutomaticKeepAlives: false);
|
||||
|
||||
final double extent;
|
||||
|
||||
@override
|
||||
double? estimateMaxScrollOffset(
|
||||
int firstIndex,
|
||||
int lastIndex,
|
||||
double leadingScrollOffset,
|
||||
double trailingScrollOffset,
|
||||
) => extent;
|
||||
}
|
||||
|
||||
class _NamesKey {
|
||||
const _NamesKey(this.names);
|
||||
|
||||
final List<String> names;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _NamesKey && stringListEquality.equals(names, other.names);
|
||||
|
||||
@override
|
||||
int get hashCode => stringListEquality.hash(names);
|
||||
}
|
||||
|
||||
/// Rebuilds only when it gains or loses the selection, so a swipe across
|
||||
/// hundreds of groups rebuilds two tabs per step instead of the strip.
|
||||
class _GroupTab extends StatefulWidget {
|
||||
const _GroupTab({
|
||||
required this.name,
|
||||
required this.index,
|
||||
required this.selected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int index;
|
||||
final ValueListenable<int> selected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_GroupTab> createState() => _GroupTabState();
|
||||
}
|
||||
|
||||
class _GroupTabState extends State<_GroupTab> {
|
||||
late bool _isSelected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selected != widget.selected) {
|
||||
oldWidget.selected.removeListener(_handleSelected);
|
||||
widget.selected.addListener(_handleSelected);
|
||||
}
|
||||
_isSelected = widget.selected.value == widget.index;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selected.removeListener(_handleSelected);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSelected() {
|
||||
final isSelected = widget.selected.value == widget.index;
|
||||
if (isSelected != _isSelected) {
|
||||
setState(() => _isSelected = isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _isSelected;
|
||||
final colorScheme = context.colorScheme;
|
||||
return CommonCard(
|
||||
isSelected: selected,
|
||||
fillsSelection: true,
|
||||
shape: AppShape.full,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _tabPadding),
|
||||
onPressed: widget.onPressed,
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: midDuration,
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: EmojiText(widget.name, maxLines: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+483
-199
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -13,15 +12,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'card.dart';
|
||||
import 'common.dart';
|
||||
|
||||
typedef GroupNameProxiesMap = Map<String, List<Proxy>>;
|
||||
|
||||
const _pinnedHeaderGap = 8.0;
|
||||
const _headerInset = 10.0;
|
||||
const _enterStaggerLimit = 8;
|
||||
const _enterStaggerStep = Duration(milliseconds: 20);
|
||||
const _enterSlideBase = 32.0;
|
||||
const _enterSlideStep = 8.0;
|
||||
final _enterWindow = commonDuration + _enterStaggerStep * _enterStaggerLimit;
|
||||
const _headerButtonInset = 4.0;
|
||||
const _groupInset = 16.0;
|
||||
const _liftDistance = 16.0;
|
||||
const _groupMinWidth = 360.0;
|
||||
const _groupMaxColumns = 3;
|
||||
const _lastRowGrowth = _headerInset - proxyGridSpacing;
|
||||
|
||||
class ProxiesListView extends ConsumerStatefulWidget {
|
||||
const ProxiesListView({super.key});
|
||||
@@ -32,10 +30,9 @@ class ProxiesListView extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
late final ScrollController _controller;
|
||||
GroupOffsets _groupOffsets = GroupOffsets.empty;
|
||||
double containerHeight = 0;
|
||||
String? _enterGroupName;
|
||||
Timer? _enterTimer;
|
||||
int _columns = 1;
|
||||
int _groupColumns = 1;
|
||||
double _gap = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -45,163 +42,43 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopEnterAnimated();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEnterAnimated(String groupName) {
|
||||
_enterTimer?.cancel();
|
||||
_enterGroupName = groupName;
|
||||
_enterTimer = Timer(_enterWindow, _stopEnterAnimated);
|
||||
}
|
||||
|
||||
void _stopEnterAnimated() {
|
||||
_enterTimer?.cancel();
|
||||
_enterTimer = null;
|
||||
_enterGroupName = null;
|
||||
}
|
||||
|
||||
void _handleChange(Set<String> currentUnfoldSet, String groupName) {
|
||||
void _handleChange(String groupName) {
|
||||
_autoScrollToGroup(groupName);
|
||||
final tempUnfoldSet = Set<String>.from(currentUnfoldSet);
|
||||
if (tempUnfoldSet.contains(groupName)) {
|
||||
tempUnfoldSet.remove(groupName);
|
||||
_stopEnterAnimated();
|
||||
} else {
|
||||
final tempUnfoldSet = Set<String>.from(ref.read(unfoldSetProvider));
|
||||
if (!tempUnfoldSet.remove(groupName)) {
|
||||
tempUnfoldSet.add(groupName);
|
||||
_startEnterAnimated(groupName);
|
||||
}
|
||||
ref
|
||||
.read(proxiesActionProvider.notifier)
|
||||
.updateCurrentUnfoldSet(tempUnfoldSet);
|
||||
}
|
||||
|
||||
GroupOffsets _getGroupOffsets({
|
||||
required List<Group> groups,
|
||||
required int columns,
|
||||
required Set<String> currentUnfoldSet,
|
||||
required ProxyCardType cardType,
|
||||
}) {
|
||||
GroupOffsets _getGroupOffsets() {
|
||||
final state = ref.read(proxiesListStateProvider);
|
||||
final offsets = <double>[];
|
||||
final rowExtent = getRowExtent(cardType);
|
||||
var currentOffset = 0.0;
|
||||
for (final group in groups) {
|
||||
offsets.add(currentOffset);
|
||||
currentOffset += listHeaderHeight + proxyGridSpacing;
|
||||
if (currentUnfoldSet.contains(group.name)) {
|
||||
final rowCount = (group.all.length + columns - 1) ~/ columns;
|
||||
currentOffset += rowCount * rowExtent;
|
||||
final rowExtent = getRowExtent(state.proxyCardType);
|
||||
final columnOffsets = List.filled(_groupColumns, 0.0);
|
||||
for (final (index, group) in state.groups.indexed) {
|
||||
final column = index % _groupColumns;
|
||||
offsets.add(columnOffsets[column]);
|
||||
columnOffsets[column] += listHeaderHeight + _gap;
|
||||
if (state.currentUnfoldSet.contains(group.name)) {
|
||||
final rowCount = (group.all.length + _columns - 1) ~/ _columns;
|
||||
columnOffsets[column] += _rowsExtent(rowCount, rowExtent);
|
||||
}
|
||||
}
|
||||
return GroupOffsets(groups, offsets);
|
||||
}
|
||||
|
||||
Widget _buildProxyRow({
|
||||
required Group group,
|
||||
required List<Proxy> proxies,
|
||||
required int rowIndex,
|
||||
required int columns,
|
||||
required ProxyCardType cardType,
|
||||
}) {
|
||||
final groupName = group.name;
|
||||
final enterAnimated = _enterGroupName == groupName;
|
||||
final children = proxies.indexed
|
||||
.map<Widget>((entry) {
|
||||
final (columnIndex, proxy) = entry;
|
||||
final card = SizedBox(
|
||||
height: getItemHeight(cardType),
|
||||
child: ProxyCard(
|
||||
testUrl: group.testUrl,
|
||||
type: cardType,
|
||||
groupType: group.type,
|
||||
key: ValueKey('$groupName.${proxy.name}'),
|
||||
proxy: proxy,
|
||||
groupName: groupName,
|
||||
),
|
||||
);
|
||||
if (!enterAnimated) {
|
||||
return Flexible(child: card);
|
||||
}
|
||||
final stagger = min(
|
||||
rowIndex * columns + columnIndex,
|
||||
_enterStaggerLimit,
|
||||
);
|
||||
return Flexible(
|
||||
child: FadeSlideEnterBox(
|
||||
delay: _enterStaggerStep * stagger,
|
||||
distance: _enterSlideBase + _enterSlideStep * stagger,
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
})
|
||||
.fill(columns, filler: (_) => const Flexible(child: SizedBox()))
|
||||
.separated(const SizedBox(width: 8));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 18, right: 18, bottom: 8),
|
||||
child: Row(children: children.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroup(
|
||||
BuildContext context, {
|
||||
required Group group,
|
||||
required Set<String> currentUnfoldSet,
|
||||
required int columns,
|
||||
required ProxyCardType cardType,
|
||||
}) {
|
||||
final groupName = group.name;
|
||||
final isExpand = currentUnfoldSet.contains(groupName);
|
||||
final rows = isExpand
|
||||
? group.all.chunks(columns).toList()
|
||||
: const <List<Proxy>>[];
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
PinnedHeaderSliver(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: SizedBox(
|
||||
height: listHeaderHeight,
|
||||
child: ListHeader(
|
||||
enterAnimated: false,
|
||||
onScrollToSelected: (groupName) {
|
||||
_scrollToGroupSelected(groupName, columns, cardType);
|
||||
},
|
||||
key: ValueKey(groupName),
|
||||
isExpand: isExpand,
|
||||
group: group,
|
||||
onChange: (groupName) {
|
||||
_handleChange(currentUnfoldSet, groupName);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpand)
|
||||
SliverFixedExtentList(
|
||||
itemExtent: getRowExtent(cardType),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(_, index) => _buildProxyRow(
|
||||
group: group,
|
||||
proxies: rows[index],
|
||||
rowIndex: index,
|
||||
columns: columns,
|
||||
cardType: cardType,
|
||||
),
|
||||
childCount: rows.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return GroupOffsets(state.groups, offsets);
|
||||
}
|
||||
|
||||
double _getGroupOffset(String groupName) {
|
||||
if (!_controller.hasClients ||
|
||||
_controller.position.maxScrollExtent == 0 ||
|
||||
_groupOffsets.isEmpty) {
|
||||
if (!_controller.hasClients || _controller.position.maxScrollExtent == 0) {
|
||||
return 0;
|
||||
}
|
||||
return _groupOffsets.offsetOf(groupName);
|
||||
return _getGroupOffsets().offsetOf(groupName);
|
||||
}
|
||||
|
||||
void _scrollToMakeVisibleWithPadding({
|
||||
@@ -247,26 +124,32 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
final pixels = _controller.position.pixels;
|
||||
final offset = _getGroupOffset(groupName);
|
||||
_scrollToMakeVisibleWithPadding(
|
||||
containerHeight: containerHeight,
|
||||
containerHeight: max(
|
||||
_controller.position.viewportDimension -
|
||||
context.appBarInset -
|
||||
_pinnedHeaderGap,
|
||||
0,
|
||||
),
|
||||
pixels: pixels,
|
||||
start: offset,
|
||||
end: offset + listHeaderHeight,
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToGroupSelected(
|
||||
String groupName,
|
||||
int columns,
|
||||
ProxyCardType cardType,
|
||||
) {
|
||||
final proxies = _groupOffsets.groupOf(groupName)?.all;
|
||||
void _scrollToGroupSelected(String groupName) {
|
||||
final cardType = ref.read(proxiesListStateProvider).proxyCardType;
|
||||
final proxies = ref
|
||||
.read(proxiesListStateProvider)
|
||||
.groups
|
||||
.getGroup(groupName)
|
||||
?.all;
|
||||
if (proxies == null) {
|
||||
return;
|
||||
}
|
||||
final rowOffset = selectedRowOffset(
|
||||
proxies: proxies,
|
||||
selectedProxyName: ref.read(selectedProxyNameProvider(groupName)),
|
||||
columns: columns,
|
||||
columns: _columns,
|
||||
rowExtent: getRowExtent(cardType),
|
||||
);
|
||||
if (rowOffset == null) {
|
||||
@@ -280,8 +163,14 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final state = ref.watch(proxiesListStateProvider);
|
||||
final groups = ref.watch(
|
||||
proxiesListStateProvider.select((state) => state.groups),
|
||||
);
|
||||
final cardType = ref.watch(
|
||||
proxiesListStateProvider.select((state) => state.proxyCardType),
|
||||
);
|
||||
ref.watch(themeSettingProvider.select((state) => state.textScale));
|
||||
final viewMode = ref.watch(viewModeProvider);
|
||||
final proxiesLayout = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.layout),
|
||||
);
|
||||
@@ -291,29 +180,41 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
).select((query) => SearchQuery(query).isNotEmpty),
|
||||
);
|
||||
return NullStatusSwitcher(
|
||||
isEmpty: state.groups.isEmpty,
|
||||
isEmpty: groups.isEmpty,
|
||||
isSearching: isSearching,
|
||||
nullStatus: NullStatus(
|
||||
illustration: NullStatusIllustration.proxies,
|
||||
label: appLocalizations.nullTip(appLocalizations.proxies),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
child: WidthBuilder(
|
||||
builder: (_, maxWidth) {
|
||||
final gap = cardSpacingOf(context, viewMode);
|
||||
final width = max(maxWidth - _groupInset * 2, 0.0);
|
||||
final groupColumns = viewMode == ViewMode.mobile
|
||||
? 1
|
||||
: ((width + gap) / (_groupMinWidth + gap)).floor().clamp(
|
||||
1,
|
||||
_groupMaxColumns,
|
||||
);
|
||||
final groupWidth =
|
||||
(width - gap * (groupColumns - 1)) / groupColumns;
|
||||
final columns = getProxiesColumns(
|
||||
max(constraints.maxWidth - 36, 0),
|
||||
max(groupWidth - _headerInset * 2, 0),
|
||||
proxiesLayout,
|
||||
);
|
||||
_groupOffsets = _getGroupOffsets(
|
||||
groups: state.groups,
|
||||
currentUnfoldSet: state.currentUnfoldSet,
|
||||
_columns = columns;
|
||||
_groupColumns = groupColumns;
|
||||
_gap = gap;
|
||||
Widget buildGroup(Group group) => _GroupSliver(
|
||||
key: _GroupSliverKey(group.name),
|
||||
group: group,
|
||||
columns: columns,
|
||||
cardType: state.proxyCardType,
|
||||
);
|
||||
final barInset = MediaQuery.paddingOf(context).top;
|
||||
containerHeight = max(
|
||||
constraints.maxHeight - barInset - _pinnedHeaderGap,
|
||||
0,
|
||||
cardType: cardType,
|
||||
gap: gap,
|
||||
onChange: _handleChange,
|
||||
onScrollToSelected: _scrollToGroupSelected,
|
||||
);
|
||||
final barInset = context.appBarInset;
|
||||
return CommonScrollBar(
|
||||
controller: _controller,
|
||||
thumbVisibility: true,
|
||||
@@ -324,14 +225,43 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
PinnedHeaderSliver(
|
||||
child: SizedBox(height: barInset + _pinnedHeaderGap),
|
||||
),
|
||||
for (final group in state.groups)
|
||||
_buildGroup(
|
||||
context,
|
||||
group: group,
|
||||
currentUnfoldSet: state.currentUnfoldSet,
|
||||
columns: columns,
|
||||
cardType: state.proxyCardType,
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _groupInset,
|
||||
),
|
||||
sliver: groupColumns == 1
|
||||
? SliverMainAxisGroup(
|
||||
slivers: groups.map(buildGroup).toList(),
|
||||
)
|
||||
: SliverCrossAxisGroup(
|
||||
slivers: [
|
||||
for (
|
||||
var column = 0;
|
||||
column < groupColumns;
|
||||
column++
|
||||
) ...[
|
||||
if (column > 0)
|
||||
SliverConstrainedCrossAxis(
|
||||
maxExtent: gap,
|
||||
sliver: const SliverToBoxAdapter(),
|
||||
),
|
||||
SliverCrossAxisExpanded(
|
||||
flex: 1,
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
for (
|
||||
var index = column;
|
||||
index < groups.length;
|
||||
index += groupColumns
|
||||
)
|
||||
buildGroup(groups[index]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 16 + BottomInsetScope.of(context),
|
||||
@@ -348,6 +278,303 @@ class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
|
||||
}
|
||||
}
|
||||
|
||||
double _rowsExtent(int rowCount, double rowExtent) =>
|
||||
rowCount == 0 ? 0 : rowCount * rowExtent + _lastRowGrowth;
|
||||
|
||||
class _GroupSliverKey extends ValueKey<String> {
|
||||
const _GroupSliverKey(super.value);
|
||||
}
|
||||
|
||||
/// Watches only its own group's fold state, so folding one group leaves the
|
||||
/// cards of every other group untouched, and keeps its rows sliver while the
|
||||
/// inputs that shape it are equal.
|
||||
class _GroupSliver extends ConsumerStatefulWidget {
|
||||
const _GroupSliver({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.columns,
|
||||
required this.cardType,
|
||||
required this.gap,
|
||||
required this.onChange,
|
||||
required this.onScrollToSelected,
|
||||
});
|
||||
|
||||
final Group group;
|
||||
final int columns;
|
||||
final ProxyCardType cardType;
|
||||
final double gap;
|
||||
final void Function(String groupName) onChange;
|
||||
final void Function(String groupName) onScrollToSelected;
|
||||
|
||||
@override
|
||||
ConsumerState<_GroupSliver> createState() => _GroupSliverState();
|
||||
}
|
||||
|
||||
class _GroupSliverState extends ConsumerState<_GroupSliver>
|
||||
with SingleTickerProviderStateMixin {
|
||||
Widget? _rows;
|
||||
double _rowExtent = 0;
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
value: ref.read(unfoldSetProvider).contains(widget.group.name) ? 1 : 0,
|
||||
);
|
||||
late final _unfold = UnfoldEntryTransition.curveOf(_controller);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addStatusListener((_) => setState(() {}));
|
||||
ref.listenManual(
|
||||
unfoldSetProvider.select((state) => state.contains(widget.group.name)),
|
||||
(_, isExpand) => _handleUnfold(isExpand),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unfold.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleUnfold(bool isExpand) {
|
||||
_controller.duration = context.motionDuration(
|
||||
UnfoldEntryTransition.duration,
|
||||
);
|
||||
isExpand ? _controller.forward() : _controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_GroupSliver oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.group != oldWidget.group ||
|
||||
widget.columns != oldWidget.columns ||
|
||||
widget.cardType != oldWidget.cardType) {
|
||||
_rows = null;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRow(BuildContext context, List<Proxy> proxies) {
|
||||
final group = widget.group;
|
||||
final cardType = widget.cardType;
|
||||
final groupName = group.name;
|
||||
final children = proxies
|
||||
.map<Widget>(
|
||||
(proxy) => Flexible(
|
||||
child: SizedBox(
|
||||
height: getItemHeight(cardType),
|
||||
child: ProxyCard(
|
||||
testUrl: group.testUrl,
|
||||
type: cardType,
|
||||
groupType: group.type,
|
||||
key: ValueKey('$groupName.${proxy.name}'),
|
||||
proxy: proxy,
|
||||
groupName: groupName,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.fill(widget.columns, filler: (_) => const Flexible(child: SizedBox()))
|
||||
.separated(const SizedBox(width: 8));
|
||||
return SurfaceScope(
|
||||
level: context.raisedSurfaceLevel,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: _headerInset,
|
||||
right: _headerInset,
|
||||
bottom: proxyGridSpacing,
|
||||
),
|
||||
child: Row(children: children.toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRows(double extent) {
|
||||
final rows = widget.group.all.chunks(widget.columns).toList();
|
||||
return SliverFixedExtentList(
|
||||
itemExtent: extent,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildRow(context, rows[index]),
|
||||
childCount: rows.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The card reaches up behind the header's rounded bottom corners, so the
|
||||
/// two read as one while the rows unfold or fold.
|
||||
Widget _buildUnfoldingRows(double extent, ShapeDecoration decoration) {
|
||||
final rows = widget.group.all.chunks(widget.columns).toList();
|
||||
final visible = min(
|
||||
rows.length.toDouble(),
|
||||
MediaQuery.sizeOf(context).height / extent,
|
||||
);
|
||||
final count = min(rows.length, visible.floor() + 1);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: -AppCorner.xl.ap(context),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: DecoratedBox(decoration: decoration),
|
||||
),
|
||||
SizeTransition(
|
||||
sizeFactor: _unfold,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < count; index++)
|
||||
UnfoldEntryTransition(
|
||||
animation: _unfold,
|
||||
index: index,
|
||||
visibleCount: visible,
|
||||
child: SizedBox(
|
||||
height: extent,
|
||||
child: _buildRow(context, rows[index]),
|
||||
),
|
||||
),
|
||||
if (count == rows.length) const SizedBox(height: _lastRowGrowth),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groupName = widget.group.name;
|
||||
final isExpand = ref.watch(
|
||||
unfoldSetProvider.select((state) => state.contains(groupName)),
|
||||
);
|
||||
final unfolded = _controller.isCompleted;
|
||||
Widget? rows;
|
||||
if (!_controller.isDismissed && widget.group.all.isNotEmpty) {
|
||||
final extent = getRowExtent(widget.cardType);
|
||||
final decoration = ShapeDecoration(
|
||||
color: context.raisedSurfaceLevel.colorOf(context.colorScheme),
|
||||
shape: AppShape.vertical(
|
||||
top: AppCorner.none,
|
||||
bottom: AppCorner.xl.ap(context),
|
||||
),
|
||||
);
|
||||
if (!unfolded) {
|
||||
_rows = null;
|
||||
rows = SliverToBoxAdapter(
|
||||
child: _buildUnfoldingRows(extent, decoration),
|
||||
);
|
||||
} else {
|
||||
if (_rows == null || _rowExtent != extent) {
|
||||
_rows = _buildRows(extent);
|
||||
_rowExtent = extent;
|
||||
}
|
||||
rows = DecoratedSliver(
|
||||
decoration: decoration,
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
_rows!,
|
||||
const SliverToBoxAdapter(child: SizedBox(height: _lastRowGrowth)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_rows = null;
|
||||
}
|
||||
Widget buildHeader({double floating = 0, double lift = 0}) => SizedBox(
|
||||
height: listHeaderHeight,
|
||||
child: ListHeader(
|
||||
enterAnimated: false,
|
||||
floating: floating,
|
||||
lift: lift,
|
||||
onScrollToSelected: widget.onScrollToSelected,
|
||||
key: ValueKey(groupName),
|
||||
isExpand: isExpand,
|
||||
unfolded: unfolded,
|
||||
group: widget.group,
|
||||
onChange: widget.onChange,
|
||||
),
|
||||
);
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: widget.gap),
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [_buildPinnedHeader(isExpand, buildHeader), ?rows],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rounds the header and lifts it as its own rows scroll under it, and lets
|
||||
/// the shadow go before the end of the group pushes it out. The page color
|
||||
/// behind its upper half hides the rows that went under it, which would
|
||||
/// otherwise show around its top corners and above it. Every header is laid
|
||||
/// out on every pass, so one beyond the cache area builds nothing.
|
||||
Widget _buildPinnedHeader(
|
||||
bool isExpand,
|
||||
Widget Function({double floating, double lift}) buildHeader,
|
||||
) {
|
||||
final columns = widget.columns;
|
||||
final rowCount = (widget.group.all.length + columns - 1) ~/ columns;
|
||||
final rowsExtent = isExpand ? _rowsExtent(rowCount, _rowExtent) : 0.0;
|
||||
final groupExtent =
|
||||
listHeaderHeight +
|
||||
(_controller.isDismissed
|
||||
? 0.0
|
||||
: _rowsExtent(rowCount, getRowExtent(widget.cardType)));
|
||||
(double, double, double, Color)? headerState;
|
||||
Widget? header;
|
||||
return SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final beyondCache =
|
||||
constraints.remainingCacheExtent <= 0 ||
|
||||
constraints.scrollOffset + constraints.cacheOrigin >= groupExtent;
|
||||
final under = isExpand
|
||||
? constraints.scrollOffset + constraints.overlap
|
||||
: 0.0;
|
||||
final state = beyondCache
|
||||
? null
|
||||
: (
|
||||
(under / _liftDistance).clamp(0.0, 1.0),
|
||||
(min(under, rowsExtent - under) / _liftDistance).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
),
|
||||
isExpand ? constraints.overlap : 0.0,
|
||||
context.surfaceLevel.colorOf(context.colorScheme),
|
||||
);
|
||||
if (header != null && state == headerState) {
|
||||
return header!;
|
||||
}
|
||||
headerState = state;
|
||||
if (state == null) {
|
||||
return header = PinnedHeaderSliver(
|
||||
child: SizedBox(height: listHeaderHeight),
|
||||
);
|
||||
}
|
||||
final (floating, lift, cover, pageColor) = state;
|
||||
return header = PinnedHeaderSliver(
|
||||
child: RepaintBoundary(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: -cover,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cover + listHeaderHeight / 2,
|
||||
child: ColoredBox(color: pageColor),
|
||||
),
|
||||
buildHeader(floating: floating, lift: lift),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListHeader extends ConsumerWidget {
|
||||
final Group group;
|
||||
|
||||
@@ -355,15 +582,28 @@ class ListHeader extends ConsumerWidget {
|
||||
final Function(String groupName) onScrollToSelected;
|
||||
final bool isExpand;
|
||||
|
||||
/// Whether the rows have settled under the header, squaring its bottom.
|
||||
final bool unfolded;
|
||||
|
||||
final bool enterAnimated;
|
||||
|
||||
/// How far the group's own rows have scrolled under the pinned header, as a
|
||||
/// fraction of the distance that rounds its bottom corners.
|
||||
final double floating;
|
||||
|
||||
/// The shadow that lifts the header off the rows under it.
|
||||
final double lift;
|
||||
|
||||
const ListHeader({
|
||||
super.key,
|
||||
this.enterAnimated = true,
|
||||
this.floating = 0,
|
||||
this.lift = 0,
|
||||
required this.group,
|
||||
required this.onChange,
|
||||
required this.onScrollToSelected,
|
||||
required this.isExpand,
|
||||
required this.unfolded,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -372,14 +612,18 @@ class ListHeader extends ConsumerWidget {
|
||||
final isDelayTesting = ref.watch(
|
||||
delayTestingGroupsProvider.select((state) => state.contains(groupName)),
|
||||
);
|
||||
return CommonCard(
|
||||
final colorScheme = context.colorScheme;
|
||||
final level = floating > 0
|
||||
? SurfaceLevel.floatingOf(colorScheme)
|
||||
: context.raisedSurfaceLevel;
|
||||
final radius = AppCorner.xl.ap(context);
|
||||
final card = CommonCard(
|
||||
enterActionsOnRight: true,
|
||||
enterAnimated: enterAnimated,
|
||||
key: key,
|
||||
radius: AppCorner.xl.ap,
|
||||
type: CommonCardType.filled,
|
||||
radius: radius - _headerButtonInset,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_headerInset),
|
||||
padding: const EdgeInsets.all(_headerInset - _headerButtonInset),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -414,9 +658,43 @@ class ListHeader extends ConsumerWidget {
|
||||
onChange(groupName);
|
||||
},
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: level.colorOf(colorScheme),
|
||||
shape: AppShape.vertical(
|
||||
top: radius,
|
||||
bottom: unfolded ? radius * floating : radius,
|
||||
),
|
||||
shadows: lift == 0 ? null : _floatingShadows(colorScheme, lift),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_headerButtonInset),
|
||||
child: SurfaceScope(
|
||||
// CommonCard fills one level above its scope.
|
||||
level: SurfaceLevel.values[level.index - 1],
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<BoxShadow> _floatingShadows(ColorScheme colorScheme, double lift) {
|
||||
final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0;
|
||||
return [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.08 * strength * lift),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.04 * strength * lift),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class _GroupIcon extends ConsumerWidget {
|
||||
const _GroupIcon({required this.src, required this.name});
|
||||
|
||||
@@ -439,16 +717,18 @@ class _GroupIcon extends ConsumerWidget {
|
||||
height: constraints.maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.all(6.ap),
|
||||
padding: EdgeInsets.all(6.ap(context)),
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.secondaryContainer,
|
||||
shape: AppShape.all(AppCorner.xl.ap - _headerInset),
|
||||
color: context.raisedSurfaceLevel.colorOf(
|
||||
context.colorScheme,
|
||||
),
|
||||
shape: AppShape.all(AppCorner.xl.ap(context) - _headerInset),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
size: constraints.maxHeight - 12.ap,
|
||||
color: context.colorScheme.onSecondaryContainer,
|
||||
size: constraints.maxHeight - 12.ap(context),
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: CommonTargetIcon(
|
||||
src: src,
|
||||
@@ -466,7 +746,7 @@ class _GroupIcon extends ConsumerWidget {
|
||||
builder: (_, constraints) {
|
||||
return IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
size: constraints.maxHeight - 16.ap,
|
||||
size: constraints.maxHeight,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)),
|
||||
@@ -492,7 +772,12 @@ class _GroupSummary extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
EmojiText(groupName, style: context.textTheme.titleSmall),
|
||||
EmojiText(
|
||||
groupName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyle.callout.of(context, emphasized: true),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Flexible(flex: 1, child: _SelectedProxyName(groupName: groupName)),
|
||||
],
|
||||
@@ -517,7 +802,7 @@ class _SelectedProxyName extends ConsumerWidget {
|
||||
proxyName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelSmall?.toLight,
|
||||
style: AppTextStyle.caption1.of(context, emphasized: true).toLight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -559,19 +844,18 @@ class _GroupActions extends StatelessWidget {
|
||||
tooltip: context.appLocalizations.delayTest,
|
||||
onPressed: isDelayTesting ? null : onDelayTest,
|
||||
icon: isDelayTesting
|
||||
? SizedBox.square(
|
||||
dimension: TonalButtonSize.compact.icon,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: CommonCircleLoading(),
|
||||
),
|
||||
)
|
||||
? CommonCircleLoading(size: TonalButtonSize.compact.icon)
|
||||
: const GlyphIcon(AppGlyphs.bolt),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(groupType, style: context.textTheme.labelMedium?.toLight),
|
||||
Text(
|
||||
groupType,
|
||||
style: AppTextStyle.caption1
|
||||
.of(context, emphasized: true)
|
||||
.toLight,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ElasticPress(
|
||||
child: IconButton(
|
||||
|
||||
@@ -3,8 +3,6 @@ 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/common.dart';
|
||||
import 'package:fl_clash/models/core.dart';
|
||||
import 'package:fl_clash/models/profile.dart';
|
||||
@@ -13,62 +11,83 @@ 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/ui/icons/icons.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 {
|
||||
const ProvidersView({super.key});
|
||||
class ExternalProvidersSliver extends ConsumerStatefulWidget {
|
||||
final List<ExternalProvider> providers;
|
||||
|
||||
const ExternalProvidersSliver({super.key, required this.providers});
|
||||
|
||||
@override
|
||||
ConsumerState<ProvidersView> createState() => _ProvidersViewState();
|
||||
ConsumerState<ExternalProvidersSliver> createState() =>
|
||||
_ExternalProvidersSliverState();
|
||||
}
|
||||
|
||||
class _ProvidersViewState extends ConsumerState<ProvidersView> {
|
||||
class _ExternalProvidersSliverState
|
||||
extends ConsumerState<ExternalProvidersSliver> {
|
||||
bool _isUpdating = false;
|
||||
|
||||
Future<void> _updateProviders() async {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final providers = ref.read(providersProvider);
|
||||
final proxiesAction = ref.read(proxiesActionProvider.notifier);
|
||||
final List<UpdatingMessage> messages = [];
|
||||
final updateProviders = providers.map<Future>((provider) async {
|
||||
try {
|
||||
final message = await proxiesAction.updateProvider(
|
||||
provider,
|
||||
showLoading: true,
|
||||
);
|
||||
if (message.isNotEmpty) {
|
||||
messages.add(UpdatingMessage(label: provider.name, message: message));
|
||||
}
|
||||
} catch (error) {
|
||||
messages.add(
|
||||
UpdatingMessage(
|
||||
label: provider.name,
|
||||
message: userFacingErrorMessage(error, appLocalizations),
|
||||
),
|
||||
);
|
||||
setState(() => _isUpdating = true);
|
||||
try {
|
||||
await Future.wait(
|
||||
widget.providers.map((provider) async {
|
||||
try {
|
||||
final message = await proxiesAction.updateProvider(
|
||||
provider,
|
||||
showLoading: true,
|
||||
);
|
||||
if (message.isNotEmpty) {
|
||||
messages.add(
|
||||
UpdatingMessage(label: provider.name, message: message),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
messages.add(
|
||||
UpdatingMessage(
|
||||
label: provider.name,
|
||||
message: userFacingErrorMessage(error, appLocalizations),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isUpdating = false);
|
||||
}
|
||||
});
|
||||
await Future.wait(updateProviders);
|
||||
}
|
||||
proxiesAction.updateGroupsDebounce();
|
||||
dialogs.showFailures(messages);
|
||||
}
|
||||
|
||||
List<Widget> _buildSection({
|
||||
required String title,
|
||||
required List<ExternalProvider> providers,
|
||||
}) {
|
||||
if (providers.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(child: ListHeader(title: title)),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final providers = widget.providers;
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListHeader(
|
||||
title: appLocalizations.providers,
|
||||
actions: [
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.sync,
|
||||
tooltip: appLocalizations.update,
|
||||
isLoading: _isUpdating,
|
||||
onPressed: _updateProviders,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (_, index) {
|
||||
final provider = providers[index];
|
||||
@@ -82,47 +101,7 @@ class _ProvidersViewState extends ConsumerState<ProvidersView> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final providers = ref.watch(providersProvider);
|
||||
final proxyProviders = providers
|
||||
.where((item) => item.type == 'Proxy')
|
||||
.toList();
|
||||
final ruleProviders = providers
|
||||
.where((item) => item.type == 'Rule')
|
||||
.toList();
|
||||
return CommonScaffold(
|
||||
actions: [
|
||||
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,
|
||||
),
|
||||
..._buildSection(
|
||||
title: appLocalizations.rules,
|
||||
providers: ruleProviders,
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
title: appLocalizations.providers,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +213,6 @@ class _ProviderItemState extends ConsumerState<ProviderItem> {
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return CommonDialog(
|
||||
backgroundColor: context.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
title: context.appLocalizations.subscriptionInfo,
|
||||
actions: [
|
||||
@@ -305,7 +283,7 @@ class _ProviderItemState extends ConsumerState<ProviderItem> {
|
||||
final previewable = _file != _ProviderFile.binary;
|
||||
return ContextMenuRegion(
|
||||
menuItems: isUpdating ? null : _menuItems(context),
|
||||
child: ExternalProviderItem(
|
||||
child: _ExternalProviderItem(
|
||||
provider: provider,
|
||||
isUpdating: isUpdating,
|
||||
onPressed: isUpdating || !previewable
|
||||
@@ -316,15 +294,14 @@ class _ProviderItemState extends ConsumerState<ProviderItem> {
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalProviderItem extends StatelessWidget {
|
||||
class _ExternalProviderItem extends StatelessWidget {
|
||||
final ExternalProvider provider;
|
||||
final bool isUpdating;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const ExternalProviderItem({
|
||||
super.key,
|
||||
const _ExternalProviderItem({
|
||||
required this.provider,
|
||||
this.isUpdating = false,
|
||||
required this.isUpdating,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@@ -350,11 +327,7 @@ class ExternalProviderItem extends StatelessWidget {
|
||||
children: [...previous, ?current],
|
||||
),
|
||||
child: isUpdating
|
||||
? const SizedBox.square(
|
||||
key: ValueKey('loading'),
|
||||
dimension: 24,
|
||||
child: CommonCircleLoading(),
|
||||
)
|
||||
? const CommonCircleLoading(key: ValueKey('loading'), size: 24)
|
||||
: LastUpdateTimeText(
|
||||
key: const ValueKey('time'),
|
||||
lastUpdateDate: updateAt.microsecondsSinceEpoch > 0
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/proxies/list.dart';
|
||||
import 'package:fl_clash/views/proxies/providers.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -13,7 +11,11 @@ import 'setting.dart';
|
||||
import 'tab.dart';
|
||||
|
||||
class ProxiesView extends ConsumerStatefulWidget {
|
||||
const ProxiesView({super.key});
|
||||
const ProxiesView({super.key, this.picker = false});
|
||||
|
||||
/// Leaves the bar only its search and delay test, for picking a proxy from a
|
||||
/// sheet.
|
||||
final bool picker;
|
||||
|
||||
@override
|
||||
ConsumerState<ProxiesView> createState() => _ProxiesViewState();
|
||||
@@ -21,7 +23,10 @@ class ProxiesView extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
final GlobalKey<ProxiesTabViewState> _proxiesTabKey = GlobalKey();
|
||||
bool _hasProviders = false;
|
||||
|
||||
// The same instance on every build, so the bar changing with the current
|
||||
// group or its delay test leaves the page alone.
|
||||
late final _tabView = ProxiesTabView(key: _proxiesTabKey);
|
||||
bool _isTab = false;
|
||||
|
||||
List<CommonPopupMenuItem> _buildMenuItems(BuildContext context) {
|
||||
@@ -42,19 +47,6 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasProviders)
|
||||
CommonPopupMenuItem(
|
||||
glyph: AppGlyphs.layers,
|
||||
label: appLocalizations.providers,
|
||||
onPressed: () {
|
||||
showExtend(
|
||||
context,
|
||||
builder: (_) {
|
||||
return const ProvidersView();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -62,16 +54,7 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
if (!_isTab) {
|
||||
return null;
|
||||
}
|
||||
final currentGroupName = ref.watch(
|
||||
proxiesTabControllerStateProvider.select(
|
||||
(state) => state.currentGroupName,
|
||||
),
|
||||
);
|
||||
final isDelayTesting = ref.watch(
|
||||
delayTestingGroupsProvider.select(
|
||||
(state) => state.contains(currentGroupName),
|
||||
),
|
||||
);
|
||||
final isDelayTesting = ref.watch(currentGroupDelayTestingProvider);
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.bolt,
|
||||
onPressed: () {
|
||||
@@ -89,16 +72,6 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual(providersProvider.select((state) => state.isNotEmpty), (
|
||||
prev,
|
||||
next,
|
||||
) {
|
||||
if (prev != next) {
|
||||
setState(() {
|
||||
_hasProviders = next;
|
||||
});
|
||||
}
|
||||
}, fireImmediately: true);
|
||||
ref.listenManual(
|
||||
proxiesStyleSettingProvider.select(
|
||||
(state) => state.type == ProxiesType.tab,
|
||||
@@ -120,12 +93,15 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
proxiesStyleSettingProvider.select((state) => state.type),
|
||||
);
|
||||
final isLoading = ref.watch(loadingProvider(LoadingTag.proxies));
|
||||
final picker = widget.picker;
|
||||
final primaryAction = _buildPrimaryAction();
|
||||
return CommonScaffold(
|
||||
isLoading: isLoading,
|
||||
resizeToAvoidBottomInset: false,
|
||||
primaryAction: _buildPrimaryAction(),
|
||||
primaryAction: primaryAction,
|
||||
searchActions: [?primaryAction],
|
||||
iconActions: [
|
||||
if (_isTab)
|
||||
if (_isTab && !picker)
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.locate,
|
||||
onPressed: () {
|
||||
@@ -134,11 +110,11 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
|
||||
tooltip: context.appLocalizations.scrollToSelected,
|
||||
),
|
||||
],
|
||||
menuItems: _buildMenuItems(context),
|
||||
menuItems: picker ? const [] : _buildMenuItems(context),
|
||||
title: context.appLocalizations.proxies,
|
||||
searchState: AppBarSearchState(onSearch: _onSearch),
|
||||
body: switch (proxiesType) {
|
||||
ProxiesType.tab => ProxiesTabView(key: _proxiesTabKey),
|
||||
ProxiesType.tab => _tabView,
|
||||
ProxiesType.list => const ProxiesListView(),
|
||||
},
|
||||
);
|
||||
|
||||
+102
-317
@@ -1,339 +1,124 @@
|
||||
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/config.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ProxiesSetting extends StatelessWidget {
|
||||
class ProxiesSetting extends ConsumerWidget {
|
||||
const ProxiesSetting({super.key});
|
||||
|
||||
Glyph _getIconWithProxiesType(ProxiesType type) {
|
||||
return switch (type) {
|
||||
ProxiesType.tab => AppGlyphs.layoutTabs,
|
||||
ProxiesType.list => AppGlyphs.layoutList,
|
||||
};
|
||||
}
|
||||
|
||||
Glyph _getIconWithProxiesSortType(ProxiesSortType type) {
|
||||
return switch (type) {
|
||||
ProxiesSortType.none => AppGlyphs.sort,
|
||||
ProxiesSortType.delay => AppGlyphs.bolt,
|
||||
ProxiesSortType.name => AppGlyphs.sortAlpha,
|
||||
};
|
||||
}
|
||||
|
||||
String _getStringProxiesSortType(BuildContext context, ProxiesSortType type) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return switch (type) {
|
||||
ProxiesSortType.none => appLocalizations.defaultText,
|
||||
ProxiesSortType.delay => appLocalizations.delay,
|
||||
ProxiesSortType.name => appLocalizations.name,
|
||||
};
|
||||
}
|
||||
|
||||
String getTextForProxiesLayout(
|
||||
BuildContext context,
|
||||
ProxiesLayout proxiesLayout,
|
||||
static ConfigWriter<T> _writer<T>(
|
||||
ProxiesStyleProps Function(ProxiesStyleProps state, T value) update,
|
||||
) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return switch (proxiesLayout) {
|
||||
ProxiesLayout.tight => appLocalizations.tight,
|
||||
ProxiesLayout.standard => appLocalizations.standard,
|
||||
ProxiesLayout.loose => appLocalizations.loose,
|
||||
};
|
||||
}
|
||||
|
||||
String _getTextWithProxiesIconStyle(
|
||||
BuildContext context,
|
||||
ProxiesIconStyle style,
|
||||
) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return switch (style) {
|
||||
ProxiesIconStyle.filled => appLocalizations.iconStyleFilled,
|
||||
ProxiesIconStyle.plain => appLocalizations.iconStylePlain,
|
||||
ProxiesIconStyle.hidden => appLocalizations.iconStyleHidden,
|
||||
};
|
||||
}
|
||||
|
||||
Glyph _getIconWithProxiesLayout(ProxiesLayout proxiesLayout) {
|
||||
return switch (proxiesLayout) {
|
||||
ProxiesLayout.tight => AppGlyphs.columnsThree,
|
||||
ProxiesLayout.standard => AppGlyphs.columnsTwo,
|
||||
ProxiesLayout.loose => AppGlyphs.columnsOne,
|
||||
};
|
||||
}
|
||||
|
||||
Glyph _getIconWithProxyCardType(ProxyCardType type) {
|
||||
return switch (type) {
|
||||
ProxyCardType.expand => AppGlyphs.cardLarge,
|
||||
ProxyCardType.shrink => AppGlyphs.cardMedium,
|
||||
ProxyCardType.min => AppGlyphs.cardSmall,
|
||||
};
|
||||
}
|
||||
|
||||
Glyph _getIconWithProxiesIconStyle(ProxiesIconStyle style) {
|
||||
return switch (style) {
|
||||
ProxiesIconStyle.filled => AppGlyphs.iconTile,
|
||||
ProxiesIconStyle.plain => AppGlyphs.iconPlain,
|
||||
ProxiesIconStyle.hidden => AppGlyphs.eyeOff,
|
||||
};
|
||||
}
|
||||
|
||||
List<Widget> _buildStyleSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
isFirst: true,
|
||||
title: appLocalizations.style,
|
||||
items: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final proxiesType = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.type),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
children: [
|
||||
for (final item in ProxiesType.values)
|
||||
SettingInfoCard(
|
||||
Info(
|
||||
label: item.label,
|
||||
glyph: _getIconWithProxiesType(item),
|
||||
),
|
||||
isSelected: proxiesType == item,
|
||||
onPressed: () {
|
||||
ref.read(proxiesStyleSettingProvider.notifier).update((
|
||||
state,
|
||||
) {
|
||||
return state.copyWith(type: item);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSortSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
title: appLocalizations.sort,
|
||||
items: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final sortType = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.sortType),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
children: [
|
||||
for (final item in ProxiesSortType.values)
|
||||
SettingInfoCard(
|
||||
Info(
|
||||
label: _getStringProxiesSortType(context, item),
|
||||
glyph: _getIconWithProxiesSortType(item),
|
||||
),
|
||||
isSelected: sortType == item,
|
||||
onPressed: () {
|
||||
ref.read(proxiesStyleSettingProvider.notifier).update((
|
||||
state,
|
||||
) {
|
||||
return state.copyWith(sortType: item);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSizeSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
title: appLocalizations.size,
|
||||
items: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final cardType = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.cardType),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
children: [
|
||||
for (final item in ProxyCardType.values)
|
||||
SettingInfoCard(
|
||||
Info(
|
||||
label: item.label,
|
||||
glyph: _getIconWithProxyCardType(item),
|
||||
),
|
||||
isSelected: item == cardType,
|
||||
onPressed: () {
|
||||
ref.read(proxiesStyleSettingProvider.notifier).update((
|
||||
state,
|
||||
) {
|
||||
return state.copyWith(cardType: item);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildLayoutSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
title: appLocalizations.layout,
|
||||
items: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final layout = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.layout),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
children: [
|
||||
for (final item in ProxiesLayout.values)
|
||||
SettingInfoCard(
|
||||
Info(
|
||||
label: getTextForProxiesLayout(context, item),
|
||||
glyph: _getIconWithProxiesLayout(item),
|
||||
),
|
||||
isSelected: item == layout,
|
||||
onPressed: () {
|
||||
ref.watch(proxiesStyleSettingProvider.notifier).update((
|
||||
state,
|
||||
) {
|
||||
return state.copyWith(layout: item);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildGroupStyleSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
title: appLocalizations.iconStyle,
|
||||
items: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final iconStyle = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.iconStyle),
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
children: [
|
||||
for (final item in ProxiesIconStyle.values)
|
||||
SettingInfoCard(
|
||||
Info(
|
||||
label: _getTextWithProxiesIconStyle(context, item),
|
||||
glyph: _getIconWithProxiesIconStyle(item),
|
||||
),
|
||||
isSelected: iconStyle == item,
|
||||
onPressed: () {
|
||||
ref.read(proxiesStyleSettingProvider.notifier).update((
|
||||
state,
|
||||
) {
|
||||
return state.copyWith(iconStyle: item);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildFilterSetting(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return generateSection(
|
||||
title: appLocalizations.filter,
|
||||
items: [
|
||||
ConfigToggleItem(
|
||||
title: (l) => l.hideTimeoutProxies,
|
||||
subtitle: (l) => l.hideTimeoutProxiesDesc,
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.hideTimeoutProxies,
|
||||
),
|
||||
onChanged: (ref, value) {
|
||||
ref
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(hideTimeoutProxies: value));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
return (ref, value) => ref
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => update(state, value));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final isList = ref.watch(
|
||||
proxiesStyleSettingProvider.select(
|
||||
(state) => state.type == ProxiesType.list,
|
||||
),
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(top: context.contentTopPadding, bottom: 32),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(top: context.contentTopPadding, bottom: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
..._buildStyleSetting(context),
|
||||
..._buildSortSetting(context),
|
||||
..._buildLayoutSetting(context),
|
||||
..._buildSizeSetting(context),
|
||||
Consumer(
|
||||
builder: (_, ref, child) {
|
||||
final isList = ref.watch(
|
||||
proxiesStyleSettingProvider.select(
|
||||
(state) => state.type == ProxiesType.list,
|
||||
generateSection(
|
||||
items: [
|
||||
ConfigOptionsItem<ProxiesType>(
|
||||
title: (l) => l.style,
|
||||
options: ProxiesType.values,
|
||||
textBuilder: (type) => type.label,
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.type,
|
||||
),
|
||||
);
|
||||
if (isList) {
|
||||
return child!;
|
||||
}
|
||||
return Container();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [..._buildGroupStyleSetting(context)],
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(type: value),
|
||||
),
|
||||
),
|
||||
ConfigOptionsItem<ProxiesSortType>(
|
||||
title: (l) => l.sort,
|
||||
options: ProxiesSortType.values,
|
||||
textBuilder: (type) => switch (type) {
|
||||
ProxiesSortType.none => appLocalizations.defaultText,
|
||||
ProxiesSortType.delay => appLocalizations.delay,
|
||||
ProxiesSortType.name => appLocalizations.name,
|
||||
},
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.sortType,
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(sortType: value),
|
||||
),
|
||||
),
|
||||
ConfigOptionsItem<ProxiesLayout>(
|
||||
title: (l) => l.layout,
|
||||
options: ProxiesLayout.values,
|
||||
textBuilder: (layout) => switch (layout) {
|
||||
ProxiesLayout.tight => appLocalizations.tight,
|
||||
ProxiesLayout.standard => appLocalizations.standard,
|
||||
ProxiesLayout.loose => appLocalizations.loose,
|
||||
},
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.layout,
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(layout: value),
|
||||
),
|
||||
),
|
||||
ConfigOptionsItem<ProxyCardType>(
|
||||
title: (l) => l.size,
|
||||
options: ProxyCardType.values,
|
||||
textBuilder: (type) => type.label,
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.cardType,
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(cardType: value),
|
||||
),
|
||||
),
|
||||
if (isList)
|
||||
ConfigOptionsItem<ProxiesIconStyle>(
|
||||
title: (l) => l.iconStyle,
|
||||
options: ProxiesIconStyle.values,
|
||||
textBuilder: (style) => switch (style) {
|
||||
ProxiesIconStyle.filled => appLocalizations.iconStyleFilled,
|
||||
ProxiesIconStyle.plain => appLocalizations.iconStylePlain,
|
||||
ProxiesIconStyle.hidden => appLocalizations.iconStyleHidden,
|
||||
},
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.iconStyle,
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(iconStyle: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
generateSection(
|
||||
title: appLocalizations.filter,
|
||||
footer: appLocalizations.hideTimeoutProxiesDesc,
|
||||
items: [
|
||||
ConfigToggleItem(
|
||||
title: (l) => l.hideTimeoutProxies,
|
||||
selector: proxiesStyleSettingProvider.select(
|
||||
(state) => state.hideTimeoutProxies,
|
||||
),
|
||||
onChanged: _writer(
|
||||
(state, value) => state.copyWith(hideTimeoutProxies: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
..._buildFilterSetting(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+45
-172
@@ -3,7 +3,6 @@ import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
@@ -12,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'card.dart';
|
||||
import 'common.dart';
|
||||
import 'group_tabs.dart';
|
||||
|
||||
class ProxiesTabView extends ConsumerStatefulWidget {
|
||||
const ProxiesTabView({super.key});
|
||||
@@ -25,7 +25,6 @@ class ProxiesTabView extends ConsumerStatefulWidget {
|
||||
class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
with TickerProviderStateMixin {
|
||||
TabController? _tabController;
|
||||
final _hasMoreButtonNotifier = ValueNotifier<bool>(false);
|
||||
final Map<String, ScrollController> _scrollControllers = {};
|
||||
int _columns = 1;
|
||||
|
||||
@@ -48,7 +47,6 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
@override
|
||||
void dispose() {
|
||||
_destroyTabController();
|
||||
_hasMoreButtonNotifier.dispose();
|
||||
for (final controller in _scrollControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
@@ -56,7 +54,10 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
}
|
||||
|
||||
ScrollController _scrollControllerOf(String groupName) {
|
||||
return _scrollControllers.putIfAbsent(groupName, ScrollController.new);
|
||||
return _scrollControllers.putIfAbsent(
|
||||
groupName,
|
||||
() => sheetScrollController(context),
|
||||
);
|
||||
}
|
||||
|
||||
// The page of a dropped group still holds its controller until this frame
|
||||
@@ -116,91 +117,6 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
return groups[index];
|
||||
}
|
||||
|
||||
Widget _buildMoreButton() {
|
||||
return Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final isMobileView = ref.watch(isMobileViewProvider);
|
||||
return IconButton(
|
||||
tooltip: context.appLocalizations.more,
|
||||
onPressed: _showMoreMenu,
|
||||
iconSize: 20,
|
||||
icon: isMobileView
|
||||
? const GlyphIcon(AppGlyphs.chevronDown)
|
||||
: const GlyphIcon(AppGlyphs.chevronForward),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A mask rather than a surface-colored cover holds on any page background;
|
||||
// at full scroll the fade lands in the last tab's trailing label padding.
|
||||
Shader _buildTabsMask(Rect bounds, bool hasMore) {
|
||||
final coverStart = bounds.width - kMinInteractiveDimension;
|
||||
final fadeStart = coverStart - kTabLabelPadding.right;
|
||||
if (!hasMore || fadeStart <= 0) {
|
||||
return const LinearGradient(
|
||||
colors: [Colors.black, Colors.black],
|
||||
).createShader(bounds);
|
||||
}
|
||||
return LinearGradient(
|
||||
begin: AlignmentDirectional.centerStart,
|
||||
end: AlignmentDirectional.centerEnd,
|
||||
colors: [Colors.black, Colors.transparent],
|
||||
stops: [fadeStart / bounds.width, coverStart / bounds.width],
|
||||
).createShader(bounds, textDirection: Directionality.of(context));
|
||||
}
|
||||
|
||||
void _showMoreMenu() {
|
||||
showSheet(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return CommonScaffold(
|
||||
body: Builder(
|
||||
builder: (context) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(
|
||||
16,
|
||||
).copyWith(top: context.contentTopPadding),
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
final state = ref.watch(proxiesTabControllerStateProvider);
|
||||
final groupNames = state.groupNames;
|
||||
final currentGroupName = state.currentGroupName;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final groupName in groupNames)
|
||||
SettingTextCard(
|
||||
groupName,
|
||||
onPressed: () {
|
||||
final index = groupNames.indexWhere(
|
||||
(item) => item == groupName,
|
||||
);
|
||||
if (index == -1) return;
|
||||
_tabController?.animateTo(index);
|
||||
ref
|
||||
.read(proxiesActionProvider.notifier)
|
||||
.updateCurrentGroupName(groupName);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
isSelected: groupName == currentGroupName,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
title: context.appLocalizations.proxyGroup,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _tabControllerListener([int? index]) {
|
||||
final group = _getGroup(index ?? _tabController?.index);
|
||||
if (group == null) {
|
||||
@@ -244,7 +160,12 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
ref.watch(themeSettingProvider.select((state) => state.textScale));
|
||||
final state = ref.watch(proxiesTabStateProvider.select((state) => state));
|
||||
final groups = ref.watch(
|
||||
proxiesTabStateProvider.select((state) => state.groups),
|
||||
);
|
||||
final cardType = ref.watch(
|
||||
proxiesTabStateProvider.select((state) => state.proxyCardType),
|
||||
);
|
||||
final proxiesLayout = ref.watch(
|
||||
proxiesStyleSettingProvider.select((state) => state.layout),
|
||||
);
|
||||
@@ -253,94 +174,51 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
|
||||
QueryTag.proxies,
|
||||
).select((query) => SearchQuery(query).isNotEmpty),
|
||||
);
|
||||
final groups = state.groups;
|
||||
_pruneScrollControllers(groups);
|
||||
final controller = _tabController;
|
||||
return NullStatusSwitcher(
|
||||
isEmpty: groups.isEmpty || _tabController == null,
|
||||
isEmpty: groups.isEmpty || controller == null,
|
||||
isSearching: isSearching,
|
||||
nullStatus: NullStatus(
|
||||
illustration: NullStatusIllustration.proxies,
|
||||
label: appLocalizations.nullTip(appLocalizations.proxies),
|
||||
),
|
||||
child: AppBarClearance(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (scrollNotification) {
|
||||
_hasMoreButtonNotifier.value =
|
||||
scrollNotification.metrics.maxScrollExtent > 0;
|
||||
return false;
|
||||
},
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _hasMoreButtonNotifier,
|
||||
builder: (_, hasMore, moreButton) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
children: [
|
||||
ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) =>
|
||||
_buildTabsMask(bounds, hasMore),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: 16,
|
||||
end: hasMore ? kMinInteractiveDimension : 16,
|
||||
),
|
||||
dividerColor: Colors.transparent,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [
|
||||
child: controller == null
|
||||
? const SizedBox.shrink()
|
||||
: AppBarClearance(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 16),
|
||||
child: GroupTabs(controller: controller, groups: groups),
|
||||
),
|
||||
Expanded(
|
||||
child: WidthBuilder(
|
||||
builder: (_, width) {
|
||||
final columns = getProxiesColumns(
|
||||
max(width - 32, 0),
|
||||
proxiesLayout,
|
||||
);
|
||||
_columns = columns;
|
||||
return TabBarView(
|
||||
controller: controller,
|
||||
children: [
|
||||
for (final group in groups)
|
||||
Tab(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return EmojiText(
|
||||
group.name,
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
);
|
||||
},
|
||||
),
|
||||
ProxyGroupView(
|
||||
key: ValueKey(group.name),
|
||||
controller: _scrollControllerOf(group.name),
|
||||
group: group,
|
||||
columns: columns,
|
||||
cardType: cardType,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasMore) moreButton!,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: _buildMoreButton(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final columns = getProxiesColumns(
|
||||
max(constraints.maxWidth - 32, 0),
|
||||
proxiesLayout,
|
||||
);
|
||||
_columns = columns;
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
for (final group in groups)
|
||||
ProxyGroupView(
|
||||
key: ValueKey(group.name),
|
||||
controller: _scrollControllerOf(group.name),
|
||||
group: group,
|
||||
columns: columns,
|
||||
cardType: state.proxyCardType,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -350,8 +228,6 @@ class ProxyGroupView extends ConsumerWidget {
|
||||
final ScrollController controller;
|
||||
final int columns;
|
||||
final ProxyCardType cardType;
|
||||
final bool shrinkWrap;
|
||||
final double topPadding;
|
||||
|
||||
const ProxyGroupView({
|
||||
super.key,
|
||||
@@ -359,8 +235,6 @@ class ProxyGroupView extends ConsumerWidget {
|
||||
required this.controller,
|
||||
required this.columns,
|
||||
required this.cardType,
|
||||
this.shrinkWrap = false,
|
||||
this.topPadding = 16,
|
||||
});
|
||||
|
||||
PageStorageKey _getPageStorageKey(WidgetRef ref) {
|
||||
@@ -382,9 +256,7 @@ class ProxyGroupView extends ConsumerWidget {
|
||||
child: GridView.builder(
|
||||
key: _getPageStorageKey(ref),
|
||||
controller: controller,
|
||||
shrinkWrap: shrinkWrap,
|
||||
padding: EdgeInsets.only(
|
||||
top: topPadding,
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16 + BottomInsetScope.of(context),
|
||||
@@ -396,6 +268,7 @@ class ProxyGroupView extends ConsumerWidget {
|
||||
mainAxisExtent: getItemHeight(cardType),
|
||||
),
|
||||
itemCount: proxies.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (_, index) {
|
||||
final proxy = proxies[index];
|
||||
return ProxyCard(
|
||||
|
||||
@@ -333,6 +333,16 @@ void main() {
|
||||
expect(group.getCurrentSelectedName(''), 'proxy-now');
|
||||
});
|
||||
|
||||
test('Selector group falls back to realNow when the pick left it', () {
|
||||
const group = Group(
|
||||
name: 'sel',
|
||||
type: GroupType.Selector,
|
||||
now: 'proxy-renamed',
|
||||
all: [Proxy(name: 'proxy-renamed', type: 'ss')],
|
||||
);
|
||||
expect(group.getCurrentSelectedName('proxy-old'), 'proxy-renamed');
|
||||
});
|
||||
|
||||
test('Selector group returns empty when both empty', () {
|
||||
const group = Group(name: 'sel', type: GroupType.Selector);
|
||||
expect(group.getCurrentSelectedName(''), '');
|
||||
|
||||
@@ -13,6 +13,8 @@ const _allowed = {
|
||||
'lib/features/editor/completion_popup.dart',
|
||||
// Nor a drag on a popup menu, which stands over the sheet it opened from.
|
||||
'lib/ui/popup.dart',
|
||||
// Nor a drag along the proxies group chips, which run across the sheet.
|
||||
'lib/views/proxies/group_tabs.dart',
|
||||
};
|
||||
|
||||
int _lineOf(String source, int offset) =>
|
||||
|
||||
@@ -167,7 +167,7 @@ void main() {
|
||||
group('ProxiesStyleSetting provider', () {
|
||||
test('default values', () {
|
||||
final value = container.read(proxiesStyleSettingProvider);
|
||||
expect(value.type, ProxiesType.tab);
|
||||
expect(value.type, ProxiesType.list);
|
||||
});
|
||||
|
||||
test('can update state', () {
|
||||
|
||||
@@ -264,6 +264,101 @@ void main() {
|
||||
expect(groups.last.all.map((proxy) => proxy.name), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('hiding timed-out nodes keeps a group listed in another', () {
|
||||
const testUrl = 'https://default.test';
|
||||
container
|
||||
.read(groupsProvider.notifier)
|
||||
.update(
|
||||
(_) => const [
|
||||
Group(
|
||||
name: 'Proxy',
|
||||
type: GroupType.Selector,
|
||||
now: 'a',
|
||||
hidden: false,
|
||||
all: [
|
||||
Proxy(name: 'a', type: 'ss'),
|
||||
Proxy(name: 'HK', type: 'Selector'),
|
||||
],
|
||||
),
|
||||
Group(
|
||||
name: 'HK',
|
||||
type: GroupType.Selector,
|
||||
now: 'b',
|
||||
hidden: true,
|
||||
all: [Proxy(name: 'b', type: 'ss')],
|
||||
),
|
||||
],
|
||||
);
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.rule));
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(testUrl: testUrl));
|
||||
container
|
||||
.read(delayDataSourceProvider.notifier)
|
||||
.setDelay(const Delay(url: testUrl, name: 'b', value: -1));
|
||||
container
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(hideTimeoutProxies: true));
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(visibleGroupsStateProvider)
|
||||
.value
|
||||
.single
|
||||
.all
|
||||
.map((proxy) => proxy.name),
|
||||
['a', 'HK'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a search whose matches all timed out keeps them and their group', () {
|
||||
const testUrl = 'https://default.test';
|
||||
container
|
||||
.read(groupsProvider.notifier)
|
||||
.update(
|
||||
(_) => const [
|
||||
Group(
|
||||
name: 'Proxy',
|
||||
type: GroupType.Selector,
|
||||
now: 'JP',
|
||||
hidden: false,
|
||||
all: [
|
||||
Proxy(name: 'HK 01', type: 'ss'),
|
||||
Proxy(name: 'HK 02', type: 'ss'),
|
||||
Proxy(name: 'JP', type: 'ss'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
container
|
||||
.read(patchClashConfigProvider.notifier)
|
||||
.update((state) => state.copyWith(mode: Mode.rule));
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(testUrl: testUrl));
|
||||
container
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(hideTimeoutProxies: true));
|
||||
final delays = container.read(delayDataSourceProvider.notifier);
|
||||
delays.setDelay(const Delay(url: testUrl, name: 'HK 01', value: -1));
|
||||
delays.setDelay(const Delay(url: testUrl, name: 'HK 02', value: -1));
|
||||
|
||||
List<String> names(String query) => [
|
||||
for (final proxy
|
||||
in container.read(filterGroupsStateProvider(query)).value.single.all)
|
||||
proxy.name,
|
||||
];
|
||||
|
||||
expect(names(''), ['JP']);
|
||||
expect(names('hk'), ['HK 01', 'HK 02']);
|
||||
|
||||
delays.setDelay(const Delay(url: testUrl, name: 'HK 02', value: 80));
|
||||
container.read(sortNumProvider.notifier).add();
|
||||
expect(names('hk'), ['HK 02']);
|
||||
});
|
||||
|
||||
test('navigation providers select items for width and current page', () {
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
@@ -313,11 +408,6 @@ void main() {
|
||||
|
||||
final dashboard = container.read(dashboardStateProvider);
|
||||
expect(dashboard.dashboardWidgets, isNotEmpty);
|
||||
|
||||
final actions = container.read(proxiesActionsStateProvider);
|
||||
expect(actions.pageLabel, PageLabel.dashboard);
|
||||
expect(actions.hasProviders, isFalse);
|
||||
expect(actions.type, ProxiesType.tab);
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -375,18 +465,6 @@ void main() {
|
||||
final controller = container.read(proxiesTabControllerStateProvider);
|
||||
expect(controller.groupNames, ['Group B']);
|
||||
expect(controller.currentGroupName, 'Group B');
|
||||
|
||||
final selector = container.read(
|
||||
proxyGroupSelectorStateProvider('Group A', 'be'),
|
||||
);
|
||||
expect(selector.proxies.single.name, 'Beta');
|
||||
expect(selector.groupType, GroupType.Selector);
|
||||
|
||||
final missing = container.read(
|
||||
proxyGroupSelectorStateProvider('Missing', ''),
|
||||
);
|
||||
expect(missing.proxies, isEmpty);
|
||||
expect(missing.groupType, GroupType.Selector);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -579,18 +657,6 @@ void main() {
|
||||
'Leaf',
|
||||
);
|
||||
expect(container.read(delayProvider(proxyName: 'Selector')), 42);
|
||||
expect(
|
||||
container.read(
|
||||
proxyDescProvider(const Proxy(name: 'Selector', type: 'Selector')),
|
||||
),
|
||||
'Selector(Leaf)',
|
||||
);
|
||||
expect(
|
||||
container.read(
|
||||
proxyDescProvider(const Proxy(name: 'Leaf', type: 'Direct')),
|
||||
),
|
||||
'Direct',
|
||||
);
|
||||
});
|
||||
|
||||
test('tray delays follow the listed groups and their test URLs', () {
|
||||
@@ -680,16 +746,6 @@ void main() {
|
||||
container.read(realTestUrlProvider('https://custom.test')),
|
||||
'https://custom.test',
|
||||
);
|
||||
expect(container.read(isCurrentPageProvider(PageLabel.dashboard)), isTrue);
|
||||
expect(
|
||||
container.read(
|
||||
isCurrentPageProvider(
|
||||
PageLabel.logs,
|
||||
handler: (_, viewMode) => viewMode == ViewMode.mobile,
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a color scheme outlives the provider that generated it', () {
|
||||
@@ -711,22 +767,7 @@ void main() {
|
||||
expect(container.read(provider), isNot(same(first)));
|
||||
});
|
||||
|
||||
test('package, hotkey, profile, and extend providers expose defaults', () {
|
||||
const package = Package(
|
||||
packageName: 'app.example',
|
||||
label: 'Example',
|
||||
system: false,
|
||||
internet: true,
|
||||
lastUpdateTime: 1,
|
||||
);
|
||||
container.read(packagesProvider.notifier).update((_) => [package]);
|
||||
final packageList = container.read(packageListSelectorStateProvider);
|
||||
expect(packageList.packages, [package]);
|
||||
expect(
|
||||
packageList.accessControlProps,
|
||||
container.read(vpnSettingProvider).accessControlProps,
|
||||
);
|
||||
|
||||
test('hotkey, profile, and extend providers expose defaults', () {
|
||||
const action = HotKeyAction(
|
||||
action: HotAction.start,
|
||||
key: 1,
|
||||
|
||||
@@ -18,13 +18,11 @@ void main() {
|
||||
|
||||
expect(offsets.offsetOf('b'), 0);
|
||||
expect(offsets.offsetOf('d'), 120);
|
||||
expect(offsets.groupOf('d')?.name, 'd');
|
||||
});
|
||||
|
||||
test('a group the measured list does not carry falls back to the top', () {
|
||||
final offsets = GroupOffsets([_group('b')], const [0]);
|
||||
|
||||
expect(offsets.offsetOf('a'), 0);
|
||||
expect(offsets.groupOf('a'), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import '../helpers/test_profiles.dart';
|
||||
void main() {
|
||||
testWidgets('the list scrolls its rows under the bar, pinning the header '
|
||||
'at its foot', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1000);
|
||||
tester.view.physicalSize = const Size(500, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(1400, 1000));
|
||||
.update((_) => const Size(500, 1000));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
@@ -83,4 +83,73 @@ void main() {
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('builds only the headers near the view and pins a far one '
|
||||
'once its rows reach the bar', (tester) async {
|
||||
tester.view.physicalSize = const Size(500, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final profile = Profile.normal().copyWith(unfoldSet: {'Far'});
|
||||
final groups = [
|
||||
for (var index = 0; index < 40; index++)
|
||||
Group(name: 'G$index', type: GroupType.Selector, hidden: false),
|
||||
Group(
|
||||
name: 'Far',
|
||||
type: GroupType.Selector,
|
||||
hidden: false,
|
||||
all: List.generate(
|
||||
160,
|
||||
(index) => Proxy(name: 'Proxy $index', type: 'Direct'),
|
||||
),
|
||||
),
|
||||
];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
currentGroupsStateProvider.overrideWithValue(
|
||||
GroupsState(value: groups),
|
||||
),
|
||||
groupsProvider.overrideWithValue(groups),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(viewSizeProvider.notifier)
|
||||
.update((_) => const Size(500, 1000));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: ProxiesView()),
|
||||
),
|
||||
);
|
||||
container
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(type: ProxiesType.list));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byType(ListHeader, skipOffstage: false).evaluate().length,
|
||||
lessThan(groups.length ~/ 2),
|
||||
);
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).first)
|
||||
.position;
|
||||
position.jumpTo(position.maxScrollExtent - 1000);
|
||||
await tester.pump();
|
||||
|
||||
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
|
||||
final far = find.ancestor(
|
||||
of: find.text('Far'),
|
||||
matching: find.byType(ListHeader),
|
||||
);
|
||||
expect(tester.getRect(far).top, barBottom + 8);
|
||||
expect(find.text('G0', skipOffstage: false), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/controller.dart';
|
||||
import 'package:fl_clash/core/interface.dart';
|
||||
import 'package:fl_clash/core/method.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/app.dart';
|
||||
import 'package:fl_clash/providers/core.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/proxies/providers.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -21,9 +22,25 @@ import 'package:path/path.dart' hide context;
|
||||
import '../helpers/context_menu.dart';
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
import '../plugins/code_forge/support.dart' show initEditorNative;
|
||||
|
||||
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
|
||||
|
||||
class _ProvidersSection extends ConsumerWidget {
|
||||
const _ProvidersSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExternalProvidersSliver(providers: ref.watch(providersProvider)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExternalProvider _provider(
|
||||
String name, {
|
||||
String type = 'Proxy',
|
||||
@@ -50,17 +67,15 @@ void main() {
|
||||
late _MockCoreHandlerInterface core;
|
||||
late Directory home;
|
||||
|
||||
setUpAll(() {
|
||||
setUpAll(() async {
|
||||
await initEditorNative();
|
||||
core = _MockCoreHandlerInterface();
|
||||
CoreController.resetInstance();
|
||||
CoreController.test(core);
|
||||
home = Directory.systemTemp.createTempSync('flclash-providers-');
|
||||
});
|
||||
|
||||
setUp(() => reset(core));
|
||||
|
||||
tearDownAll(() {
|
||||
CoreController.resetInstance();
|
||||
if (home.existsSync()) home.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
@@ -78,7 +93,11 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).update((_) => size);
|
||||
@@ -94,11 +113,18 @@ void main() {
|
||||
return container;
|
||||
}
|
||||
|
||||
Future<void> pump(WidgetTester tester, ProviderContainer container) async {
|
||||
Future<void> pump(
|
||||
WidgetTester tester,
|
||||
ProviderContainer container, {
|
||||
bool withStatusManager = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: ProvidersView()),
|
||||
child: TestApp(
|
||||
withStatusManager: withStatusManager,
|
||||
child: const _ProvidersSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -119,6 +145,14 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// The editor blinks its caret for as long as it is open, so a page holding
|
||||
// it never settles.
|
||||
Future<void> pumpTransitions(WidgetTester tester) async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Reading and writing the provider file runs on the real event loop,
|
||||
// outside fake-async, so pump in rounds until the flow it feeds arrives.
|
||||
Future<void> pumpUntil(
|
||||
@@ -130,8 +164,9 @@ void main() {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (finder.evaluate().isNotEmpty == found) {
|
||||
await pumpTransitions(tester);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -166,34 +201,34 @@ void main() {
|
||||
return tester.widget<EditorPage>(find.byType(EditorPage));
|
||||
}
|
||||
|
||||
testWidgets('splits providers into proxy and rule sections', (tester) async {
|
||||
testWidgets('lists proxy and rule providers under one header', (
|
||||
tester,
|
||||
) async {
|
||||
final container = containerFor(tester, [
|
||||
_provider('proxy-a'),
|
||||
_provider('rule-a', type: 'Rule'),
|
||||
]);
|
||||
await pump(tester, container);
|
||||
|
||||
final l10n = currentAppLocalizations;
|
||||
expect(find.text(l10n.proxies), findsOne);
|
||||
expect(find.text(l10n.rules), findsOne);
|
||||
expect(find.byType(ListHeader), findsOne);
|
||||
expect(
|
||||
find.widgetWithText(ListHeader, currentAppLocalizations.providers),
|
||||
findsOne,
|
||||
);
|
||||
expect(find.text('proxy-a'), findsOne);
|
||||
expect(find.text('rule-a'), findsOne);
|
||||
expect(tester.takeException(), null);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'renders virtualized list using CustomScrollView and SliverList',
|
||||
(tester) async {
|
||||
final providers = List.generate(50, (i) => _provider('proxy-$i'));
|
||||
final container = containerFor(tester, providers);
|
||||
await pump(tester, container);
|
||||
testWidgets('builds only the rows in view', (tester) async {
|
||||
final providers = List.generate(50, (i) => _provider('proxy-$i'));
|
||||
final container = containerFor(tester, providers);
|
||||
await pump(tester, container);
|
||||
|
||||
expect(find.byType(CustomScrollView), findsOneWidget);
|
||||
expect(find.byType(SliverList), findsOneWidget);
|
||||
expect(find.text('proxy-0'), findsOneWidget);
|
||||
expect(find.text('proxy-49'), findsNothing);
|
||||
},
|
||||
);
|
||||
expect(find.byType(SliverList), findsOneWidget);
|
||||
expect(find.text('proxy-0'), findsOneWidget);
|
||||
expect(find.text('proxy-49'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows a subscription expiry beside the name', (tester) async {
|
||||
final container = containerFor(tester, [
|
||||
@@ -386,7 +421,7 @@ void main() {
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('the toolbar action refreshes every provider', (tester) async {
|
||||
testWidgets('the header action refreshes every provider', (tester) async {
|
||||
final container = containerFor(tester, [
|
||||
_provider('proxy-a'),
|
||||
_provider('rule-a', type: 'Rule'),
|
||||
@@ -410,17 +445,36 @@ void main() {
|
||||
await tester.tap(find.byGlyph(AppGlyphs.sync));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CommonCircleLoading), findsOne);
|
||||
final header = find.byType(ListHeader);
|
||||
final pendingRow = find.ancestor(
|
||||
of: find.text('proxy-a'),
|
||||
matching: find.byType(DecorationListItem),
|
||||
);
|
||||
for (final pending in [header, pendingRow]) {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: pending,
|
||||
matching: find.byType(CommonCircleLoading),
|
||||
),
|
||||
findsOne,
|
||||
);
|
||||
}
|
||||
await tester.tap(find.byTooltip(currentAppLocalizations.update));
|
||||
await tester.pump();
|
||||
|
||||
completer.complete('');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.byType(CommonCircleLoading), findsNothing);
|
||||
expect(find.byGlyph(AppGlyphs.sync), findsOne);
|
||||
verify(() => core.updateExternalProvider('proxy-a')).called(1);
|
||||
verify(() => core.updateExternalProvider('rule-a')).called(1);
|
||||
});
|
||||
|
||||
testWidgets('the toolbar classifies Core request failures', (tester) async {
|
||||
testWidgets('the header action classifies Core request failures', (
|
||||
tester,
|
||||
) async {
|
||||
final container = containerFor(tester, [_provider('proxy-a')]);
|
||||
when(() => core.updateExternalProvider('proxy-a')).thenThrow(
|
||||
const CoreMethodException(
|
||||
@@ -431,13 +485,7 @@ void main() {
|
||||
when(
|
||||
() => core.getProxies(),
|
||||
).thenAnswer((_) async => const ProxiesData(proxies: {}, all: []));
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(withStatusManager: true, child: ProvidersView()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await pump(tester, container, withStatusManager: true);
|
||||
|
||||
await tester.tap(find.byGlyph(AppGlyphs.sync));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -588,7 +636,7 @@ void main() {
|
||||
|
||||
expect(find.text('invalid payload'), findsOneWidget);
|
||||
await tester.tap(find.text(currentAppLocalizations.confirm));
|
||||
await tester.pumpAndSettle();
|
||||
await pumpTransitions(tester);
|
||||
expect(find.byType(EditorPage), findsOneWidget);
|
||||
verifyNever(() => core.getExternalProvider('rejected'));
|
||||
});
|
||||
@@ -607,10 +655,10 @@ void main() {
|
||||
expect(await editor.onPop!(context, 'leaving', 'payload:\n'), isTrue);
|
||||
|
||||
final popped = editor.onPop!(context, 'leaving', 'payload: changed\n');
|
||||
await tester.pumpAndSettle();
|
||||
await pumpTransitions(tester);
|
||||
expect(find.text(currentAppLocalizations.saveChanges), findsOneWidget);
|
||||
await tester.tap(find.text(currentAppLocalizations.cancel));
|
||||
await tester.pumpAndSettle();
|
||||
await pumpTransitions(tester);
|
||||
|
||||
expect(await popped, isTrue);
|
||||
expect(file.readAsStringSync(), 'payload:\n');
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/proxies/card.dart';
|
||||
import 'package:fl_clash/views/proxies/list.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart' hide ListHeader;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -35,19 +38,24 @@ void main() {
|
||||
Future<void> pumpList(
|
||||
WidgetTester tester, {
|
||||
Map<String, String> selectedMap = const {},
|
||||
ViewMode viewMode = ViewMode.mobile,
|
||||
List<Group>? groups,
|
||||
Set<String> unfoldSet = const {'G0', 'G1', 'G2'},
|
||||
ValueListenable<Brightness>? brightness,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final groups = [_group('G0', 40), _group('G1', 30), _group('G2', 60)];
|
||||
groups ??= [_group('G0', 40), _group('G1', 30), _group('G2', 60)];
|
||||
final profile = Profile.normal().copyWith(
|
||||
selectedMap: selectedMap,
|
||||
unfoldSet: {'G0', 'G1', 'G2'},
|
||||
unfoldSet: unfoldSet,
|
||||
);
|
||||
_RecordingProxiesAction.tested.clear();
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
viewModeProvider.overrideWithValue(viewMode),
|
||||
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
||||
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
||||
currentGroupsStateProvider.overrideWithValue(
|
||||
@@ -73,7 +81,16 @@ void main() {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const ProxiesListView(),
|
||||
child: brightness == null
|
||||
? const ProxiesListView()
|
||||
: ValueListenableBuilder(
|
||||
valueListenable: brightness,
|
||||
builder: (_, brightness, child) => Theme(
|
||||
data: ThemeData(brightness: brightness),
|
||||
child: child!,
|
||||
),
|
||||
child: const ProxiesListView(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -90,20 +107,35 @@ void main() {
|
||||
matching: find.byTooltip(tooltip),
|
||||
);
|
||||
|
||||
Finder scrollable() => find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first;
|
||||
|
||||
Future<void> revealHeaderOf(WidgetTester tester, String groupName) async {
|
||||
await tester.scrollUntilVisible(
|
||||
actionOf(groupName, 'Scroll to selected'),
|
||||
200,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
scrollable: scrollable(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
ShapeDecoration surfaceOf(WidgetTester tester, String groupName) =>
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find
|
||||
.descendant(
|
||||
of: headerOf(groupName),
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.decoration
|
||||
as ShapeDecoration;
|
||||
|
||||
testWidgets('scroll to selected lands the row under the pinned header', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -115,7 +147,168 @@ void main() {
|
||||
|
||||
final header = tester.getRect(headerOf('G1'));
|
||||
final card = tester.getRect(find.byKey(const ValueKey('G1.G1-25')).first);
|
||||
expect(card.top, header.bottom + 8);
|
||||
expect(card.top, header.bottom);
|
||||
});
|
||||
|
||||
testWidgets('folding a group leaves the cards of the others as they were', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpList(tester);
|
||||
final card = find.byKey(const ValueKey('G0.G0-0')).first;
|
||||
final before = tester.widget(card);
|
||||
|
||||
container.read(proxiesActionProvider.notifier).updateCurrentUnfoldSet({
|
||||
'G0',
|
||||
'G1',
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget(card), same(before));
|
||||
});
|
||||
|
||||
testWidgets('a group unfolds its rows from under its header and folds them '
|
||||
'back', (tester) async {
|
||||
await pumpList(tester);
|
||||
container.read(proxiesActionProvider.notifier).updateCurrentUnfoldSet({
|
||||
'G1',
|
||||
'G2',
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
final radius = AppCorner.xl.ap(tester.element(find.text('G0')));
|
||||
final unfolding = find.byType(UnfoldEntryTransition);
|
||||
final cards = find.byWidgetPredicate(
|
||||
(widget) => widget is ProxyCard && widget.groupName == 'G0',
|
||||
);
|
||||
BorderRadiusGeometry headerCorners() =>
|
||||
(surfaceOf(tester, 'G0').shape as RoundedSuperellipseBorder)
|
||||
.borderRadius;
|
||||
|
||||
await tester.tap(find.text('G0'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
expect(unfolding, findsWidgets);
|
||||
expect(headerCorners(), AppRadius.all(radius));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(unfolding, findsNothing);
|
||||
expect(cards, findsWidgets);
|
||||
expect(headerCorners(), AppRadius.vertical(top: radius));
|
||||
|
||||
await tester.tap(find.text('G0'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
expect(unfolding, findsWidgets);
|
||||
expect(headerCorners(), AppRadius.all(radius));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(cards, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the pinned header floats over its rows and leaves with the '
|
||||
'bottom of its group card', (tester) async {
|
||||
await pumpList(tester);
|
||||
expect(surfaceOf(tester, 'G0').shadows, isNull);
|
||||
|
||||
final position = tester.state<ScrollableState>(scrollable()).position;
|
||||
position.jumpTo(200);
|
||||
await tester.pump();
|
||||
final floating = surfaceOf(tester, 'G0');
|
||||
expect(floating.shadows, isNotEmpty);
|
||||
expect(
|
||||
(floating.shape as RoundedSuperellipseBorder).borderRadius,
|
||||
AppRadius.all(AppCorner.xl.ap(tester.element(headerOf('G0')))),
|
||||
);
|
||||
|
||||
final pinnedTop = tester.getRect(headerOf('G0')).top;
|
||||
while (headerOf('G1').evaluate().isEmpty) {
|
||||
position.jumpTo(position.pixels + 100);
|
||||
await tester.pump();
|
||||
}
|
||||
final nextTop = tester.getRect(headerOf('G1')).top;
|
||||
position.jumpTo(position.pixels + nextTop - pinnedTop - 20);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
tester.getRect(headerOf('G0')).bottom,
|
||||
tester.getRect(headerOf('G1')).top -
|
||||
cardSpacingOf(tester.element(headerOf('G1')), ViewMode.mobile),
|
||||
);
|
||||
expect(surfaceOf(tester, 'G0').shadows, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a folded pinned header takes the page color of a new theme', (
|
||||
tester,
|
||||
) async {
|
||||
final brightness = ValueNotifier(Brightness.light);
|
||||
addTearDown(brightness.dispose);
|
||||
await pumpList(tester, unfoldSet: const {}, brightness: brightness);
|
||||
|
||||
brightness.value = Brightness.dark;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final header = find.ancestor(
|
||||
of: headerOf('G0'),
|
||||
matching: find.byType(PinnedHeaderSliver),
|
||||
);
|
||||
final context = tester.element(header);
|
||||
final cover = tester.widget<ColoredBox>(
|
||||
find.descendant(of: header, matching: find.byType(ColoredBox)).first,
|
||||
);
|
||||
expect(context.colorScheme.brightness, Brightness.dark);
|
||||
expect(cover.color, context.surfaceLevel.colorOf(context.colorScheme));
|
||||
});
|
||||
|
||||
testWidgets('the last row sits as far from the bottom of its group card as '
|
||||
'from its sides', (tester) async {
|
||||
await pumpList(tester);
|
||||
final lastCard = find.byWidgetPredicate(
|
||||
(widget) => widget is ProxyCard && widget.proxy.name == 'G0-39',
|
||||
);
|
||||
await tester.scrollUntilVisible(lastCard, 200, scrollable: scrollable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final header = tester.getRect(headerOf('G0'));
|
||||
final card = tester.getRect(lastCard);
|
||||
final nextHeader = tester.getRect(headerOf('G1'));
|
||||
final sideInset = header.right - card.right;
|
||||
expect(
|
||||
nextHeader.top -
|
||||
cardSpacingOf(tester.element(headerOf('G1')), ViewMode.mobile) -
|
||||
card.bottom,
|
||||
sideInset,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a desktop window lays the groups out in columns and pins the '
|
||||
'header of an unfolded one', (tester) async {
|
||||
await pumpList(tester, viewMode: ViewMode.desktop);
|
||||
container.read(proxiesActionProvider.notifier).updateCurrentUnfoldSet({
|
||||
'G2',
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
final first = tester.getRect(headerOf('G0'));
|
||||
final second = tester.getRect(headerOf('G1'));
|
||||
final third = tester.getRect(headerOf('G2'));
|
||||
|
||||
expect(second.top, first.top);
|
||||
expect(
|
||||
second.left - first.right,
|
||||
cardSpacingOf(tester.element(headerOf('G1')), ViewMode.desktop),
|
||||
);
|
||||
expect(third.left, first.left);
|
||||
expect(third.top, greaterThan(first.bottom));
|
||||
|
||||
final position = tester.state<ScrollableState>(scrollable()).position;
|
||||
position.jumpTo(40);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getRect(headerOf('G0')).top, first.top - 40);
|
||||
expect(tester.getRect(headerOf('G1')).top, second.top - 40);
|
||||
|
||||
position.jumpTo(third.top - first.top + 200);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getRect(headerOf('G2')).top, first.top);
|
||||
});
|
||||
|
||||
testWidgets('scroll to selected stays put without a selection', (
|
||||
@@ -131,6 +324,17 @@ void main() {
|
||||
expect(tester.getRect(headerOf('G1')), before);
|
||||
});
|
||||
|
||||
testWidgets('a group name too long for its header stays on one line', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpList(
|
||||
tester,
|
||||
groups: [_group('Streaming Media Very Long Group Name 流媒体服务分组 ' * 3, 4)],
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the delay test button tests its own group only', (tester) async {
|
||||
await pumpList(tester);
|
||||
|
||||
|
||||
@@ -5,11 +5,17 @@ import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:fl_clash/views/proxies/card.dart';
|
||||
import 'package:fl_clash/views/proxies/group_tabs.dart';
|
||||
import 'package:fl_clash/views/proxies/proxies.dart';
|
||||
import 'package:fl_clash/views/proxies/tab.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
import '../helpers/test_profiles.dart';
|
||||
|
||||
@@ -71,60 +77,160 @@ void main() {
|
||||
return key;
|
||||
}
|
||||
|
||||
testWidgets('current group follows the rendered tab list', (tester) async {
|
||||
testWidgets('current group follows the tapped tab', (tester) async {
|
||||
final key = await pumpTabView(tester);
|
||||
|
||||
expect(key.currentState?.currentGroup?.name, 'B');
|
||||
expect(isTabSelected(tester, 'B'), isTrue);
|
||||
|
||||
final tabBar = tester.widget<TabBar>(find.byType(TabBar));
|
||||
tabBar.controller?.animateTo(1);
|
||||
await tester.tap(find.text('C'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(key.currentState?.currentGroup?.name, 'C');
|
||||
expect(globalContainer.read(currentProfileProvider)?.currentGroupName, 'C');
|
||||
expect(isTabSelected(tester, 'C'), isTrue);
|
||||
expect(isTabSelected(tester, 'B'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps the outgoing tab bar usable while the empty state enters',
|
||||
(tester) async {
|
||||
final key = await pumpTabView(tester);
|
||||
testWidgets('a swipe between pages moves the selected tab', (tester) async {
|
||||
final key = await pumpTabView(tester);
|
||||
|
||||
globalContainer.read(_tabStateProvider.notifier).set(_tabState([]));
|
||||
await tester.pump();
|
||||
await tester.drag(find.byType(TabBarView), const Offset(-500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TabBar), findsOneWidget);
|
||||
await tester.tap(find.byType(Tab).at(1), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(key.currentState?.currentGroup?.name, 'C');
|
||||
expect(isTabSelected(tester, 'C'), isTrue);
|
||||
expect(isTabSelected(tester, 'B'), isFalse);
|
||||
});
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(key.currentState?.currentGroup, isNull);
|
||||
testWidgets('keeps the outgoing tabs usable while the empty state enters', (
|
||||
tester,
|
||||
) async {
|
||||
final key = await pumpTabView(tester);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
globalContainer.read(_tabStateProvider.notifier).set(_tabState([]));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(TabBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
expect(find.text('C'), findsOneWidget);
|
||||
await tester.tap(find.text('C'), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
|
||||
testWidgets('the last tab scrolls clear of the more button fade', (
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(key.currentState?.currentGroup, isNull);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('C'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the more button shows only once the tabs overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpTabView(tester);
|
||||
|
||||
expect(find.byGlyph(AppGlyphs.chevronDown), findsNothing);
|
||||
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(_tabState([for (var i = 0; i < 12; i++) _group('Group $i')]));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byGlyph(AppGlyphs.chevronDown), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opens with the current tab in view beside the more button', (
|
||||
tester,
|
||||
) async {
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(
|
||||
_tabState([
|
||||
for (var i = 0; i < 12; i++) _group('Group $i'),
|
||||
], current: 'Group 11'),
|
||||
);
|
||||
await pumpTabView(tester);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final current = tester.getRect(tabOf('Group 11'));
|
||||
final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown));
|
||||
expect(isTabSelected(tester, 'Group 11'), isTrue);
|
||||
expect(current.right, lessThan(more.left));
|
||||
});
|
||||
|
||||
testWidgets('the last tab scrolls clear of the more button', (tester) async {
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(_tabState([for (var i = 0; i < 12; i++) _group('Group $i')]));
|
||||
await pumpTabView(tester);
|
||||
await tester.pump();
|
||||
|
||||
final tabs = find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
of: find.byType(GroupTabs),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
final position = tester.state<ScrollableState>(tabs).position;
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
|
||||
final label = tester.getRect(find.text('Group 11', findRichText: true));
|
||||
final more = tester.getRect(find.byType(IconButton));
|
||||
expect(label.right, lessThanOrEqualTo(more.left - kTabLabelPadding.right));
|
||||
final last = tester.getRect(tabOf('Group 11'));
|
||||
final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown));
|
||||
expect(last.right, lessThan(more.left));
|
||||
});
|
||||
|
||||
testWidgets('the more menu stays short, opens on the checked current group '
|
||||
'and switches to the picked one', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(
|
||||
_tabState([
|
||||
for (var i = 0; i < 20; i++) _group('Group $i'),
|
||||
], current: 'Group 12'),
|
||||
);
|
||||
final key = await pumpTabView(tester);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byGlyph(AppGlyphs.chevronDown));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final menu = find.byType(CommonPopupMenu);
|
||||
final check = find.descendant(
|
||||
of: menu,
|
||||
matching: find.byGlyph(AppGlyphs.check),
|
||||
);
|
||||
expect(check, findsOneWidget);
|
||||
expect(
|
||||
tester.getCenter(check).dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getCenter(
|
||||
find.descendant(of: menu, matching: find.text('Group 12')),
|
||||
)
|
||||
.dy,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
final card = tester.getRect(find.byType(PopupMenuCard));
|
||||
expect(card.height, lessThan(tester.view.physicalSize.height / 2));
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(of: menu, matching: find.text('Group 15')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(menu, findsNothing);
|
||||
expect(key.currentState?.currentGroup?.name, 'Group 15');
|
||||
expect(isTabSelected(tester, 'Group 15'), isTrue);
|
||||
final picked = tester.getRect(tabOf('Group 15'));
|
||||
final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown));
|
||||
expect(picked.left, greaterThanOrEqualTo(0));
|
||||
expect(picked.right, lessThan(more.left));
|
||||
});
|
||||
|
||||
testWidgets('scroll to selected brings the row to the top of the grid', (
|
||||
@@ -162,6 +268,128 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('cards rest where the grid clips them, clear of the tabs', (
|
||||
tester,
|
||||
) async {
|
||||
final group = Group(
|
||||
type: GroupType.Selector,
|
||||
name: 'B',
|
||||
all: [for (var i = 0; i < 40; i++) Proxy(name: 'B-$i', type: 'ss')],
|
||||
);
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(_tabState([_group('A'), group]));
|
||||
await pumpTabView(tester);
|
||||
await tester.pumpAndSettle();
|
||||
final tabs = tester.getRect(tabOf('B'));
|
||||
final grid = tester.getRect(find.byType(GridView));
|
||||
|
||||
expect(grid.top, tabs.bottom + 16);
|
||||
expect(tester.getRect(find.byType(ProxyCard).first).top, grid.top);
|
||||
});
|
||||
|
||||
testWidgets('a new current group leaves the visible cards as they were', (
|
||||
tester,
|
||||
) async {
|
||||
final group = Group(
|
||||
type: GroupType.Selector,
|
||||
name: 'B',
|
||||
all: [for (var i = 0; i < 4; i++) Proxy(name: 'B-$i', type: 'ss')],
|
||||
);
|
||||
final state = _tabState([_group('A'), group]);
|
||||
globalContainer.read(_tabStateProvider.notifier).set(state);
|
||||
await pumpTabView(tester);
|
||||
final card = find.byType(ProxyCard).first;
|
||||
final before = tester.widget(card);
|
||||
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(state.copyWith(currentGroupName: 'A'));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget(card), same(before));
|
||||
});
|
||||
|
||||
testWidgets('lays each tab out at the width its label takes', (tester) async {
|
||||
const names = ['A', '🚀 节点选择', 'Group with a long name'];
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(_tabState([for (final name in names) _group(name)], current: 'A'));
|
||||
await pumpTabView(tester);
|
||||
|
||||
for (final name in names) {
|
||||
final tab = tabOf(name);
|
||||
expect(
|
||||
tester.getSize(tab).width,
|
||||
closeTo(
|
||||
tester
|
||||
.renderObject<RenderBox>(tab)
|
||||
.getMaxIntrinsicWidth(double.infinity),
|
||||
1,
|
||||
),
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('builds only the tabs in view and opens on a far current one', (
|
||||
tester,
|
||||
) async {
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(
|
||||
_tabState([
|
||||
for (var i = 0; i < 200; i++) _group('Group $i'),
|
||||
], current: 'Group 180'),
|
||||
);
|
||||
await pumpTabView(tester);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(GroupTabs),
|
||||
matching: find.byType(CommonCard),
|
||||
skipOffstage: false,
|
||||
)
|
||||
.evaluate()
|
||||
.length,
|
||||
lessThan(30),
|
||||
);
|
||||
final current = tester.getRect(tabOf('Group 180'));
|
||||
final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown));
|
||||
expect(isTabSelected(tester, 'Group 180'), isTrue);
|
||||
expect(current.left, greaterThanOrEqualTo(0));
|
||||
expect(current.right, lessThan(more.left));
|
||||
});
|
||||
|
||||
testWidgets('the bar following the current group leaves the page built', (
|
||||
tester,
|
||||
) async {
|
||||
globalContainer
|
||||
.read(proxiesStyleSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(type: ProxiesType.tab));
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: globalContainer,
|
||||
child: const TestApp(child: ProxiesView()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final tabs = tester.widget(find.byType(GroupTabs));
|
||||
|
||||
final state = globalContainer.read(_tabStateProvider);
|
||||
globalContainer
|
||||
.read(_tabStateProvider.notifier)
|
||||
.set(state.copyWith(currentGroupName: 'C'));
|
||||
globalContainer.read(delayTestingGroupsProvider.notifier).start('C');
|
||||
await tester.pump();
|
||||
|
||||
expect(globalContainer.read(currentGroupDelayTestingProvider), isTrue);
|
||||
expect(tester.widget(find.byType(GroupTabs)), same(tabs));
|
||||
});
|
||||
|
||||
testWidgets('rebuilds the tab bar when groups return', (tester) async {
|
||||
final key = await pumpTabView(tester);
|
||||
|
||||
@@ -172,18 +400,26 @@ void main() {
|
||||
.set(_tabState([_group('A'), _group('B'), _group('C')]));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabBar = tester.widget<TabBar>(find.byType(TabBar));
|
||||
expect(tabBar.controller?.length, 3);
|
||||
for (final name in ['A', 'B', 'C']) {
|
||||
expect(tabOf(name), findsOneWidget);
|
||||
}
|
||||
expect(key.currentState?.currentGroup?.name, 'B');
|
||||
expect(isTabSelected(tester, 'B'), isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
ProxiesTabState _tabState(List<Group> groups) {
|
||||
Finder tabOf(String name) =>
|
||||
find.ancestor(of: find.text(name), matching: find.byType(CommonCard));
|
||||
|
||||
bool isTabSelected(WidgetTester tester, String name) =>
|
||||
tester.widget<CommonCard>(tabOf(name)).isSelected;
|
||||
|
||||
ProxiesTabState _tabState(List<Group> groups, {String current = 'B'}) {
|
||||
return ProxiesTabState(
|
||||
groups: groups,
|
||||
currentGroupName: 'B',
|
||||
proxyCardType: ProxyCardType.expand,
|
||||
currentGroupName: current,
|
||||
proxyCardType: ProxyCardType.standard,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ void main() {
|
||||
testWidgets('tab walks card to card without stopping on the delay control', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCards(tester, ProxyCardType.shrink);
|
||||
await pumpCards(tester, ProxyCardType.standard);
|
||||
|
||||
await focusCard(tester, 0);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
@@ -134,7 +134,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('enter on the delay control runs the delay test', (tester) async {
|
||||
await pumpCards(tester, ProxyCardType.shrink);
|
||||
await pumpCards(tester, ProxyCardType.standard);
|
||||
|
||||
await focusCard(tester, 0);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
|
||||
Reference in New Issue
Block a user