Files
FlClash/lib/views/proxies/tab.dart
T
chen08209 491fae10f5 feat(views)!: set DNS, NTP, sniffer and TUN key by key
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile
Changelog: Per-key DNS, NTP, sniffer and TUN settings
2026-10-12 10:34:14 +08:00

413 lines
13 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/icons/icons.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '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 _hasMoreButtonNotifier = ValueNotifier<bool>(false);
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();
_hasMoreButtonNotifier.dispose();
for (final controller in _scrollControllers.values) {
controller.dispose();
}
super.dispose();
}
ScrollController _scrollControllerOf(String groupName) {
return _scrollControllers.putIfAbsent(groupName, ScrollController.new);
}
// 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];
}
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) {
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 state = ref.watch(proxiesTabStateProvider.select((state) => state));
final proxiesLayout = ref.watch(
proxiesStyleSettingProvider.select((state) => state.layout),
);
final isSearching = ref.watch(
queryProvider(
QueryTag.proxies,
).select((query) => SearchQuery(query).isNotEmpty),
);
final groups = state.groups;
_pruneScrollControllers(groups);
return NullStatusSwitcher(
isEmpty: groups.isEmpty || _tabController == null,
isSearching: isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.proxies,
label: appLocalizations.nullTip(appLocalizations.proxies),
),
child: AppBarClearance(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NotificationListener<ScrollMetricsNotification>(
onNotification: (scrollNotification) {
_hasMoreButtonNotifier.value =
scrollNotification.metrics.maxScrollExtent > 0;
return false;
},
child: ValueListenableBuilder(
valueListenable: _hasMoreButtonNotifier,
builder: (_, hasMore, moreButton) {
return Stack(
alignment: AlignmentDirectional.centerEnd,
children: [
ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) =>
_buildTabsMask(bounds, hasMore),
child: TabBar(
controller: _tabController,
padding: EdgeInsetsDirectional.only(
start: 16,
end: hasMore ? kMinInteractiveDimension : 16,
),
dividerColor: Colors.transparent,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
for (final group in groups)
Tab(
child: Builder(
builder: (context) {
return EmojiText(
group.name,
style: DefaultTextStyle.of(context).style,
);
},
),
),
],
),
),
if (hasMore) moreButton!,
],
);
},
child: _buildMoreButton(),
),
),
Expanded(
child: LayoutBuilder(
builder: (_, constraints) {
final columns = getProxiesColumns(
max(constraints.maxWidth - 32, 0),
proxiesLayout,
);
_columns = columns;
return TabBarView(
controller: _tabController,
children: [
for (final group in groups)
ProxyGroupView(
key: ValueKey(group.name),
controller: _scrollControllerOf(group.name),
group: group,
columns: columns,
cardType: state.proxyCardType,
),
],
);
},
),
),
],
),
),
);
}
}
class ProxyGroupView extends ConsumerWidget {
final Group group;
final ScrollController controller;
final int columns;
final ProxyCardType cardType;
final bool shrinkWrap;
final double topPadding;
const ProxyGroupView({
super.key,
required this.group,
required this.controller,
required this.columns,
required this.cardType,
this.shrinkWrap = false,
this.topPadding = 16,
});
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,
shrinkWrap: shrinkWrap,
padding: EdgeInsets.only(
top: topPadding,
left: 16,
right: 16,
bottom: 16 + BottomInsetScope.of(context),
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: proxyGridSpacing,
crossAxisSpacing: 8,
mainAxisExtent: getItemHeight(cardType),
),
itemCount: proxies.length,
itemBuilder: (_, index) {
final proxy = proxies[index];
return ProxyCard(
testUrl: group.testUrl,
groupType: group.type,
type: cardType,
proxy: proxy,
groupName: group.name,
);
},
),
);
}
}