Files
FlClash/lib/views/proxies/list.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

590 lines
18 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/models.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';
typedef GroupNameProxiesMap = Map<String, List<Proxy>>;
const _pinnedHeaderGap = 8.0;
const _headerInset = 10.0;
const _enterStaggerLimit = 8;
const _enterStaggerStep = Duration(milliseconds: 20);
const _enterSlideBase = 32.0;
const _enterSlideStep = 8.0;
final _enterWindow = commonDuration + _enterStaggerStep * _enterStaggerLimit;
class ProxiesListView extends ConsumerStatefulWidget {
const ProxiesListView({super.key});
@override
ConsumerState<ProxiesListView> createState() => _ProxiesListViewState();
}
class _ProxiesListViewState extends ConsumerState<ProxiesListView> {
late final ScrollController _controller;
GroupOffsets _groupOffsets = GroupOffsets.empty;
double containerHeight = 0;
String? _enterGroupName;
Timer? _enterTimer;
@override
void initState() {
super.initState();
_controller = sheetScrollController(context);
}
@override
void dispose() {
_stopEnterAnimated();
_controller.dispose();
super.dispose();
}
void _startEnterAnimated(String groupName) {
_enterTimer?.cancel();
_enterGroupName = groupName;
_enterTimer = Timer(_enterWindow, _stopEnterAnimated);
}
void _stopEnterAnimated() {
_enterTimer?.cancel();
_enterTimer = null;
_enterGroupName = null;
}
void _handleChange(Set<String> currentUnfoldSet, String groupName) {
_autoScrollToGroup(groupName);
final tempUnfoldSet = Set<String>.from(currentUnfoldSet);
if (tempUnfoldSet.contains(groupName)) {
tempUnfoldSet.remove(groupName);
_stopEnterAnimated();
} else {
tempUnfoldSet.add(groupName);
_startEnterAnimated(groupName);
}
ref
.read(proxiesActionProvider.notifier)
.updateCurrentUnfoldSet(tempUnfoldSet);
}
GroupOffsets _getGroupOffsets({
required List<Group> groups,
required int columns,
required Set<String> currentUnfoldSet,
required ProxyCardType cardType,
}) {
final offsets = <double>[];
final rowExtent = getRowExtent(cardType);
var currentOffset = 0.0;
for (final group in groups) {
offsets.add(currentOffset);
currentOffset += listHeaderHeight + proxyGridSpacing;
if (currentUnfoldSet.contains(group.name)) {
final rowCount = (group.all.length + columns - 1) ~/ columns;
currentOffset += rowCount * rowExtent;
}
}
return GroupOffsets(groups, offsets);
}
Widget _buildProxyRow({
required Group group,
required List<Proxy> proxies,
required int rowIndex,
required int columns,
required ProxyCardType cardType,
}) {
final groupName = group.name;
final enterAnimated = _enterGroupName == groupName;
final children = proxies.indexed
.map<Widget>((entry) {
final (columnIndex, proxy) = entry;
final card = SizedBox(
height: getItemHeight(cardType),
child: ProxyCard(
testUrl: group.testUrl,
type: cardType,
groupType: group.type,
key: ValueKey('$groupName.${proxy.name}'),
proxy: proxy,
groupName: groupName,
),
);
if (!enterAnimated) {
return Flexible(child: card);
}
final stagger = min(
rowIndex * columns + columnIndex,
_enterStaggerLimit,
);
return Flexible(
child: FadeSlideEnterBox(
delay: _enterStaggerStep * stagger,
distance: _enterSlideBase + _enterSlideStep * stagger,
child: card,
),
);
})
.fill(columns, filler: (_) => const Flexible(child: SizedBox()))
.separated(const SizedBox(width: 8));
return Padding(
padding: const EdgeInsets.only(left: 18, right: 18, bottom: 8),
child: Row(children: children.toList()),
);
}
Widget _buildGroup(
BuildContext context, {
required Group group,
required Set<String> currentUnfoldSet,
required int columns,
required ProxyCardType cardType,
}) {
final groupName = group.name;
final isExpand = currentUnfoldSet.contains(groupName);
final rows = isExpand
? group.all.chunks(columns).toList()
: const <List<Proxy>>[];
return SliverMainAxisGroup(
slivers: [
PinnedHeaderSliver(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: SizedBox(
height: listHeaderHeight,
child: ListHeader(
enterAnimated: false,
onScrollToSelected: (groupName) {
_scrollToGroupSelected(groupName, columns, cardType);
},
key: ValueKey(groupName),
isExpand: isExpand,
group: group,
onChange: (groupName) {
_handleChange(currentUnfoldSet, groupName);
},
),
),
),
),
if (isExpand)
SliverFixedExtentList(
itemExtent: getRowExtent(cardType),
delegate: SliverChildBuilderDelegate(
(_, index) => _buildProxyRow(
group: group,
proxies: rows[index],
rowIndex: index,
columns: columns,
cardType: cardType,
),
childCount: rows.length,
),
),
],
);
}
double _getGroupOffset(String groupName) {
if (!_controller.hasClients ||
_controller.position.maxScrollExtent == 0 ||
_groupOffsets.isEmpty) {
return 0;
}
return _groupOffsets.offsetOf(groupName);
}
void _scrollToMakeVisibleWithPadding({
required double containerHeight,
required double pixels,
required double start,
required double end,
double padding = 24,
}) {
final visibleStart = pixels;
final visibleEnd = pixels + containerHeight;
final isElementVisible = start >= visibleStart && end <= visibleEnd;
if (isElementVisible) {
return;
}
double targetScrollOffset;
if (end <= visibleStart) {
targetScrollOffset = start;
} else if (start >= visibleEnd) {
targetScrollOffset = end - containerHeight + padding;
} else {
final visibleTopPart = end - visibleStart;
final visibleBottomPart = visibleEnd - start;
if (visibleTopPart.abs() >= visibleBottomPart.abs()) {
targetScrollOffset = end - containerHeight + padding;
} else {
targetScrollOffset = start;
}
}
targetScrollOffset = targetScrollOffset.clamp(
_controller.position.minScrollExtent,
_controller.position.maxScrollExtent,
);
_controller.jumpTo(targetScrollOffset);
}
void _autoScrollToGroup(String groupName) {
final pixels = _controller.position.pixels;
final offset = _getGroupOffset(groupName);
_scrollToMakeVisibleWithPadding(
containerHeight: containerHeight,
pixels: pixels,
start: offset,
end: offset + listHeaderHeight,
);
}
void _scrollToGroupSelected(
String groupName,
int columns,
ProxyCardType cardType,
) {
final proxies = _groupOffsets.groupOf(groupName)?.all;
if (proxies == null) {
return;
}
final rowOffset = selectedRowOffset(
proxies: proxies,
selectedProxyName: ref.read(selectedProxyNameProvider(groupName)),
columns: columns,
rowExtent: getRowExtent(cardType),
);
if (rowOffset == null) {
return;
}
animateScrollTo(_controller, _getGroupOffset(groupName) + rowOffset);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return Consumer(
builder: (_, ref, _) {
final state = ref.watch(proxiesListStateProvider);
ref.watch(themeSettingProvider.select((state) => state.textScale));
final proxiesLayout = ref.watch(
proxiesStyleSettingProvider.select((state) => state.layout),
);
final isSearching = ref.watch(
queryProvider(
QueryTag.proxies,
).select((query) => SearchQuery(query).isNotEmpty),
);
return NullStatusSwitcher(
isEmpty: state.groups.isEmpty,
isSearching: isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.proxies,
label: appLocalizations.nullTip(appLocalizations.proxies),
),
child: LayoutBuilder(
builder: (_, constraints) {
final columns = getProxiesColumns(
max(constraints.maxWidth - 36, 0),
proxiesLayout,
);
_groupOffsets = _getGroupOffsets(
groups: state.groups,
currentUnfoldSet: state.currentUnfoldSet,
columns: columns,
cardType: state.proxyCardType,
);
final barInset = MediaQuery.paddingOf(context).top;
containerHeight = max(
constraints.maxHeight - barInset - _pinnedHeaderGap,
0,
);
return CommonScrollBar(
controller: _controller,
thumbVisibility: true,
child: CustomScrollView(
key: proxiesListStoreKey,
controller: _controller,
slivers: [
PinnedHeaderSliver(
child: SizedBox(height: barInset + _pinnedHeaderGap),
),
for (final group in state.groups)
_buildGroup(
context,
group: group,
currentUnfoldSet: state.currentUnfoldSet,
columns: columns,
cardType: state.proxyCardType,
),
SliverToBoxAdapter(
child: SizedBox(
height: 16 + BottomInsetScope.of(context),
),
),
],
),
);
},
),
);
},
);
}
}
class ListHeader extends ConsumerWidget {
final Group group;
final Function(String groupName) onChange;
final Function(String groupName) onScrollToSelected;
final bool isExpand;
final bool enterAnimated;
const ListHeader({
super.key,
this.enterAnimated = true,
required this.group,
required this.onChange,
required this.onScrollToSelected,
required this.isExpand,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final groupName = group.name;
final isDelayTesting = ref.watch(
delayTestingGroupsProvider.select((state) => state.contains(groupName)),
);
return CommonCard(
enterActionsOnRight: true,
enterAnimated: enterAnimated,
key: key,
radius: AppCorner.xl.ap,
type: CommonCardType.filled,
child: Padding(
padding: const EdgeInsets.all(_headerInset),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
children: [
_GroupIcon(src: group.icon, name: groupName),
Flexible(child: _GroupSummary(groupName: groupName)),
],
),
),
_GroupActions(
isExpand: isExpand,
isDelayTesting: isDelayTesting,
groupType: group.type.name,
onScrollToSelected: () {
onScrollToSelected(groupName);
},
onDelayTest: () {
ref
.read(proxiesActionProvider.notifier)
.delayTestPageGroup(groupName);
},
onToggle: () {
onChange(groupName);
},
),
],
),
),
onPressed: () {
onChange(groupName);
},
);
}
}
class _GroupIcon extends ConsumerWidget {
const _GroupIcon({required this.src, required this.name});
final String src;
final String name;
@override
Widget build(BuildContext context, WidgetRef ref) {
final iconStyle = ref.watch(
proxiesStyleSettingProvider.select((state) => state.iconStyle),
);
return switch (iconStyle) {
ProxiesIconStyle.filled => LayoutBuilder(
builder: (_, constraints) {
return Container(
margin: const EdgeInsets.only(right: 12),
child: AspectRatio(
aspectRatio: 1,
child: Container(
height: constraints.maxHeight,
width: constraints.maxWidth,
alignment: Alignment.center,
padding: EdgeInsets.all(6.ap),
decoration: ShapeDecoration(
color: context.colorScheme.secondaryContainer,
shape: AppShape.all(AppCorner.xl.ap - _headerInset),
),
clipBehavior: Clip.antiAlias,
child: IconTheme.merge(
data: IconThemeData(
size: constraints.maxHeight - 12.ap,
color: context.colorScheme.onSecondaryContainer,
),
child: CommonTargetIcon(
src: src,
fallback: GroupMonogram(name),
),
),
),
),
);
},
),
ProxiesIconStyle.plain => Container(
margin: const EdgeInsets.only(left: 2, right: 10),
child: LayoutBuilder(
builder: (_, constraints) {
return IconTheme.merge(
data: IconThemeData(
size: constraints.maxHeight - 16.ap,
color: context.colorScheme.primary,
),
child: CommonTargetIcon(src: src, fallback: GroupMonogram(name)),
);
},
),
),
// Lines the title up with the proxy card names below, which sit at the
// row inset plus the card padding.
ProxiesIconStyle.hidden => const SizedBox(width: 4),
};
}
}
class _GroupSummary extends StatelessWidget {
const _GroupSummary({required this.groupName});
final String groupName;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EmojiText(groupName, style: context.textTheme.titleSmall),
const SizedBox(height: 2),
Flexible(flex: 1, child: _SelectedProxyName(groupName: groupName)),
],
);
}
}
class _SelectedProxyName extends ConsumerWidget {
const _SelectedProxyName({required this.groupName});
final String groupName;
@override
Widget build(BuildContext context, WidgetRef ref) {
final proxyName = ref
.watch(selectedProxyNameProvider(groupName))
.takeFirstValid([]);
if (proxyName.isEmpty) {
return const SizedBox.shrink();
}
return EmojiText(
proxyName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.toLight,
);
}
}
class _GroupActions extends StatelessWidget {
const _GroupActions({
required this.isExpand,
required this.isDelayTesting,
required this.groupType,
required this.onScrollToSelected,
required this.onDelayTest,
required this.onToggle,
});
final bool isExpand;
final bool isDelayTesting;
final String groupType;
final VoidCallback onScrollToSelected;
final VoidCallback onDelayTest;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
return TonalButtonTheme(
size: TonalButtonSize.compact,
child: Row(
children: [
if (isExpand)
TonalButtonGroup(
size: TonalButtonSize.compact,
children: [
IconButton(
tooltip: context.appLocalizations.scrollToSelected,
onPressed: onScrollToSelected,
iconSize: 19,
icon: const GlyphIcon(AppGlyphs.locate),
),
IconButton(
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(),
),
)
: const GlyphIcon(AppGlyphs.bolt),
),
],
)
else
Text(groupType, style: context.textTheme.labelMedium?.toLight),
const SizedBox(width: 6),
ElasticPress(
child: IconButton(
tooltip: isExpand
? context.appLocalizations.showLess
: context.appLocalizations.showMore,
onPressed: onToggle,
icon: CommonExpandIcon(expand: isExpand),
),
),
],
),
);
}
}