491fae10f5
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile Changelog: Per-key DNS, NTP, sniffer and TUN settings
413 lines
13 KiB
Dart
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,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|