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:
chen08209
2026-10-15 21:27:45 +08:00
parent 54633d8e0c
commit 9d17f50594
22 changed files with 1731 additions and 1524 deletions
+6 -4
View File
@@ -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] ?? '',
);
+3 -3
View File
@@ -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
View File
@@ -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,
+5 -1
View File
@@ -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
+1 -359
View File
@@ -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> {
+12 -65
View File
@@ -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
@@ -176,34 +165,6 @@ 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),
),
);
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,
);
}
@riverpod
String realTestUrl(Ref ref, [String? testUrl]) {
final currentTestUrl = ref.watch(appSettingProvider).testUrl;
@@ -280,20 +241,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,
+126 -137
View File
@@ -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,26 +296,26 @@ 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,
iconSize: measure.textHeight(AppTextStyle.caption2),
padding: EdgeInsets.zero,
onPressed: _handleTestCurrentDelay,
),
@@ -298,10 +330,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 +345,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(),
),
);
}
}
+16 -7
View File
@@ -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({
+466 -199
View File
@@ -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,286 @@ 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.
Widget _buildPinnedHeader(
bool isExpand,
Widget Function({double floating, double lift}) buildHeader,
) {
final columns = widget.columns;
final rowsExtent = isExpand
? _rowsExtent(
(widget.group.all.length + columns - 1) ~/ columns,
_rowExtent,
)
: 0.0;
(double, double, double, Color)? headerState;
Widget? header;
return SliverLayoutBuilder(
builder: (context, constraints) {
final under = isExpand
? constraints.scrollOffset + constraints.overlap
: 0.0;
final state = (
(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 (state == headerState) {
return header!;
}
headerState = state;
final (floating, lift, cover, pageColor) = state;
return header = PinnedHeaderSliver(
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 +565,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 +595,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 +641,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 +700,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 +729,7 @@ class _GroupIcon extends ConsumerWidget {
builder: (_, constraints) {
return IconTheme.merge(
data: IconThemeData(
size: constraints.maxHeight - 16.ap,
size: constraints.maxHeight - 16.ap(context),
color: context.colorScheme.primary,
),
child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)),
@@ -492,7 +755,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 +785,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 +827,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(
+64 -91
View File
@@ -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
+12 -31
View File
@@ -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,6 @@ class ProxiesView extends ConsumerStatefulWidget {
class _ProxiesViewState extends ConsumerState<ProxiesView> {
final GlobalKey<ProxiesTabViewState> _proxiesTabKey = GlobalKey();
bool _hasProviders = false;
bool _isTab = false;
List<CommonPopupMenuItem> _buildMenuItems(BuildContext context) {
@@ -42,19 +43,6 @@ class _ProxiesViewState extends ConsumerState<ProxiesView> {
);
},
),
if (_hasProviders)
CommonPopupMenuItem(
glyph: AppGlyphs.layers,
label: appLocalizations.providers,
onPressed: () {
showExtend(
context,
builder: (_) {
return const ProvidersView();
},
);
},
),
];
}
@@ -89,16 +77,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 +98,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,7 +115,7 @@ 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) {
+102 -317
View File
@@ -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),
],
),
);
+323 -170
View File
@@ -3,10 +3,11 @@ 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/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';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -25,7 +26,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 +48,6 @@ class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
@override
void dispose() {
_destroyTabController();
_hasMoreButtonNotifier.dispose();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
@@ -56,7 +55,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 +118,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 +161,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,96 +175,332 @@ 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,
),
],
);
},
),
),
],
);
}
}
class _GroupTabs extends StatefulWidget {
const _GroupTabs({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;
final _hasMore = ValueNotifier(false);
final _fade = ValueNotifier(0.0);
final Map<String, GlobalKey> _tabKeys = {};
late final _selected = ValueNotifier(widget.controller.index);
@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);
_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();
}
// The strip owns no controller: a sheet's would hand its drags to the sheet.
void _reveal({bool jump = false}) {
if (!mounted) {
return;
}
final name = widget.groups.elementAtOrNull(_selected.value)?.name;
final tab = _tabKeys[name]?.currentContext;
final position = tab == null ? null : Scrollable.maybeOf(tab)?.position;
if (tab == null || position == null) {
return;
}
position.ensureVisible(
tab.findRenderObject()!,
alignment: 0.5,
duration: jump ? Duration.zero : 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),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final names = {for (final group in widget.groups) group.name};
_tabKeys.removeWhere((name, _) => !names.contains(name));
final tabs = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
for (final (index, group) in widget.groups.indexed)
_GroupTab(
key: _tabKeys.putIfAbsent(group.name, GlobalKey.new),
name: group.name,
index: index,
selected: _selected,
onPressed: () => _select(index),
),
],
);
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: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsetsDirectional.only(
start: 16,
end: hasMore ? 0 : 16,
),
child: tabs,
),
),
),
),
),
if (hasMore)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
child: moreButton,
),
],
);
},
child: _buildMoreButton(),
),
);
}
}
/// 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({
super.key,
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 name = widget.name;
final onPressed = widget.onPressed;
final colorScheme = context.colorScheme;
return CommonCard(
isSelected: selected,
fillsSelection: true,
shape: AppShape.full,
padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: onPressed,
child: AnimatedDefaultTextStyle(
duration: midDuration,
style: AppTextStyle.subheadline
.of(context, emphasized: true)
.copyWith(
color: selected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
child: EmojiText(name, maxLines: 1),
),
);
}
}
class ProxyGroupView extends ConsumerWidget {
@@ -350,8 +508,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 +515,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 +536,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 +548,7 @@ class ProxyGroupView extends ConsumerWidget {
mainAxisExtent: getItemHeight(cardType),
),
itemCount: proxies.length,
addAutomaticKeepAlives: false,
itemBuilder: (_, index) {
final proxy = proxies[index];
return ProxyCard(
+10
View File
@@ -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(''), '');
+1 -1
View File
@@ -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', () {
+96 -55
View File
@@ -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);
});
}
+2 -2
View File
@@ -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(
+86 -38
View File
@@ -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');
+214 -10
View File
@@ -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);
+183 -29
View File
@@ -5,11 +5,15 @@ 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/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 +75,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(SingleChildScrollView),
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 +266,48 @@ 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('rebuilds the tab bar when groups return', (tester) async {
final key = await pumpTabView(tester);
@@ -172,18 +318,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,
);
}
+2 -2
View File
@@ -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);