From 2ea52fdfdca05b0dcdb3afb873ed274bd3ef78ea Mon Sep 17 00:00:00 2001 From: chen08209 Date: Thu, 15 Oct 2026 21:27:45 +0800 Subject: [PATCH] feat(proxies): show groups as cards and group tabs as chips Changelog: Proxy groups as cards, group tabs as chips --- lib/common/compute.dart | 10 +- lib/common/l10n_labels.dart | 6 +- lib/enum/enum.dart | 2 +- lib/models/common.dart | 6 +- lib/providers/generated/state.g.dart | 360 +---------- lib/providers/state/proxies.dart | 79 +-- lib/ui/popup.dart | 24 +- lib/ui/text.dart | 21 + lib/views/proxies/card.dart | 277 ++++---- lib/views/proxies/common.dart | 23 +- lib/views/proxies/group_tabs.dart | 408 ++++++++++++ lib/views/proxies/list.dart | 682 ++++++++++++++------ lib/views/proxies/providers.dart | 155 ++--- lib/views/proxies/proxies.dart | 60 +- lib/views/proxies/setting.dart | 419 +++--------- lib/views/proxies/tab.dart | 217 ++----- test/common/compute_test.dart | 10 + test/lint/sheet_scroll_controller_test.dart | 2 + test/providers/config_test.dart | 2 +- test/providers/state_derived_test.dart | 151 +++-- test/views/proxies_group_offsets_test.dart | 2 - test/views/proxies_list_test.dart | 73 ++- test/views/proxy_providers_view_test.dart | 124 ++-- test/widgets/proxies_list_test.dart | 224 ++++++- test/widgets/proxies_tab_test.dart | 294 ++++++++- test/widgets/proxy_card_focus_test.dart | 4 +- 26 files changed, 2078 insertions(+), 1557 deletions(-) create mode 100644 lib/views/proxies/group_tabs.dart diff --git a/lib/common/compute.dart b/lib/common/compute.dart index 46fe34f8..c65f984b 100644 --- a/lib/common/compute.dart +++ b/lib/common/compute.dart @@ -81,7 +81,10 @@ List 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 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] ?? '', ); diff --git a/lib/common/l10n_labels.dart b/lib/common/l10n_labels.dart index 5a27fd8f..e92ed5c7 100644 --- a/lib/common/l10n_labels.dart +++ b/lib/common/l10n_labels.dart @@ -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, }; } } diff --git a/lib/enum/enum.dart b/lib/enum/enum.dart index 637198b1..ac77b451 100644 --- a/lib/enum/enum.dart +++ b/lib/enum/enum.dart @@ -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, diff --git a/lib/models/common.dart b/lib/models/common.dart index b04d2448..afbcafdb 100644 --- a/lib/models/common.dart +++ b/lib/models/common.dart @@ -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 diff --git a/lib/providers/generated/state.g.dart b/lib/providers/generated/state.g.dart index 3d4f79f0..9e84d3ee 100644 --- a/lib/providers/generated/state.g.dart +++ b/lib/providers/generated/state.g.dart @@ -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 { - PackageListSelectorStateProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'packageListSelectorStateProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$packageListSelectorStateHash(); - - @$internal - @override - $ProviderElement $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(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 { - ProxiesActionsStateProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'proxiesActionsStateProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$proxiesActionsStateHash(); - - @$internal - @override - $ProviderElement $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(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 { - 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 $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(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 { - 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 - with $Provider { - 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 $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(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 { - 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 - with $Provider { - 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 $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(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, int> { diff --git a/lib/providers/state/proxies.dart b/lib/providers/state/proxies.dart index cbbb1836..faafd0c6 100644 --- a/lib/providers/state/proxies.dart +++ b/lib/providers/state/proxies.dart @@ -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 = {}; final groups = currentGroups.value .map((group) { @@ -124,7 +113,7 @@ GroupsState filterGroupsState(Ref ref, String query) { }) .where((group) => group.all.isNotEmpty) .toList(); - return currentGroups.copyWith(value: groups); + return _hideTimeoutProxies(ref, currentGroups.copyWith(value: groups)); } @riverpod @@ -177,30 +166,14 @@ ProxiesTabControllerState proxiesTabControllerState(Ref ref) { } @riverpod -ProxyGroupSelectorState proxyGroupSelectorState( - Ref ref, - String groupName, - String query, -) { - final proxiesStyle = ref.watch(proxiesStyleSettingProvider); - final group = ref.watch( - visibleGroupsStateProvider.select( - (state) => state.value.getGroup(groupName), - ), +bool currentGroupDelayTesting(Ref ref) { + final currentGroupName = ref.watch( + proxiesTabControllerStateProvider.select((state) => state.currentGroupName), ); - final sortNum = ref.watch(sortNumProvider); - final proxies = - group?.all - .whereMatches(SearchQuery(query), (proxy) => proxy.searchFields) - .toList() ?? - []; - return ProxyGroupSelectorState( - testUrl: group?.testUrl, - proxiesSortType: proxiesStyle.sortType, - proxyCardType: proxiesStyle.cardType, - sortNum: sortNum, - groupType: group?.type ?? GroupType.Selector, - proxies: proxies, + return ref.watch( + delayTestingGroupsProvider.select( + (state) => state.contains(currentGroupName), + ), ); } @@ -280,20 +253,6 @@ String? selectedProxyName(Ref ref, String groupName) { return group?.getCurrentSelectedName(proxyName ?? ''); } -@riverpod -String proxyDesc(Ref ref, Proxy proxy) { - final groupTypeNamesList = GroupType.values.map((e) => e.name).toList(); - if (!groupTypeNamesList.contains(proxy.type)) { - return proxy.type; - } else { - final groups = ref.watch(groupsProvider); - final index = groups.indexWhere((element) => element.name == proxy.name); - if (index == -1) return proxy.type; - final state = ref.watch(realSelectedProxyStateProvider(proxy.name)); - return "${proxy.type}(${state.proxyName.isNotEmpty ? state.proxyName : '*'})"; - } -} - @riverpod ({bool isProxies, int sortNum, ProxiesSortType sortType}) needUpdateGroups( Ref ref, diff --git a/lib/ui/popup.dart b/lib/ui/popup.dart index 3b775cbf..7b522e70 100644 --- a/lib/ui/popup.dart +++ b/lib/ui/popup.dart @@ -1716,7 +1716,7 @@ class _CommonPopupMenuState extends State return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context); } - static final _labelWidths = <(String, TextStyle, TextScaler), double>{}; + static final _labelWidths = <(String, TextStyle, TextScaler, bool), double>{}; /// What the rows would take at their intrinsic width, measured from the /// labels as the rows lay them out. @@ -1724,31 +1724,15 @@ class _CommonPopupMenuState extends State final style = AppTextStyle.subheadline.of(context); final root = DefaultTextStyle.of(context).style.merge(style); final textScaler = MediaQuery.textScalerOf(context); - final textDirection = Directionality.of(context); - final locale = Localizations.maybeLocaleOf(context); + final boldText = MediaQuery.boldTextOf(context); const limit = _menuMaxWidth - 2 * _cardInset; var width = 0.0; for (final item in items) { if (_labelWidths.length >= 1024) { _labelWidths.clear(); } - final label = _labelWidths[(item.label, root, textScaler)] ??= () { - final painter = TextPainter( - text: TextSpan( - style: root, - children: [EmojiText.spanOf(item.label, style)], - ), - textDirection: textDirection, - textScaler: textScaler, - locale: locale, - maxLines: 1, - )..layout(); - try { - return painter.maxIntrinsicWidth; - } finally { - painter.dispose(); - } - }(); + final label = _labelWidths[(item.label, root, textScaler, boldText)] ??= + EmojiText.widthOf(context, item.label, root); final checked = item.checked == true; final row = (checked ? _itemArrowPadding : _itemPadding).horizontal + diff --git a/lib/ui/text.dart b/lib/ui/text.dart index 5e8eeadf..5683b125 100644 --- a/lib/ui/text.dart +++ b/lib/ui/text.dart @@ -96,6 +96,27 @@ class EmojiText extends StatelessWidget { return TextSpan(children: spans); } + /// The width [EmojiText] lays [text] out at on one line under [style]. + static double widthOf(BuildContext context, String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan( + style: MediaQuery.boldTextOf(context) + ? style.merge(const TextStyle(fontWeight: FontWeight.bold)) + : style, + children: [spanOf(text, null)], + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.maybeLocaleOf(context), + maxLines: 1, + )..layout(); + try { + return painter.maxIntrinsicWidth; + } finally { + painter.dispose(); + } + } + @override Widget build(BuildContext context) { return Text.rich( diff --git a/lib/views/proxies/card.dart b/lib/views/proxies/card.dart index 967b1eb8..814180b2 100644 --- a/lib/views/proxies/card.dart +++ b/lib/views/proxies/card.dart @@ -1,9 +1,9 @@ import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/widgets/widgets.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -51,24 +51,24 @@ class ProxyCard extends ConsumerWidget { Measure get measure => globalState.measure; Widget _buildProxyNameText(BuildContext context) { - if (type == ProxyCardType.min) { + if (type == ProxyCardType.compact) { return SizedBox( - height: measure.bodyMediumHeight * 1, + height: measure.textHeight(AppTextStyle.subheadline) * 1, child: EmojiText( proxy.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, + style: AppTextStyle.subheadline.of(context), ), ); } else { return SizedBox( - height: measure.bodyMediumHeight * 2, + height: measure.textHeight(AppTextStyle.subheadline) * 2, child: EmojiText( proxy.name, maxLines: 2, overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, + style: AppTextStyle.subheadline.of(context), ), ); } @@ -77,31 +77,72 @@ class ProxyCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final measure = globalState.measure; - final delayText = _DelayText(proxy: proxy, testUrl: testUrl, type: type); + final isLoose = type == ProxyCardType.loose; + final delayText = _DelayText( + proxy: proxy, + testUrl: testUrl, + alignment: isLoose ? Alignment.centerLeft : Alignment.centerRight, + ); final proxyNameText = _buildProxyNameText(context); - return Stack( - children: [ - Consumer( - builder: (_, ref, child) { - final selectedProxyName = ref.watch( - selectedProxyNameProvider(groupName), + final typeStyle = AppTextStyle.footnote + .of(context) + .copyWith( + overflow: TextOverflow.ellipsis, + color: context.colorScheme.onSurface.opacity80, + ); + final details = isLoose + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: measure.textHeight(AppTextStyle.footnote), + child: _ProxyDesc(proxy: proxy, style: typeStyle), + ), + const SizedBox(height: 6), + delayText, + ], + ) + : SizedBox( + height: measure.textHeight(AppTextStyle.footnote), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + flex: 1, + child: TooltipText( + text: Text(proxy.type, maxLines: 1, style: typeStyle), + ), + ), + delayText, + ], + ), + ); + return Consumer( + builder: (context, ref, _) { + final inUse = ref.watch( + selectedProxyNameProvider( + groupName, + ).select((name) => name == proxy.name), + ); + final pinned = + groupType.isComputedSelected && + ref.watch( + proxyNameProvider(groupName).select((name) => name == proxy.name), ); - return CommonCard( - radius: AppCorner.lg, - enterActionsOnRight: true, - key: key, - onPressed: () { - selectGroupProxy( - ref, - groupName: groupName, - groupType: groupType, - proxyName: proxy.name, - ); - }, - isSelected: selectedProxyName == proxy.name, - child: child!, + return CommonCard( + radius: AppCorner.lg, + enterActionsOnRight: true, + key: key, + onPressed: () { + selectGroupProxy( + ref, + groupName: groupName, + groupType: groupType, + proxyName: proxy.name, ); }, + isSelected: inUse, child: Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 12), @@ -109,54 +150,49 @@ class ProxyCard extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - proxyNameText, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Expanded(child: proxyNameText), + if (pinned) + SelectionBadge.preferred(inUse: inUse) + else if (inUse) + const SelectionBadge(), + ], + ), const SizedBox(height: 8), - if (type == ProxyCardType.expand) ...[ - SizedBox( - height: measure.bodySmallHeight, - child: _ProxyDesc(proxy: proxy), - ), - const SizedBox(height: 6), - delayText, - ] else - SizedBox( - height: measure.bodySmallHeight, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - flex: 1, - child: TooltipText( - text: Text( - proxy.type, - maxLines: 1, - style: context.textTheme.bodySmall?.copyWith( - overflow: TextOverflow.ellipsis, - color: context - .textTheme - .bodySmall - ?.color - ?.opacity80, - ), - ), - ), - ), - delayText, - ], - ), - ), + details, ], ), ), - ), - if (groupType.isComputedSelected) - Positioned( - top: 0, - right: 0, - child: _ProxyComputedMark(groupName: groupName, proxy: proxy), - ), - ], + ); + }, + ); + } +} + +class _ProxyDesc extends ConsumerWidget { + const _ProxyDesc({required this.proxy, required this.style}); + + final Proxy proxy; + final TextStyle style; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isGroup = GroupType.values.any((type) => type.name == proxy.type); + final nodeName = isGroup + ? ref.watch( + realSelectedProxyStateProvider( + proxy.name, + ).select((state) => state.proxyName), + ) + : proxy.name; + return EmojiText( + nodeName == proxy.name ? proxy.type : '${proxy.type}($nodeName)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, ); } } @@ -165,12 +201,12 @@ class _DelayText extends ConsumerStatefulWidget { const _DelayText({ required this.proxy, required this.testUrl, - required this.type, + required this.alignment, }); final Proxy proxy; final String? testUrl; - final ProxyCardType type; + final Alignment alignment; @override ConsumerState<_DelayText> createState() => _DelayTextState(); @@ -225,12 +261,8 @@ class _DelayTextState extends ConsumerState<_DelayText> { child: IgnorePointer( child: DecoratedBox( decoration: ShapeDecoration( - shape: RoundedSuperellipseBorder( - borderRadius: AppRadius.xs, - side: BorderSide( - color: context.colorScheme.primary, - width: 2, - ), + shape: AppShape.xs.copyWith( + side: focusRingSide(context.colorScheme), ), ), ), @@ -264,28 +296,32 @@ class _DelayTextState extends ConsumerState<_DelayText> { child: Focus( focusNode: _focusNode, child: SizedBox( - height: measure.labelSmallHeight, + height: measure.textHeight(AppTextStyle.caption2), child: FadeBox( - alignment: widget.type == ProxyCardType.expand - ? Alignment.centerLeft - : Alignment.centerRight, + alignment: widget.alignment, child: phase != null || delay == null ? SizedBox( - height: measure.labelSmallHeight, - width: measure.labelSmallHeight, + height: measure.textHeight(AppTextStyle.caption2), + width: measure.textHeight(AppTextStyle.caption2), child: _withFocusRing(context, switch (phase) { - DelayTestPhase.running => const CommonCircleLoading(), + DelayTestPhase.running => CommonCircleLoading( + size: measure.textHeight(AppTextStyle.caption2), + ), DelayTestPhase.queued => GlyphIcon( AppGlyphs.clock, - size: measure.labelSmallHeight, + size: measure.textHeight(AppTextStyle.caption2), color: context.colorScheme.onSurfaceVariant.opacity38, ), - null => IconButton( - tooltip: context.appLocalizations.delayTest, - icon: const GlyphIcon(AppGlyphs.bolt), - iconSize: measure.labelSmallHeight, - padding: EdgeInsets.zero, - onPressed: _handleTestCurrentDelay, + null => Tooltip( + message: context.appLocalizations.delayTest, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTestCurrentDelay, + child: GlyphIcon( + AppGlyphs.bolt, + size: measure.textHeight(AppTextStyle.caption2), + ), + ), ), }), ) @@ -298,10 +334,12 @@ class _DelayTextState extends ConsumerState<_DelayText> { ? '$delay ms' : delayFailureText(delay, context.appLocalizations), maxLines: 1, - style: context.textTheme.labelSmall?.copyWith( - overflow: TextOverflow.ellipsis, - color: context.colorScheme.delayColor(delay), - ), + style: AppTextStyle.caption2 + .of(context, emphasized: true) + .copyWith( + overflow: TextOverflow.ellipsis, + color: context.colorScheme.delayColor(delay), + ), ), ), ), @@ -311,48 +349,3 @@ class _DelayTextState extends ConsumerState<_DelayText> { ); } } - -class _ProxyDesc extends ConsumerWidget { - final Proxy proxy; - - const _ProxyDesc({required this.proxy}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final desc = ref.watch(proxyDescProvider(proxy)); - return EmojiText( - desc, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodySmall?.copyWith( - color: context.textTheme.bodySmall?.color?.opacity80, - ), - ); - } -} - -class _ProxyComputedMark extends ConsumerWidget { - final String groupName; - final Proxy proxy; - - const _ProxyComputedMark({required this.groupName, required this.proxy}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final proxyName = ref.watch(proxyNameProvider(groupName)); - if (proxyName != proxy.name) { - return const SizedBox(); - } - return Container( - alignment: Alignment.topRight, - margin: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.secondaryContainer, - ), - child: const SelectIcon(), - ), - ); - } -} diff --git a/lib/views/proxies/common.dart b/lib/views/proxies/common.dart index aac44b25..f1105ae2 100644 --- a/lib/views/proxies/common.dart +++ b/lib/views/proxies/common.dart @@ -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({ diff --git a/lib/views/proxies/group_tabs.dart b/lib/views/proxies/group_tabs.dart new file mode 100644 index 00000000..0fb9ebe2 --- /dev/null +++ b/lib/views/proxies/group_tabs.dart @@ -0,0 +1,408 @@ +import 'dart:math'; + +import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/models/common.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; +import 'package:fl_clash/widgets/widgets.dart'; +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:material_ui/material_ui.dart'; + +const _stripInset = 16.0; +const _tabSpacing = 8.0; +const _tabPadding = 16.0; + +/// The group chips over the proxies tab view. Only the chips in view are +/// built: each chip's width comes from its label, so the strip can scroll to +/// one it has not built. +class GroupTabs extends StatefulWidget { + const GroupTabs({super.key, required this.controller, required this.groups}); + + final TabController controller; + final List groups; + + @override + State createState() => _GroupTabsState(); +} + +class _GroupTabsState extends State { + static const _fadeExtent = 24.0; + + // A sheet's controller would hand the strip's drags to the sheet. + final _scrollController = ScrollController(); + final _hasMore = ValueNotifier(false); + final _fade = ValueNotifier(0.0); + late final _selected = ValueNotifier(widget.controller.index); + _TabExtents? _extents; + + @override + void initState() { + super.initState(); + _follow(widget.controller); + } + + @override + void didUpdateWidget(GroupTabs oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.animation?.removeListener(_handleAnimation); + _follow(widget.controller); + } + } + + @override + void dispose() { + widget.controller.animation?.removeListener(_handleAnimation); + _scrollController.dispose(); + _hasMore.dispose(); + _fade.dispose(); + _selected.dispose(); + super.dispose(); + } + + void _follow(TabController controller) { + _selected.value = controller.index; + controller.animation!.addListener(_handleAnimation); + WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true)); + } + + // A tap animates across every tab between; only a swipe's passes count. + void _handleAnimation() { + final controller = widget.controller; + final index = controller.indexIsChanging + ? controller.index + : controller.animation!.value.round(); + if (index == _selected.value) { + return; + } + _selected.value = index; + _reveal(); + } + + void _reveal({bool jump = false}) { + final extents = _extents; + if (!mounted || extents == null || !_scrollController.hasClients) { + return; + } + final position = _scrollController.position; + final index = _selected.value; + if (!position.hasContentDimensions || + index < 0 || + index >= extents.widths.length) { + return; + } + final center = + _stripInset + extents.starts[index] + extents.widths[index] / 2; + final target = (center - position.viewportDimension / 2).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (jump) { + position.jumpTo(target); + } else { + position.animateTo( + target, + duration: commonDuration, + curve: Curves.easeOutCubic, + ); + } + } + + void _select(int index) { + widget.controller.animateTo(index); + } + + bool _handleMetrics(ScrollMetrics metrics) { + _fade.value = (metrics.extentAfter / _fadeExtent).clamp(0.0, 1.0); + final hasMore = metrics.maxScrollExtent > 0; + if (hasMore != _hasMore.value) { + _hasMore.value = hasMore; + // The more button narrows the tabs it overflows. + WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true)); + } + return false; + } + + Shader _buildEdgeFade(Rect bounds, double fade) { + return LinearGradient( + begin: AlignmentDirectional.centerStart, + end: AlignmentDirectional.centerEnd, + colors: [ + Colors.black, + Colors.black.withValues(alpha: 1 - fade), + ], + stops: [max(0.0, 1 - _fadeExtent / bounds.width), 1], + ).createShader(bounds, textDirection: Directionality.of(context)); + } + + Widget _buildMoreButton() { + return TonalButtonTheme( + size: TonalButtonSize.section, + child: ElasticPress( + child: CommonPopupBox( + targetBuilder: (open) => IconButton( + tooltip: context.appLocalizations.proxyGroup, + onPressed: open, + icon: const GlyphIcon(AppGlyphs.chevronDown), + ), + popupBuilder: (_) => CommonPopupMenu( + maxVisibleItems: 7, + items: [ + for (final (index, group) in widget.groups.indexed) + CommonPopupMenuItem( + label: group.name, + checked: index == widget.controller.index, + onPressed: () => _select(index), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTabs(_TabExtents extents, bool hasMore) { + final groups = widget.groups; + final last = groups.length - 1; + return ListView.custom( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: EdgeInsetsDirectional.only( + start: _stripInset, + end: hasMore ? 0 : _stripInset, + ), + itemExtentBuilder: (index, _) => index < extents.widths.length + ? extents.widths[index] + (index < last ? _tabSpacing : 0) + : null, + childrenDelegate: _TabsDelegate( + (_, index) => Padding( + padding: EdgeInsetsDirectional.only( + end: index < last ? _tabSpacing : 0, + ), + child: _GroupTab( + name: groups[index].name, + index: index, + selected: _selected, + onPressed: () => _select(index), + ), + ), + childCount: groups.length, + extent: extents.total, + ), + ); + } + + @override + Widget build(BuildContext context) { + final extents = _extents = _TabExtents.of(context, widget.groups, _extents); + return SizedBox( + height: TonalButtonSize.section.button, + child: ValueListenableBuilder( + valueListenable: _hasMore, + builder: (_, hasMore, moreButton) { + return Row( + children: [ + Expanded( + child: NotificationListener( + onNotification: (notification) => + _handleMetrics(notification.metrics), + child: NotificationListener( + onNotification: (notification) => + _handleMetrics(notification.metrics), + child: ValueListenableBuilder( + valueListenable: _fade, + builder: (_, fade, child) => ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => + _buildEdgeFade(bounds, fade), + child: child, + ), + child: _buildTabs(extents, hasMore), + ), + ), + ), + ), + if (hasMore) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8, end: 16), + child: moreButton, + ), + ], + ); + }, + child: _buildMoreButton(), + ), + ); + } +} + +/// The width each chip lays its label out at, worked out the way a +/// [CommonCard]'s button sizes itself. +class _TabExtents { + _TabExtents._(this._key, this.widths) : starts = _startsOf(widths); + + final Object _key; + final List widths; + final List starts; + + double get total => widths.isEmpty ? 0 : starts.last + widths.last; + + static List _startsOf(List widths) { + final starts = []; + var start = 0.0; + for (final width in widths) { + starts.add(start); + start += width + _tabSpacing; + } + return starts; + } + + static _TabExtents of( + BuildContext context, + List groups, + _TabExtents? previous, + ) { + final style = AppTextStyle.subheadline.of(context, emphasized: true); + final buttonStyle = FilledButtonTheme.of(context).style; + final defaults = const FilledButton( + onPressed: null, + child: null, + ).defaultStyleOf(context); + final minimumSize = (buttonStyle?.minimumSize ?? defaults.minimumSize)! + .resolve(const {})!; + final density = (buttonStyle?.visualDensity ?? defaults.visualDensity)!; + final adjustment = density.baseSizeAdjustment.dx; + final minWidth = max(0.0, minimumSize.width + adjustment); + final padding = 2 * (_tabPadding + max(0.0, adjustment)); + final names = [for (final group in groups) group.name]; + final key = ( + _NamesKey(names), + style, + MediaQuery.boldTextOf(context), + MediaQuery.textScalerOf(context), + Directionality.of(context), + Localizations.maybeLocaleOf(context), + minWidth, + padding, + ); + if (previous != null && previous._key == key) { + return previous; + } + return _TabExtents._(key, [ + for (final name in names) + max( + minWidth, + EmojiText.widthOf(context, name, style).ceilToDouble() + padding, + ), + ]); + } +} + +/// Reports the strip's whole extent, which a list of varied extents would +/// otherwise extrapolate from the chips it has built. +class _TabsDelegate extends SliverChildBuilderDelegate { + _TabsDelegate( + super.builder, { + required super.childCount, + required this.extent, + }) : super(addAutomaticKeepAlives: false); + + final double extent; + + @override + double? estimateMaxScrollOffset( + int firstIndex, + int lastIndex, + double leadingScrollOffset, + double trailingScrollOffset, + ) => extent; +} + +class _NamesKey { + const _NamesKey(this.names); + + final List names; + + @override + bool operator ==(Object other) => + other is _NamesKey && stringListEquality.equals(names, other.names); + + @override + int get hashCode => stringListEquality.hash(names); +} + +/// Rebuilds only when it gains or loses the selection, so a swipe across +/// hundreds of groups rebuilds two tabs per step instead of the strip. +class _GroupTab extends StatefulWidget { + const _GroupTab({ + required this.name, + required this.index, + required this.selected, + required this.onPressed, + }); + + final String name; + final int index; + final ValueListenable selected; + final VoidCallback onPressed; + + @override + State<_GroupTab> createState() => _GroupTabState(); +} + +class _GroupTabState extends State<_GroupTab> { + late bool _isSelected; + + @override + void initState() { + super.initState(); + _isSelected = widget.selected.value == widget.index; + widget.selected.addListener(_handleSelected); + } + + @override + void didUpdateWidget(_GroupTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selected != widget.selected) { + oldWidget.selected.removeListener(_handleSelected); + widget.selected.addListener(_handleSelected); + } + _isSelected = widget.selected.value == widget.index; + } + + @override + void dispose() { + widget.selected.removeListener(_handleSelected); + super.dispose(); + } + + void _handleSelected() { + final isSelected = widget.selected.value == widget.index; + if (isSelected != _isSelected) { + setState(() => _isSelected = isSelected); + } + } + + @override + Widget build(BuildContext context) { + final selected = _isSelected; + final colorScheme = context.colorScheme; + return CommonCard( + isSelected: selected, + fillsSelection: true, + shape: AppShape.full, + padding: const EdgeInsets.symmetric(horizontal: _tabPadding), + onPressed: widget.onPressed, + child: AnimatedDefaultTextStyle( + duration: midDuration, + style: AppTextStyle.subheadline + .of(context, emphasized: true) + .copyWith( + color: selected + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + ), + child: EmojiText(widget.name, maxLines: 1), + ), + ); + } +} diff --git a/lib/views/proxies/list.dart b/lib/views/proxies/list.dart index eefd97f1..d142785f 100644 --- a/lib/views/proxies/list.dart +++ b/lib/views/proxies/list.dart @@ -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>; - 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 { 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 { @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 currentUnfoldSet, String groupName) { + void _handleChange(String groupName) { _autoScrollToGroup(groupName); - final tempUnfoldSet = Set.from(currentUnfoldSet); - if (tempUnfoldSet.contains(groupName)) { - tempUnfoldSet.remove(groupName); - _stopEnterAnimated(); - } else { + final tempUnfoldSet = Set.from(ref.read(unfoldSetProvider)); + if (!tempUnfoldSet.remove(groupName)) { tempUnfoldSet.add(groupName); - _startEnterAnimated(groupName); } ref .read(proxiesActionProvider.notifier) .updateCurrentUnfoldSet(tempUnfoldSet); } - GroupOffsets _getGroupOffsets({ - required List groups, - required int columns, - required Set currentUnfoldSet, - required ProxyCardType cardType, - }) { + GroupOffsets _getGroupOffsets() { + final state = ref.read(proxiesListStateProvider); final offsets = []; - 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 proxies, - required int rowIndex, - required int columns, - required ProxyCardType cardType, - }) { - final groupName = group.name; - final enterAnimated = _enterGroupName == groupName; - final children = proxies.indexed - .map((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 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 >[]; - 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 { 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 { 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 { ).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 { PinnedHeaderSliver( child: SizedBox(height: barInset + _pinnedHeaderGap), ), - for (final group in state.groups) - _buildGroup( - context, - group: group, - currentUnfoldSet: state.currentUnfoldSet, - columns: columns, - cardType: state.proxyCardType, + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: _groupInset, ), + sliver: groupColumns == 1 + ? SliverMainAxisGroup( + slivers: groups.map(buildGroup).toList(), + ) + : SliverCrossAxisGroup( + slivers: [ + for ( + var column = 0; + column < groupColumns; + column++ + ) ...[ + if (column > 0) + SliverConstrainedCrossAxis( + maxExtent: gap, + sliver: const SliverToBoxAdapter(), + ), + SliverCrossAxisExpanded( + flex: 1, + sliver: SliverMainAxisGroup( + slivers: [ + for ( + var index = column; + index < groups.length; + index += groupColumns + ) + buildGroup(groups[index]), + ], + ), + ), + ], + ], + ), + ), SliverToBoxAdapter( child: SizedBox( height: 16 + BottomInsetScope.of(context), @@ -348,6 +278,303 @@ class _ProxiesListViewState extends ConsumerState { } } +double _rowsExtent(int rowCount, double rowExtent) => + rowCount == 0 ? 0 : rowCount * rowExtent + _lastRowGrowth; + +class _GroupSliverKey extends ValueKey { + 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 proxies) { + final group = widget.group; + final cardType = widget.cardType; + final groupName = group.name; + final children = proxies + .map( + (proxy) => Flexible( + child: SizedBox( + height: getItemHeight(cardType), + child: ProxyCard( + testUrl: group.testUrl, + type: cardType, + groupType: group.type, + key: ValueKey('$groupName.${proxy.name}'), + proxy: proxy, + groupName: groupName, + ), + ), + ), + ) + .fill(widget.columns, filler: (_) => const Flexible(child: SizedBox())) + .separated(const SizedBox(width: 8)); + return SurfaceScope( + level: context.raisedSurfaceLevel, + child: Padding( + padding: const EdgeInsets.only( + left: _headerInset, + right: _headerInset, + bottom: proxyGridSpacing, + ), + child: Row(children: children.toList()), + ), + ); + } + + Widget _buildRows(double extent) { + final rows = widget.group.all.chunks(widget.columns).toList(); + return SliverFixedExtentList( + itemExtent: extent, + delegate: SliverChildBuilderDelegate( + (context, index) => _buildRow(context, rows[index]), + childCount: rows.length, + addAutomaticKeepAlives: false, + ), + ); + } + + /// The card reaches up behind the header's rounded bottom corners, so the + /// two read as one while the rows unfold or fold. + Widget _buildUnfoldingRows(double extent, ShapeDecoration decoration) { + final rows = widget.group.all.chunks(widget.columns).toList(); + final visible = min( + rows.length.toDouble(), + MediaQuery.sizeOf(context).height / extent, + ); + final count = min(rows.length, visible.floor() + 1); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: -AppCorner.xl.ap(context), + left: 0, + right: 0, + bottom: 0, + child: DecoratedBox(decoration: decoration), + ), + SizeTransition( + sizeFactor: _unfold, + alignment: Alignment.topCenter, + child: Column( + children: [ + for (var index = 0; index < count; index++) + UnfoldEntryTransition( + animation: _unfold, + index: index, + visibleCount: visible, + child: SizedBox( + height: extent, + child: _buildRow(context, rows[index]), + ), + ), + if (count == rows.length) const SizedBox(height: _lastRowGrowth), + ], + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final groupName = widget.group.name; + final isExpand = ref.watch( + unfoldSetProvider.select((state) => state.contains(groupName)), + ); + final unfolded = _controller.isCompleted; + Widget? rows; + if (!_controller.isDismissed && widget.group.all.isNotEmpty) { + final extent = getRowExtent(widget.cardType); + final decoration = ShapeDecoration( + color: context.raisedSurfaceLevel.colorOf(context.colorScheme), + shape: AppShape.vertical( + top: AppCorner.none, + bottom: AppCorner.xl.ap(context), + ), + ); + if (!unfolded) { + _rows = null; + rows = SliverToBoxAdapter( + child: _buildUnfoldingRows(extent, decoration), + ); + } else { + if (_rows == null || _rowExtent != extent) { + _rows = _buildRows(extent); + _rowExtent = extent; + } + rows = DecoratedSliver( + decoration: decoration, + sliver: SliverMainAxisGroup( + slivers: [ + _rows!, + const SliverToBoxAdapter(child: SizedBox(height: _lastRowGrowth)), + ], + ), + ); + } + } else { + _rows = null; + } + Widget buildHeader({double floating = 0, double lift = 0}) => SizedBox( + height: listHeaderHeight, + child: ListHeader( + enterAnimated: false, + floating: floating, + lift: lift, + onScrollToSelected: widget.onScrollToSelected, + key: ValueKey(groupName), + isExpand: isExpand, + unfolded: unfolded, + group: widget.group, + onChange: widget.onChange, + ), + ); + return SliverPadding( + padding: EdgeInsets.only(bottom: widget.gap), + sliver: SliverMainAxisGroup( + slivers: [_buildPinnedHeader(isExpand, buildHeader), ?rows], + ), + ); + } + + /// Rounds the header and lifts it as its own rows scroll under it, and lets + /// the shadow go before the end of the group pushes it out. The page color + /// behind its upper half hides the rows that went under it, which would + /// otherwise show around its top corners and above it. Every header is laid + /// out on every pass, so one beyond the cache area builds nothing. + Widget _buildPinnedHeader( + bool isExpand, + Widget Function({double floating, double lift}) buildHeader, + ) { + final columns = widget.columns; + final rowCount = (widget.group.all.length + columns - 1) ~/ columns; + final rowsExtent = isExpand ? _rowsExtent(rowCount, _rowExtent) : 0.0; + final groupExtent = + listHeaderHeight + + (_controller.isDismissed + ? 0.0 + : _rowsExtent(rowCount, getRowExtent(widget.cardType))); + (double, double, double, Color)? headerState; + Widget? header; + return SliverLayoutBuilder( + builder: (context, constraints) { + final beyondCache = + constraints.remainingCacheExtent <= 0 || + constraints.scrollOffset + constraints.cacheOrigin >= groupExtent; + final under = isExpand + ? constraints.scrollOffset + constraints.overlap + : 0.0; + final state = beyondCache + ? null + : ( + (under / _liftDistance).clamp(0.0, 1.0), + (min(under, rowsExtent - under) / _liftDistance).clamp( + 0.0, + 1.0, + ), + isExpand ? constraints.overlap : 0.0, + context.surfaceLevel.colorOf(context.colorScheme), + ); + if (header != null && state == headerState) { + return header!; + } + headerState = state; + if (state == null) { + return header = PinnedHeaderSliver( + child: SizedBox(height: listHeaderHeight), + ); + } + final (floating, lift, cover, pageColor) = state; + return header = PinnedHeaderSliver( + child: RepaintBoundary( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: -cover, + left: 0, + right: 0, + height: cover + listHeaderHeight / 2, + child: ColoredBox(color: pageColor), + ), + buildHeader(floating: floating, lift: lift), + ], + ), + ), + ); + }, + ); + } +} + class ListHeader extends ConsumerWidget { final Group group; @@ -355,15 +582,28 @@ class ListHeader extends ConsumerWidget { final Function(String groupName) onScrollToSelected; final bool isExpand; + /// Whether the rows have settled under the header, squaring its bottom. + final bool unfolded; + final bool enterAnimated; + /// How far the group's own rows have scrolled under the pinned header, as a + /// fraction of the distance that rounds its bottom corners. + final double floating; + + /// The shadow that lifts the header off the rows under it. + final double lift; + const ListHeader({ super.key, this.enterAnimated = true, + this.floating = 0, + this.lift = 0, required this.group, required this.onChange, required this.onScrollToSelected, required this.isExpand, + required this.unfolded, }); @override @@ -372,14 +612,18 @@ class ListHeader extends ConsumerWidget { final isDelayTesting = ref.watch( delayTestingGroupsProvider.select((state) => state.contains(groupName)), ); - return CommonCard( + final colorScheme = context.colorScheme; + final level = floating > 0 + ? SurfaceLevel.floatingOf(colorScheme) + : context.raisedSurfaceLevel; + final radius = AppCorner.xl.ap(context); + final card = CommonCard( enterActionsOnRight: true, enterAnimated: enterAnimated, key: key, - radius: AppCorner.xl.ap, - type: CommonCardType.filled, + radius: radius - _headerButtonInset, child: Padding( - padding: const EdgeInsets.all(_headerInset), + padding: const EdgeInsets.all(_headerInset - _headerButtonInset), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -414,9 +658,43 @@ class ListHeader extends ConsumerWidget { onChange(groupName); }, ); + return DecoratedBox( + decoration: ShapeDecoration( + color: level.colorOf(colorScheme), + shape: AppShape.vertical( + top: radius, + bottom: unfolded ? radius * floating : radius, + ), + shadows: lift == 0 ? null : _floatingShadows(colorScheme, lift), + ), + child: Padding( + padding: const EdgeInsets.all(_headerButtonInset), + child: SurfaceScope( + // CommonCard fills one level above its scope. + level: SurfaceLevel.values[level.index - 1], + child: card, + ), + ), + ); } } +List _floatingShadows(ColorScheme colorScheme, double lift) { + final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0; + return [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08 * strength * lift), + blurRadius: 16, + offset: const Offset(0, 4), + ), + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.04 * strength * lift), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ]; +} + class _GroupIcon extends ConsumerWidget { const _GroupIcon({required this.src, required this.name}); @@ -439,16 +717,18 @@ class _GroupIcon extends ConsumerWidget { height: constraints.maxHeight, width: constraints.maxWidth, alignment: Alignment.center, - padding: EdgeInsets.all(6.ap), + padding: EdgeInsets.all(6.ap(context)), decoration: ShapeDecoration( - color: context.colorScheme.secondaryContainer, - shape: AppShape.all(AppCorner.xl.ap - _headerInset), + color: context.raisedSurfaceLevel.colorOf( + context.colorScheme, + ), + shape: AppShape.all(AppCorner.xl.ap(context) - _headerInset), ), clipBehavior: Clip.antiAlias, child: IconTheme.merge( data: IconThemeData( - size: constraints.maxHeight - 12.ap, - color: context.colorScheme.onSecondaryContainer, + size: constraints.maxHeight - 12.ap(context), + color: context.colorScheme.onSurfaceVariant, ), child: CommonTargetIcon( src: src, @@ -466,7 +746,7 @@ class _GroupIcon extends ConsumerWidget { builder: (_, constraints) { return IconTheme.merge( data: IconThemeData( - size: constraints.maxHeight - 16.ap, + size: constraints.maxHeight, color: context.colorScheme.primary, ), child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)), @@ -492,7 +772,12 @@ class _GroupSummary extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - EmojiText(groupName, style: context.textTheme.titleSmall), + EmojiText( + groupName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyle.callout.of(context, emphasized: true), + ), const SizedBox(height: 2), Flexible(flex: 1, child: _SelectedProxyName(groupName: groupName)), ], @@ -517,7 +802,7 @@ class _SelectedProxyName extends ConsumerWidget { proxyName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.toLight, + style: AppTextStyle.caption1.of(context, emphasized: true).toLight, ); } } @@ -559,19 +844,18 @@ class _GroupActions extends StatelessWidget { tooltip: context.appLocalizations.delayTest, onPressed: isDelayTesting ? null : onDelayTest, icon: isDelayTesting - ? SizedBox.square( - dimension: TonalButtonSize.compact.icon, - child: const Padding( - padding: EdgeInsets.all(2), - child: CommonCircleLoading(), - ), - ) + ? CommonCircleLoading(size: TonalButtonSize.compact.icon) : const GlyphIcon(AppGlyphs.bolt), ), ], ) else - Text(groupType, style: context.textTheme.labelMedium?.toLight), + Text( + groupType, + style: AppTextStyle.caption1 + .of(context, emphasized: true) + .toLight, + ), const SizedBox(width: 6), ElasticPress( child: IconButton( diff --git a/lib/views/proxies/providers.dart b/lib/views/proxies/providers.dart index 300da80f..4b2d1dd8 100644 --- a/lib/views/proxies/providers.dart +++ b/lib/views/proxies/providers.dart @@ -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 providers; + + const ExternalProvidersSliver({super.key, required this.providers}); @override - ConsumerState createState() => _ProvidersViewState(); + ConsumerState createState() => + _ExternalProvidersSliverState(); } -class _ProvidersViewState extends ConsumerState { +class _ExternalProvidersSliverState + extends ConsumerState { + bool _isUpdating = false; + Future _updateProviders() async { final appLocalizations = context.appLocalizations; - final providers = ref.read(providersProvider); final proxiesAction = ref.read(proxiesActionProvider.notifier); final List messages = []; - final updateProviders = 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), - ), - ); + 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 _buildSection({ - required String title, - required List 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 { ); }, ), - ), - ]; - } - - @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 { 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 { 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 { } } -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 diff --git a/lib/views/proxies/proxies.dart b/lib/views/proxies/proxies.dart index 27c5d113..64027490 100644 --- a/lib/views/proxies/proxies.dart +++ b/lib/views/proxies/proxies.dart @@ -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 createState() => _ProxiesViewState(); @@ -21,7 +23,10 @@ class ProxiesView extends ConsumerStatefulWidget { class _ProxiesViewState extends ConsumerState { final GlobalKey _proxiesTabKey = GlobalKey(); - bool _hasProviders = false; + + // The same instance on every build, so the bar changing with the current + // group or its delay test leaves the page alone. + late final _tabView = ProxiesTabView(key: _proxiesTabKey); bool _isTab = false; List _buildMenuItems(BuildContext context) { @@ -42,19 +47,6 @@ class _ProxiesViewState extends ConsumerState { ); }, ), - if (_hasProviders) - CommonPopupMenuItem( - glyph: AppGlyphs.layers, - label: appLocalizations.providers, - onPressed: () { - showExtend( - context, - builder: (_) { - return const ProvidersView(); - }, - ); - }, - ), ]; } @@ -62,16 +54,7 @@ class _ProxiesViewState extends ConsumerState { if (!_isTab) { return null; } - final currentGroupName = ref.watch( - proxiesTabControllerStateProvider.select( - (state) => state.currentGroupName, - ), - ); - final isDelayTesting = ref.watch( - delayTestingGroupsProvider.select( - (state) => state.contains(currentGroupName), - ), - ); + final isDelayTesting = ref.watch(currentGroupDelayTestingProvider); return IconButtonData( glyph: AppGlyphs.bolt, onPressed: () { @@ -89,16 +72,6 @@ class _ProxiesViewState extends ConsumerState { @override void initState() { super.initState(); - ref.listenManual(providersProvider.select((state) => state.isNotEmpty), ( - prev, - next, - ) { - if (prev != next) { - setState(() { - _hasProviders = next; - }); - } - }, fireImmediately: true); ref.listenManual( proxiesStyleSettingProvider.select( (state) => state.type == ProxiesType.tab, @@ -120,12 +93,15 @@ class _ProxiesViewState extends ConsumerState { proxiesStyleSettingProvider.select((state) => state.type), ); final isLoading = ref.watch(loadingProvider(LoadingTag.proxies)); + final picker = widget.picker; + final primaryAction = _buildPrimaryAction(); return CommonScaffold( isLoading: isLoading, resizeToAvoidBottomInset: false, - primaryAction: _buildPrimaryAction(), + primaryAction: primaryAction, + searchActions: [?primaryAction], iconActions: [ - if (_isTab) + if (_isTab && !picker) IconButtonData( glyph: AppGlyphs.locate, onPressed: () { @@ -134,11 +110,11 @@ class _ProxiesViewState extends ConsumerState { tooltip: context.appLocalizations.scrollToSelected, ), ], - menuItems: _buildMenuItems(context), + menuItems: picker ? const [] : _buildMenuItems(context), title: context.appLocalizations.proxies, searchState: AppBarSearchState(onSearch: _onSearch), body: switch (proxiesType) { - ProxiesType.tab => ProxiesTabView(key: _proxiesTabKey), + ProxiesType.tab => _tabView, ProxiesType.list => const ProxiesListView(), }, ); diff --git a/lib/views/proxies/setting.dart b/lib/views/proxies/setting.dart index ee23df35..a8884355 100644 --- a/lib/views/proxies/setting.dart +++ b/lib/views/proxies/setting.dart @@ -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 _writer( + 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 _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 _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 _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 _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 _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 _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( + 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( + 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( + 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( + 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( + 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), ], ), ); diff --git a/lib/views/proxies/tab.dart b/lib/views/proxies/tab.dart index 4752ba41..981b1a25 100644 --- a/lib/views/proxies/tab.dart +++ b/lib/views/proxies/tab.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/common.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/widgets/widgets.dart'; @@ -12,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'card.dart'; import 'common.dart'; +import 'group_tabs.dart'; class ProxiesTabView extends ConsumerStatefulWidget { const ProxiesTabView({super.key}); @@ -25,7 +25,6 @@ class ProxiesTabView extends ConsumerStatefulWidget { class ProxiesTabViewState extends ConsumerState with TickerProviderStateMixin { TabController? _tabController; - final _hasMoreButtonNotifier = ValueNotifier(false); final Map _scrollControllers = {}; int _columns = 1; @@ -48,7 +47,6 @@ class ProxiesTabViewState extends ConsumerState @override void dispose() { _destroyTabController(); - _hasMoreButtonNotifier.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -56,7 +54,10 @@ class ProxiesTabViewState extends ConsumerState } ScrollController _scrollControllerOf(String groupName) { - return _scrollControllers.putIfAbsent(groupName, ScrollController.new); + return _scrollControllers.putIfAbsent( + groupName, + () => sheetScrollController(context), + ); } // The page of a dropped group still holds its controller until this frame @@ -116,91 +117,6 @@ class ProxiesTabViewState extends ConsumerState return groups[index]; } - Widget _buildMoreButton() { - return Consumer( - builder: (_, ref, _) { - final isMobileView = ref.watch(isMobileViewProvider); - return IconButton( - tooltip: context.appLocalizations.more, - onPressed: _showMoreMenu, - iconSize: 20, - icon: isMobileView - ? const GlyphIcon(AppGlyphs.chevronDown) - : const GlyphIcon(AppGlyphs.chevronForward), - ); - }, - ); - } - - // A mask rather than a surface-colored cover holds on any page background; - // at full scroll the fade lands in the last tab's trailing label padding. - Shader _buildTabsMask(Rect bounds, bool hasMore) { - final coverStart = bounds.width - kMinInteractiveDimension; - final fadeStart = coverStart - kTabLabelPadding.right; - if (!hasMore || fadeStart <= 0) { - return const LinearGradient( - colors: [Colors.black, Colors.black], - ).createShader(bounds); - } - return LinearGradient( - begin: AlignmentDirectional.centerStart, - end: AlignmentDirectional.centerEnd, - colors: [Colors.black, Colors.transparent], - stops: [fadeStart / bounds.width, coverStart / bounds.width], - ).createShader(bounds, textDirection: Directionality.of(context)); - } - - void _showMoreMenu() { - showSheet( - context: context, - builder: (_) { - return CommonScaffold( - body: Builder( - builder: (context) => SingleChildScrollView( - padding: const EdgeInsets.all( - 16, - ).copyWith(top: context.contentTopPadding), - child: Consumer( - builder: (_, ref, _) { - final state = ref.watch(proxiesTabControllerStateProvider); - final groupNames = state.groupNames; - final currentGroupName = state.currentGroupName; - return SizedBox( - width: double.infinity, - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 8, - spacing: 8, - children: [ - for (final groupName in groupNames) - SettingTextCard( - groupName, - onPressed: () { - final index = groupNames.indexWhere( - (item) => item == groupName, - ); - if (index == -1) return; - _tabController?.animateTo(index); - ref - .read(proxiesActionProvider.notifier) - .updateCurrentGroupName(groupName); - Navigator.of(context).pop(); - }, - isSelected: groupName == currentGroupName, - ), - ], - ), - ); - }, - ), - ), - ), - title: context.appLocalizations.proxyGroup, - ); - }, - ); - } - void _tabControllerListener([int? index]) { final group = _getGroup(index ?? _tabController?.index); if (group == null) { @@ -244,7 +160,12 @@ class ProxiesTabViewState extends ConsumerState Widget build(BuildContext context) { final appLocalizations = context.appLocalizations; ref.watch(themeSettingProvider.select((state) => state.textScale)); - final state = ref.watch(proxiesTabStateProvider.select((state) => state)); + final groups = ref.watch( + proxiesTabStateProvider.select((state) => state.groups), + ); + final cardType = ref.watch( + proxiesTabStateProvider.select((state) => state.proxyCardType), + ); final proxiesLayout = ref.watch( proxiesStyleSettingProvider.select((state) => state.layout), ); @@ -253,94 +174,51 @@ class ProxiesTabViewState extends ConsumerState 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( - onNotification: (scrollNotification) { - _hasMoreButtonNotifier.value = - scrollNotification.metrics.maxScrollExtent > 0; - return false; - }, - child: ValueListenableBuilder( - valueListenable: _hasMoreButtonNotifier, - builder: (_, hasMore, moreButton) { - return Stack( - alignment: AlignmentDirectional.centerEnd, - children: [ - ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => - _buildTabsMask(bounds, hasMore), - child: TabBar( - controller: _tabController, - padding: EdgeInsetsDirectional.only( - start: 16, - end: hasMore ? kMinInteractiveDimension : 16, - ), - dividerColor: Colors.transparent, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: [ + child: controller == null + ? const SizedBox.shrink() + : AppBarClearance( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 16), + child: GroupTabs(controller: controller, groups: groups), + ), + Expanded( + child: WidthBuilder( + builder: (_, width) { + final columns = getProxiesColumns( + max(width - 32, 0), + proxiesLayout, + ); + _columns = columns; + return TabBarView( + controller: controller, + children: [ for (final group in groups) - Tab( - child: Builder( - builder: (context) { - return EmojiText( - group.name, - style: DefaultTextStyle.of(context).style, - ); - }, - ), + ProxyGroupView( + key: ValueKey(group.name), + controller: _scrollControllerOf(group.name), + group: group, + columns: columns, + cardType: cardType, ), ], - ), - ), - if (hasMore) moreButton!, - ], - ); - }, - child: _buildMoreButton(), + ); + }, + ), + ), + ], ), ), - Expanded( - child: LayoutBuilder( - builder: (_, constraints) { - final columns = getProxiesColumns( - max(constraints.maxWidth - 32, 0), - proxiesLayout, - ); - _columns = columns; - return TabBarView( - controller: _tabController, - children: [ - for (final group in groups) - ProxyGroupView( - key: ValueKey(group.name), - controller: _scrollControllerOf(group.name), - group: group, - columns: columns, - cardType: state.proxyCardType, - ), - ], - ); - }, - ), - ), - ], - ), - ), ); } } @@ -350,8 +228,6 @@ class ProxyGroupView extends ConsumerWidget { final ScrollController controller; final int columns; final ProxyCardType cardType; - final bool shrinkWrap; - final double topPadding; const ProxyGroupView({ super.key, @@ -359,8 +235,6 @@ class ProxyGroupView extends ConsumerWidget { required this.controller, required this.columns, required this.cardType, - this.shrinkWrap = false, - this.topPadding = 16, }); PageStorageKey _getPageStorageKey(WidgetRef ref) { @@ -382,9 +256,7 @@ class ProxyGroupView extends ConsumerWidget { child: GridView.builder( key: _getPageStorageKey(ref), controller: controller, - shrinkWrap: shrinkWrap, padding: EdgeInsets.only( - top: topPadding, left: 16, right: 16, bottom: 16 + BottomInsetScope.of(context), @@ -396,6 +268,7 @@ class ProxyGroupView extends ConsumerWidget { mainAxisExtent: getItemHeight(cardType), ), itemCount: proxies.length, + addAutomaticKeepAlives: false, itemBuilder: (_, index) { final proxy = proxies[index]; return ProxyCard( diff --git a/test/common/compute_test.dart b/test/common/compute_test.dart index 5fab0d0b..4623eb18 100644 --- a/test/common/compute_test.dart +++ b/test/common/compute_test.dart @@ -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(''), ''); diff --git a/test/lint/sheet_scroll_controller_test.dart b/test/lint/sheet_scroll_controller_test.dart index 6ca6f49b..775f0fe8 100644 --- a/test/lint/sheet_scroll_controller_test.dart +++ b/test/lint/sheet_scroll_controller_test.dart @@ -13,6 +13,8 @@ const _allowed = { 'lib/features/editor/completion_popup.dart', // Nor a drag on a popup menu, which stands over the sheet it opened from. 'lib/ui/popup.dart', + // Nor a drag along the proxies group chips, which run across the sheet. + 'lib/views/proxies/group_tabs.dart', }; int _lineOf(String source, int offset) => diff --git a/test/providers/config_test.dart b/test/providers/config_test.dart index 4e0e3af2..15677a1a 100644 --- a/test/providers/config_test.dart +++ b/test/providers/config_test.dart @@ -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', () { diff --git a/test/providers/state_derived_test.dart b/test/providers/state_derived_test.dart index ed527ae0..e69b9933 100644 --- a/test/providers/state_derived_test.dart +++ b/test/providers/state_derived_test.dart @@ -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 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, diff --git a/test/views/proxies_group_offsets_test.dart b/test/views/proxies_group_offsets_test.dart index a5973e52..ae342d67 100644 --- a/test/views/proxies_group_offsets_test.dart +++ b/test/views/proxies_group_offsets_test.dart @@ -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); }); } diff --git a/test/views/proxies_list_test.dart b/test/views/proxies_list_test.dart index 5876e90a..fe0a140a 100644 --- a/test/views/proxies_list_test.dart +++ b/test/views/proxies_list_test.dart @@ -15,7 +15,7 @@ import '../helpers/test_profiles.dart'; void main() { testWidgets('the list scrolls its rows under the bar, pinning the header ' 'at its foot', (tester) async { - tester.view.physicalSize = const Size(1400, 1000); + tester.view.physicalSize = const Size(500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -49,7 +49,7 @@ void main() { globalState.container = container; container .read(viewSizeProvider.notifier) - .update((_) => const Size(1400, 1000)); + .update((_) => const Size(500, 1000)); await tester.pumpWidget( UncontrolledProviderScope( @@ -83,4 +83,73 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('builds only the headers near the view and pins a far one ' + 'once its rows reach the bar', (tester) async { + tester.view.physicalSize = const Size(500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final profile = Profile.normal().copyWith(unfoldSet: {'Far'}); + final groups = [ + for (var index = 0; index < 40; index++) + Group(name: 'G$index', type: GroupType.Selector, hidden: false), + Group( + name: 'Far', + type: GroupType.Selector, + hidden: false, + all: List.generate( + 160, + (index) => Proxy(name: 'Proxy $index', type: 'Direct'), + ), + ), + ]; + final container = ProviderContainer( + overrides: [ + profilesProvider.overrideWith(() => TestProfiles([profile])), + currentProfileIdProvider.overrideWithBuild((_, _) => profile.id), + currentGroupsStateProvider.overrideWithValue( + GroupsState(value: groups), + ), + groupsProvider.overrideWithValue(groups), + ], + ); + addTearDown(container.dispose); + globalState.container = container; + container + .read(viewSizeProvider.notifier) + .update((_) => const Size(500, 1000)); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const TestApp(child: ProxiesView()), + ), + ); + container + .read(proxiesStyleSettingProvider.notifier) + .update((state) => state.copyWith(type: ProxiesType.list)); + await tester.pump(); + + expect( + find.byType(ListHeader, skipOffstage: false).evaluate().length, + lessThan(groups.length ~/ 2), + ); + + final position = tester + .state(find.byType(Scrollable).first) + .position; + position.jumpTo(position.maxScrollExtent - 1000); + await tester.pump(); + + final barBottom = tester.getRect(find.byType(AppBar)).bottom; + final far = find.ancestor( + of: find.text('Far'), + matching: find.byType(ListHeader), + ); + expect(tester.getRect(far).top, barBottom + 8); + expect(find.text('G0', skipOffstage: false), findsNothing); + expect(tester.takeException(), isNull); + }); } diff --git a/test/views/proxy_providers_view_test.dart b/test/views/proxy_providers_view_test.dart index 4e31fba4..c253443d 100644 --- a/test/views/proxy_providers_view_test.dart +++ b/test/views/proxy_providers_view_test.dart @@ -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 pump(WidgetTester tester, ProviderContainer container) async { + Future 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 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 pumpUntil( @@ -130,8 +164,9 @@ void main() { await tester.runAsync( () => Future.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(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'); diff --git a/test/widgets/proxies_list_test.dart b/test/widgets/proxies_list_test.dart index 596baaa6..09fb89fd 100644 --- a/test/widgets/proxies_list_test.dart +++ b/test/widgets/proxies_list_test.dart @@ -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 pumpList( WidgetTester tester, { Map selectedMap = const {}, + ViewMode viewMode = ViewMode.mobile, + List? groups, + Set unfoldSet = const {'G0', 'G1', 'G2'}, + ValueListenable? 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 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( + 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(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( + 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(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); diff --git a/test/widgets/proxies_tab_test.dart b/test/widgets/proxies_tab_test.dart index 19598b6c..2dbaa167 100644 --- a/test/widgets/proxies_tab_test.dart +++ b/test/widgets/proxies_tab_test.dart @@ -5,11 +5,17 @@ import 'package:fl_clash/providers/config.dart'; import 'package:fl_clash/providers/database.dart'; import 'package:fl_clash/providers/state.dart'; import 'package:fl_clash/state.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; +import 'package:fl_clash/ui/ui.dart'; +import 'package:fl_clash/views/proxies/card.dart'; +import 'package:fl_clash/views/proxies/group_tabs.dart'; +import 'package:fl_clash/views/proxies/proxies.dart'; import 'package:fl_clash/views/proxies/tab.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../helpers/glyph_finders.dart'; import '../helpers/test_app.dart'; import '../helpers/test_profiles.dart'; @@ -71,60 +77,160 @@ void main() { return key; } - testWidgets('current group follows the rendered tab list', (tester) async { + testWidgets('current group follows the tapped tab', (tester) async { final key = await pumpTabView(tester); expect(key.currentState?.currentGroup?.name, 'B'); + expect(isTabSelected(tester, 'B'), isTrue); - final tabBar = tester.widget(find.byType(TabBar)); - tabBar.controller?.animateTo(1); + await tester.tap(find.text('C')); await tester.pumpAndSettle(); expect(key.currentState?.currentGroup?.name, 'C'); expect(globalContainer.read(currentProfileProvider)?.currentGroupName, 'C'); + expect(isTabSelected(tester, 'C'), isTrue); + expect(isTabSelected(tester, 'B'), isFalse); }); - testWidgets( - 'keeps the outgoing tab bar usable while the empty state enters', - (tester) async { - final key = await pumpTabView(tester); + testWidgets('a swipe between pages moves the selected tab', (tester) async { + final key = await pumpTabView(tester); - globalContainer.read(_tabStateProvider.notifier).set(_tabState([])); - await tester.pump(); + await tester.drag(find.byType(TabBarView), const Offset(-500, 0)); + await tester.pumpAndSettle(); - expect(find.byType(TabBar), findsOneWidget); - await tester.tap(find.byType(Tab).at(1), warnIfMissed: false); - await tester.pump(); + expect(key.currentState?.currentGroup?.name, 'C'); + expect(isTabSelected(tester, 'C'), isTrue); + expect(isTabSelected(tester, 'B'), isFalse); + }); - expect(tester.takeException(), isNull); - expect(key.currentState?.currentGroup, isNull); + testWidgets('keeps the outgoing tabs usable while the empty state enters', ( + tester, + ) async { + final key = await pumpTabView(tester); - await tester.pumpAndSettle(); + globalContainer.read(_tabStateProvider.notifier).set(_tabState([])); + await tester.pump(); - expect(find.byType(TabBar), findsNothing); - expect(tester.takeException(), isNull); - }, - ); + expect(find.text('C'), findsOneWidget); + await tester.tap(find.text('C'), warnIfMissed: false); + await tester.pump(); - testWidgets('the last tab scrolls clear of the more button fade', ( + expect(tester.takeException(), isNull); + expect(key.currentState?.currentGroup, isNull); + + await tester.pumpAndSettle(); + + expect(find.text('C'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('the more button shows only once the tabs overflow', ( + tester, + ) async { + await pumpTabView(tester); + + expect(find.byGlyph(AppGlyphs.chevronDown), findsNothing); + + globalContainer + .read(_tabStateProvider.notifier) + .set(_tabState([for (var i = 0; i < 12; i++) _group('Group $i')])); + await tester.pumpAndSettle(); + + expect(find.byGlyph(AppGlyphs.chevronDown), findsOneWidget); + }); + + testWidgets('opens with the current tab in view beside the more button', ( tester, ) async { + globalContainer + .read(_tabStateProvider.notifier) + .set( + _tabState([ + for (var i = 0; i < 12; i++) _group('Group $i'), + ], current: 'Group 11'), + ); + await pumpTabView(tester); + await tester.pump(); + await tester.pump(); + + final current = tester.getRect(tabOf('Group 11')); + final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown)); + expect(isTabSelected(tester, 'Group 11'), isTrue); + expect(current.right, lessThan(more.left)); + }); + + testWidgets('the last tab scrolls clear of the more button', (tester) async { globalContainer .read(_tabStateProvider.notifier) .set(_tabState([for (var i = 0; i < 12; i++) _group('Group $i')])); await pumpTabView(tester); + await tester.pump(); final tabs = find.descendant( - of: find.byType(TabBar), + of: find.byType(GroupTabs), matching: find.byType(Scrollable), ); final position = tester.state(tabs).position; position.jumpTo(position.maxScrollExtent); await tester.pump(); - final label = tester.getRect(find.text('Group 11', findRichText: true)); - final more = tester.getRect(find.byType(IconButton)); - expect(label.right, lessThanOrEqualTo(more.left - kTabLabelPadding.right)); + final last = tester.getRect(tabOf('Group 11')); + final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown)); + expect(last.right, lessThan(more.left)); + }); + + testWidgets('the more menu stays short, opens on the checked current group ' + 'and switches to the picked one', (tester) async { + tester.view.physicalSize = const Size(800, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + globalContainer + .read(_tabStateProvider.notifier) + .set( + _tabState([ + for (var i = 0; i < 20; i++) _group('Group $i'), + ], current: 'Group 12'), + ); + final key = await pumpTabView(tester); + await tester.pump(); + + await tester.tap(find.byGlyph(AppGlyphs.chevronDown)); + await tester.pumpAndSettle(); + + final menu = find.byType(CommonPopupMenu); + final check = find.descendant( + of: menu, + matching: find.byGlyph(AppGlyphs.check), + ); + expect(check, findsOneWidget); + expect( + tester.getCenter(check).dy, + closeTo( + tester + .getCenter( + find.descendant(of: menu, matching: find.text('Group 12')), + ) + .dy, + 1, + ), + ); + + final card = tester.getRect(find.byType(PopupMenuCard)); + expect(card.height, lessThan(tester.view.physicalSize.height / 2)); + + await tester.tap( + find.descendant(of: menu, matching: find.text('Group 15')), + ); + await tester.pumpAndSettle(); + + expect(menu, findsNothing); + expect(key.currentState?.currentGroup?.name, 'Group 15'); + expect(isTabSelected(tester, 'Group 15'), isTrue); + final picked = tester.getRect(tabOf('Group 15')); + final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown)); + expect(picked.left, greaterThanOrEqualTo(0)); + expect(picked.right, lessThan(more.left)); }); testWidgets('scroll to selected brings the row to the top of the grid', ( @@ -162,6 +268,128 @@ void main() { ); }); + testWidgets('cards rest where the grid clips them, clear of the tabs', ( + tester, + ) async { + final group = Group( + type: GroupType.Selector, + name: 'B', + all: [for (var i = 0; i < 40; i++) Proxy(name: 'B-$i', type: 'ss')], + ); + globalContainer + .read(_tabStateProvider.notifier) + .set(_tabState([_group('A'), group])); + await pumpTabView(tester); + await tester.pumpAndSettle(); + final tabs = tester.getRect(tabOf('B')); + final grid = tester.getRect(find.byType(GridView)); + + expect(grid.top, tabs.bottom + 16); + expect(tester.getRect(find.byType(ProxyCard).first).top, grid.top); + }); + + testWidgets('a new current group leaves the visible cards as they were', ( + tester, + ) async { + final group = Group( + type: GroupType.Selector, + name: 'B', + all: [for (var i = 0; i < 4; i++) Proxy(name: 'B-$i', type: 'ss')], + ); + final state = _tabState([_group('A'), group]); + globalContainer.read(_tabStateProvider.notifier).set(state); + await pumpTabView(tester); + final card = find.byType(ProxyCard).first; + final before = tester.widget(card); + + globalContainer + .read(_tabStateProvider.notifier) + .set(state.copyWith(currentGroupName: 'A')); + await tester.pump(); + + expect(tester.widget(card), same(before)); + }); + + testWidgets('lays each tab out at the width its label takes', (tester) async { + const names = ['A', '🚀 节点选择', 'Group with a long name']; + globalContainer + .read(_tabStateProvider.notifier) + .set(_tabState([for (final name in names) _group(name)], current: 'A')); + await pumpTabView(tester); + + for (final name in names) { + final tab = tabOf(name); + expect( + tester.getSize(tab).width, + closeTo( + tester + .renderObject(tab) + .getMaxIntrinsicWidth(double.infinity), + 1, + ), + reason: name, + ); + } + }); + + testWidgets('builds only the tabs in view and opens on a far current one', ( + tester, + ) async { + globalContainer + .read(_tabStateProvider.notifier) + .set( + _tabState([ + for (var i = 0; i < 200; i++) _group('Group $i'), + ], current: 'Group 180'), + ); + await pumpTabView(tester); + await tester.pump(); + await tester.pump(); + + expect( + find + .descendant( + of: find.byType(GroupTabs), + matching: find.byType(CommonCard), + skipOffstage: false, + ) + .evaluate() + .length, + lessThan(30), + ); + final current = tester.getRect(tabOf('Group 180')); + final more = tester.getRect(find.byGlyph(AppGlyphs.chevronDown)); + expect(isTabSelected(tester, 'Group 180'), isTrue); + expect(current.left, greaterThanOrEqualTo(0)); + expect(current.right, lessThan(more.left)); + }); + + testWidgets('the bar following the current group leaves the page built', ( + tester, + ) async { + globalContainer + .read(proxiesStyleSettingProvider.notifier) + .update((state) => state.copyWith(type: ProxiesType.tab)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: globalContainer, + child: const TestApp(child: ProxiesView()), + ), + ); + await tester.pump(); + final tabs = tester.widget(find.byType(GroupTabs)); + + final state = globalContainer.read(_tabStateProvider); + globalContainer + .read(_tabStateProvider.notifier) + .set(state.copyWith(currentGroupName: 'C')); + globalContainer.read(delayTestingGroupsProvider.notifier).start('C'); + await tester.pump(); + + expect(globalContainer.read(currentGroupDelayTestingProvider), isTrue); + expect(tester.widget(find.byType(GroupTabs)), same(tabs)); + }); + testWidgets('rebuilds the tab bar when groups return', (tester) async { final key = await pumpTabView(tester); @@ -172,18 +400,26 @@ void main() { .set(_tabState([_group('A'), _group('B'), _group('C')])); await tester.pumpAndSettle(); - final tabBar = tester.widget(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 groups) { +Finder tabOf(String name) => + find.ancestor(of: find.text(name), matching: find.byType(CommonCard)); + +bool isTabSelected(WidgetTester tester, String name) => + tester.widget(tabOf(name)).isSelected; + +ProxiesTabState _tabState(List groups, {String current = 'B'}) { return ProxiesTabState( groups: groups, - currentGroupName: 'B', - proxyCardType: ProxyCardType.expand, + currentGroupName: current, + proxyCardType: ProxyCardType.standard, ); } diff --git a/test/widgets/proxy_card_focus_test.dart b/test/widgets/proxy_card_focus_test.dart index 46e067cf..0e3eaf6b 100644 --- a/test/widgets/proxy_card_focus_test.dart +++ b/test/widgets/proxy_card_focus_test.dart @@ -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);