Files
FlClash/lib/views/proxies/tab.dart
T
chen08209 eb47ee261f feat(proxies): show groups as cards and group tabs as chips
Changelog: Proxy groups as cards, group tabs as chips
2026-10-17 09:54:29 +08:00

566 lines
17 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'card.dart';
import 'common.dart';
class ProxiesTabView extends ConsumerStatefulWidget {
const ProxiesTabView({super.key});
static Map<String, PageStorageKey> pageListStoreMap = {};
@override
ConsumerState<ProxiesTabView> createState() => ProxiesTabViewState();
}
class ProxiesTabViewState extends ConsumerState<ProxiesTabView>
with TickerProviderStateMixin {
TabController? _tabController;
final Map<String, ScrollController> _scrollControllers = {};
int _columns = 1;
@override
void initState() {
super.initState();
ref.listenManual(proxiesTabControllerStateProvider, (prev, next) {
if (prev == next) {
return;
}
if (!stringListEquality.equals(prev?.groupNames, next.groupNames)) {
final groupNames = next.groupNames;
final currentGroupName = next.currentGroupName;
final index = groupNames.indexWhere((item) => item == currentGroupName);
_updateTabController(groupNames.length, index);
}
}, fireImmediately: true);
}
@override
void dispose() {
_destroyTabController();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
ScrollController _scrollControllerOf(String groupName) {
return _scrollControllers.putIfAbsent(
groupName,
() => sheetScrollController(context),
);
}
// The page of a dropped group still holds its controller until this frame
// unmounts it.
void _pruneScrollControllers(List<Group> groups) {
final names = {for (final group in groups) group.name};
final dropped = [
for (final entry in _scrollControllers.entries)
if (!names.contains(entry.key)) entry,
];
for (final entry in dropped) {
_scrollControllers.remove(entry.key);
WidgetsBinding.instance.addPostFrameCallback(
(_) => entry.value.dispose(),
);
}
}
void scrollToGroupSelected() {
final group = currentGroup;
final controller = _scrollControllers[group?.name];
if (group == null || controller == null) {
return;
}
final cardType = ref.read(proxiesTabStateProvider).proxyCardType;
final rowOffset = selectedRowOffset(
proxies: group.all,
selectedProxyName: ref.read(selectedProxyNameProvider(group.name)),
columns: _columns,
rowExtent: getRowExtent(cardType),
);
if (rowOffset == null) {
return;
}
animateScrollTo(controller, rowOffset);
}
Future<void> delayTestCurrentGroup() async {
final group = currentGroup;
if (group == null) {
return;
}
await ref
.read(proxiesActionProvider.notifier)
.delayTestPageGroup(group.name);
}
Group? get currentGroup {
return _getGroup(_tabController?.index);
}
Group? _getGroup(int? index) {
final groups = ref.read(proxiesTabStateProvider).groups;
if (index == null || index < 0 || index >= groups.length) {
return null;
}
return groups[index];
}
void _tabControllerListener([int? index]) {
final group = _getGroup(index ?? _tabController?.index);
if (group == null) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
ref
.read(proxiesActionProvider.notifier)
.updateCurrentGroupName(group.name);
});
}
void _destroyTabController() {
_tabController?.removeListener(_tabControllerListener);
_tabController?.dispose();
_tabController = null;
}
// An empty group list keeps the previous controller: the outgoing tab bar
// still drives it while the empty state animates in.
void _updateTabController(int length, int index) {
if (length == 0) {
return;
}
_destroyTabController();
final realIndex = index == -1 ? 0 : index;
final controller = TabController(
length: length,
initialIndex: realIndex,
vsync: this,
);
_tabController = controller;
_tabControllerListener(realIndex);
controller.addListener(_tabControllerListener);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
ref.watch(themeSettingProvider.select((state) => state.textScale));
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),
);
final isSearching = ref.watch(
queryProvider(
QueryTag.proxies,
).select((query) => SearchQuery(query).isNotEmpty),
);
_pruneScrollControllers(groups);
final controller = _tabController;
return NullStatusSwitcher(
isEmpty: groups.isEmpty || controller == null,
isSearching: isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.proxies,
label: appLocalizations.nullTip(appLocalizations.proxies),
),
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)
ProxyGroupView(
key: ValueKey(group.name),
controller: _scrollControllerOf(group.name),
group: group,
columns: columns,
cardType: cardType,
),
],
);
},
),
),
],
),
),
);
}
}
class _GroupTabs extends StatefulWidget {
const _GroupTabs({required this.controller, required this.groups});
final TabController controller;
final List<Group> groups;
@override
State<_GroupTabs> createState() => _GroupTabsState();
}
class _GroupTabsState extends State<_GroupTabs> {
static const _fadeExtent = 24.0;
final _hasMore = ValueNotifier(false);
final _fade = ValueNotifier(0.0);
final Map<String, GlobalKey> _tabKeys = {};
late final _selected = ValueNotifier(widget.controller.index);
@override
void initState() {
super.initState();
_follow(widget.controller);
}
@override
void didUpdateWidget(_GroupTabs oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.animation?.removeListener(_handleAnimation);
_follow(widget.controller);
}
}
@override
void dispose() {
widget.controller.animation?.removeListener(_handleAnimation);
_hasMore.dispose();
_fade.dispose();
_selected.dispose();
super.dispose();
}
void _follow(TabController controller) {
_selected.value = controller.index;
controller.animation!.addListener(_handleAnimation);
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
}
// A tap animates across every tab between; only a swipe's passes count.
void _handleAnimation() {
final controller = widget.controller;
final index = controller.indexIsChanging
? controller.index
: controller.animation!.value.round();
if (index == _selected.value) {
return;
}
_selected.value = index;
_reveal();
}
// The strip owns no controller: a sheet's would hand its drags to the sheet.
void _reveal({bool jump = false}) {
if (!mounted) {
return;
}
final name = widget.groups.elementAtOrNull(_selected.value)?.name;
final tab = _tabKeys[name]?.currentContext;
final position = tab == null ? null : Scrollable.maybeOf(tab)?.position;
if (tab == null || position == null) {
return;
}
position.ensureVisible(
tab.findRenderObject()!,
alignment: 0.5,
duration: jump ? Duration.zero : commonDuration,
curve: Curves.easeOutCubic,
);
}
void _select(int index) {
widget.controller.animateTo(index);
}
bool _handleMetrics(ScrollMetrics metrics) {
_fade.value = (metrics.extentAfter / _fadeExtent).clamp(0.0, 1.0);
final hasMore = metrics.maxScrollExtent > 0;
if (hasMore != _hasMore.value) {
_hasMore.value = hasMore;
// The more button narrows the tabs it overflows.
WidgetsBinding.instance.addPostFrameCallback((_) => _reveal(jump: true));
}
return false;
}
Shader _buildEdgeFade(Rect bounds, double fade) {
return LinearGradient(
begin: AlignmentDirectional.centerStart,
end: AlignmentDirectional.centerEnd,
colors: [
Colors.black,
Colors.black.withValues(alpha: 1 - fade),
],
stops: [max(0.0, 1 - _fadeExtent / bounds.width), 1],
).createShader(bounds, textDirection: Directionality.of(context));
}
Widget _buildMoreButton() {
return TonalButtonTheme(
size: TonalButtonSize.section,
child: ElasticPress(
child: CommonPopupBox(
targetBuilder: (open) => IconButton(
tooltip: context.appLocalizations.proxyGroup,
onPressed: open,
icon: const GlyphIcon(AppGlyphs.chevronDown),
),
popupBuilder: (_) => CommonPopupMenu(
maxVisibleItems: 7,
items: [
for (final (index, group) in widget.groups.indexed)
CommonPopupMenuItem(
label: group.name,
checked: index == widget.controller.index,
onPressed: () => _select(index),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final names = {for (final group in widget.groups) group.name};
_tabKeys.removeWhere((name, _) => !names.contains(name));
final tabs = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
for (final (index, group) in widget.groups.indexed)
_GroupTab(
key: _tabKeys.putIfAbsent(group.name, GlobalKey.new),
name: group.name,
index: index,
selected: _selected,
onPressed: () => _select(index),
),
],
);
return SizedBox(
height: TonalButtonSize.section.button,
child: ValueListenableBuilder(
valueListenable: _hasMore,
builder: (_, hasMore, moreButton) {
return Row(
children: [
Expanded(
child: NotificationListener<ScrollMetricsNotification>(
onNotification: (notification) =>
_handleMetrics(notification.metrics),
child: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) =>
_handleMetrics(notification.metrics),
child: ValueListenableBuilder(
valueListenable: _fade,
builder: (_, fade, child) => ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) =>
_buildEdgeFade(bounds, fade),
child: child,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsetsDirectional.only(
start: 16,
end: hasMore ? 0 : 16,
),
child: tabs,
),
),
),
),
),
if (hasMore)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8, end: 16),
child: moreButton,
),
],
);
},
child: _buildMoreButton(),
),
);
}
}
/// Rebuilds only when it gains or loses the selection, so a swipe across
/// hundreds of groups rebuilds two tabs per step instead of the strip.
class _GroupTab extends StatefulWidget {
const _GroupTab({
super.key,
required this.name,
required this.index,
required this.selected,
required this.onPressed,
});
final String name;
final int index;
final ValueListenable<int> selected;
final VoidCallback onPressed;
@override
State<_GroupTab> createState() => _GroupTabState();
}
class _GroupTabState extends State<_GroupTab> {
late bool _isSelected;
@override
void initState() {
super.initState();
_isSelected = widget.selected.value == widget.index;
widget.selected.addListener(_handleSelected);
}
@override
void didUpdateWidget(_GroupTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
oldWidget.selected.removeListener(_handleSelected);
widget.selected.addListener(_handleSelected);
}
_isSelected = widget.selected.value == widget.index;
}
@override
void dispose() {
widget.selected.removeListener(_handleSelected);
super.dispose();
}
void _handleSelected() {
final isSelected = widget.selected.value == widget.index;
if (isSelected != _isSelected) {
setState(() => _isSelected = isSelected);
}
}
@override
Widget build(BuildContext context) {
final selected = _isSelected;
final name = widget.name;
final onPressed = widget.onPressed;
final colorScheme = context.colorScheme;
return CommonCard(
isSelected: selected,
fillsSelection: true,
shape: AppShape.full,
padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: onPressed,
child: AnimatedDefaultTextStyle(
duration: midDuration,
style: AppTextStyle.subheadline
.of(context, emphasized: true)
.copyWith(
color: selected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
child: EmojiText(name, maxLines: 1),
),
);
}
}
class ProxyGroupView extends ConsumerWidget {
final Group group;
final ScrollController controller;
final int columns;
final ProxyCardType cardType;
const ProxyGroupView({
super.key,
required this.group,
required this.controller,
required this.columns,
required this.cardType,
});
PageStorageKey _getPageStorageKey(WidgetRef ref) {
final profile = ref.read(currentProfileProvider);
final key =
'${profile?.id}_${ScrollPositionCacheKey.proxiesTabList.name}_${group.name}';
return ProxiesTabView.pageListStoreMap.updateCacheValue(
key,
() => PageStorageKey(key),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final proxies = group.all;
return CommonScrollBar(
controller: controller,
thumbVisibility: true,
child: GridView.builder(
key: _getPageStorageKey(ref),
controller: controller,
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: 16 + BottomInsetScope.of(context),
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: proxyGridSpacing,
crossAxisSpacing: 8,
mainAxisExtent: getItemHeight(cardType),
),
itemCount: proxies.length,
addAutomaticKeepAlives: false,
itemBuilder: (_, index) {
final proxy = proxies[index];
return ProxyCard(
testUrl: group.testUrl,
groupType: group.type,
type: cardType,
proxy: proxy,
groupName: group.name,
);
},
),
);
}
}