437 lines
12 KiB
Dart
437 lines
12 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/providers/providers.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:fl_clash/widgets/widgets.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../models/common.dart';
|
|
import 'card.dart';
|
|
import 'common.dart';
|
|
|
|
List<Proxy> currentTabProxies = [];
|
|
String? currentTabTestUrl;
|
|
|
|
typedef GroupNameKeyMap = Map<String, GlobalObjectKey<ProxyGroupViewState>>;
|
|
|
|
class ProxiesTabFragment extends ConsumerStatefulWidget {
|
|
const ProxiesTabFragment({super.key});
|
|
|
|
@override
|
|
ConsumerState<ProxiesTabFragment> createState() => ProxiesTabFragmentState();
|
|
}
|
|
|
|
class ProxiesTabFragmentState extends ConsumerState<ProxiesTabFragment>
|
|
with TickerProviderStateMixin {
|
|
TabController? _tabController;
|
|
final _hasMoreButtonNotifier = ValueNotifier<bool>(false);
|
|
GroupNameKeyMap _keyMap = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_handleTabListen();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_destroyTabController();
|
|
super.dispose();
|
|
}
|
|
|
|
scrollToGroupSelected() {
|
|
final currentGroupName = globalState.appController.getCurrentGroupName();
|
|
_keyMap[currentGroupName]?.currentState?.scrollToSelected();
|
|
}
|
|
|
|
_buildMoreButton() {
|
|
return Consumer(
|
|
builder: (_, ref, ___) {
|
|
final isMobileView = ref.watch(isMobileViewProvider);
|
|
return IconButton(
|
|
onPressed: _showMoreMenu,
|
|
icon: isMobileView
|
|
? const Icon(
|
|
Icons.expand_more,
|
|
)
|
|
: const Icon(
|
|
Icons.chevron_right,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
_showMoreMenu() {
|
|
showSheet(
|
|
context: context,
|
|
props: SheetProps(
|
|
isScrollControlled: false,
|
|
),
|
|
builder: (_, type) {
|
|
return AdaptiveSheetScaffold(
|
|
type: type,
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Consumer(
|
|
builder: (_, ref, __) {
|
|
final state = ref.watch(proxiesSelectorStateProvider);
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: Wrap(
|
|
alignment: WrapAlignment.center,
|
|
runSpacing: 8,
|
|
spacing: 8,
|
|
children: [
|
|
for (final groupName in state.groupNames)
|
|
SettingTextCard(
|
|
groupName,
|
|
onPressed: () {
|
|
final index = state.groupNames.indexWhere(
|
|
(item) => item == groupName,
|
|
);
|
|
if (index == -1) return;
|
|
_tabController?.animateTo(index);
|
|
globalState.appController
|
|
.updateCurrentGroupName(groupName);
|
|
Navigator.of(context).pop();
|
|
},
|
|
isSelected: groupName == state.currentGroupName,
|
|
)
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
title: appLocalizations.proxyGroup,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
_tabControllerListener([int? index]) {
|
|
int? groupIndex = index;
|
|
if (groupIndex == -1) {
|
|
return;
|
|
}
|
|
final appController = globalState.appController;
|
|
if (groupIndex == null) {
|
|
final currentIndex = _tabController?.index;
|
|
groupIndex = currentIndex;
|
|
}
|
|
final currentGroups = appController.getCurrentGroups();
|
|
if (groupIndex == null || groupIndex > currentGroups.length) {
|
|
return;
|
|
}
|
|
final currentGroup = currentGroups[groupIndex];
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
globalState.appController.updateCurrentGroupName(
|
|
currentGroup.name,
|
|
);
|
|
});
|
|
}
|
|
|
|
_destroyTabController() {
|
|
_tabController?.removeListener(_tabControllerListener);
|
|
_tabController?.dispose();
|
|
_tabController = null;
|
|
}
|
|
|
|
_updateTabController(int length, int index) {
|
|
if (length == 0) {
|
|
_destroyTabController();
|
|
return;
|
|
}
|
|
final realIndex = index == -1 ? 0 : index;
|
|
_tabController ??= TabController(
|
|
length: length,
|
|
initialIndex: realIndex,
|
|
vsync: this,
|
|
);
|
|
_tabControllerListener(realIndex);
|
|
_tabController?.addListener(_tabControllerListener);
|
|
}
|
|
|
|
_handleTabListen() {
|
|
ref.listenManual(
|
|
proxiesSelectorStateProvider,
|
|
(prev, next) {
|
|
if (prev == next) {
|
|
return;
|
|
}
|
|
if (!stringListEquality.equals(prev?.groupNames, next.groupNames)) {
|
|
_destroyTabController();
|
|
final index = next.groupNames.indexWhere(
|
|
(item) => item == next.currentGroupName,
|
|
);
|
|
_updateTabController(next.groupNames.length, index);
|
|
}
|
|
},
|
|
fireImmediately: true,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.watch(themeSettingProvider.select((state) => state.textScale));
|
|
final state = ref.watch(groupNamesStateProvider);
|
|
final groupNames = state.groupNames;
|
|
if (groupNames.isEmpty) {
|
|
return NullStatus(
|
|
label: appLocalizations.nullProxies,
|
|
);
|
|
}
|
|
final GroupNameKeyMap keyMap = {};
|
|
final children = groupNames.map((groupName) {
|
|
keyMap[groupName] = GlobalObjectKey(groupName);
|
|
return KeepScope(
|
|
child: ProxyGroupView(
|
|
key: keyMap[groupName],
|
|
groupName: groupName,
|
|
),
|
|
);
|
|
}).toList();
|
|
_keyMap = keyMap;
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
NotificationListener<ScrollMetricsNotification>(
|
|
onNotification: (scrollNotification) {
|
|
_hasMoreButtonNotifier.value =
|
|
scrollNotification.metrics.maxScrollExtent > 0;
|
|
return true;
|
|
},
|
|
child: ValueListenableBuilder(
|
|
valueListenable: _hasMoreButtonNotifier,
|
|
builder: (_, value, child) {
|
|
return Stack(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
children: [
|
|
TabBar(
|
|
controller: _tabController,
|
|
padding: EdgeInsets.only(
|
|
left: 16,
|
|
right: 16 + (value ? 16 : 0),
|
|
),
|
|
dividerColor: Colors.transparent,
|
|
isScrollable: true,
|
|
tabAlignment: TabAlignment.start,
|
|
overlayColor:
|
|
const WidgetStatePropertyAll(Colors.transparent),
|
|
tabs: [
|
|
for (final groupName in groupNames)
|
|
Tab(
|
|
text: groupName,
|
|
),
|
|
],
|
|
),
|
|
if (value)
|
|
Positioned(
|
|
right: 0,
|
|
child: child!,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: [
|
|
context.colorScheme.surface.opacity10,
|
|
context.colorScheme.surface,
|
|
],
|
|
stops: const [
|
|
0.0,
|
|
0.1
|
|
]),
|
|
),
|
|
child: _buildMoreButton(),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: children,
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ProxyGroupView extends ConsumerStatefulWidget {
|
|
final String groupName;
|
|
|
|
const ProxyGroupView({
|
|
super.key,
|
|
required this.groupName,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<ProxyGroupView> createState() => ProxyGroupViewState();
|
|
}
|
|
|
|
class ProxyGroupViewState extends ConsumerState<ProxyGroupView> {
|
|
final _controller = ScrollController();
|
|
|
|
String get groupName => widget.groupName;
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
scrollToSelected() {
|
|
if (_controller.position.maxScrollExtent == 0) {
|
|
return;
|
|
}
|
|
_controller.animateTo(
|
|
min(
|
|
16 +
|
|
getScrollToSelectedOffset(
|
|
groupName: groupName,
|
|
proxies: currentTabProxies,
|
|
),
|
|
_controller.position.maxScrollExtent,
|
|
),
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeIn,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(proxyGroupSelectorStateProvider(groupName));
|
|
final proxies = state.proxies;
|
|
final columns = state.columns;
|
|
final proxyCardType = state.proxyCardType;
|
|
final sortedProxies = globalState.appController.getSortProxies(
|
|
proxies,
|
|
state.testUrl,
|
|
);
|
|
currentTabProxies = sortedProxies;
|
|
currentTabTestUrl = state.testUrl;
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: CommonAutoHiddenScrollBar(
|
|
controller: _controller,
|
|
child: GridView.builder(
|
|
controller: _controller,
|
|
padding: const EdgeInsets.only(
|
|
top: 16,
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 96,
|
|
),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
mainAxisExtent: getItemHeight(proxyCardType),
|
|
),
|
|
itemCount: sortedProxies.length,
|
|
itemBuilder: (_, index) {
|
|
final proxy = sortedProxies[index];
|
|
return ProxyCard(
|
|
testUrl: state.testUrl,
|
|
groupType: state.groupType,
|
|
type: proxyCardType,
|
|
key: ValueKey('$groupName.${proxy.name}'),
|
|
proxy: proxy,
|
|
groupName: groupName,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DelayTestButton extends StatefulWidget {
|
|
final Future Function() onClick;
|
|
|
|
const DelayTestButton({
|
|
super.key,
|
|
required this.onClick,
|
|
});
|
|
|
|
@override
|
|
State<DelayTestButton> createState() => _DelayTestButtonState();
|
|
}
|
|
|
|
class _DelayTestButtonState extends State<DelayTestButton>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _scale;
|
|
|
|
_healthcheck() async {
|
|
if (_controller.isAnimating) {
|
|
return;
|
|
}
|
|
_controller.forward();
|
|
await widget.onClick();
|
|
if (mounted) {
|
|
_controller.reverse();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(
|
|
milliseconds: 200,
|
|
),
|
|
);
|
|
_scale = Tween<double>(
|
|
begin: 1.0,
|
|
end: 0.0,
|
|
).animate(
|
|
CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(
|
|
0,
|
|
1,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller.view,
|
|
builder: (_, child) {
|
|
return SizedBox(
|
|
width: 56,
|
|
height: 56,
|
|
child: Transform.scale(
|
|
scale: _scale.value,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: FloatingActionButton(
|
|
heroTag: null,
|
|
onPressed: _healthcheck,
|
|
child: const Icon(Icons.network_ping),
|
|
),
|
|
);
|
|
}
|
|
}
|