Add linux deb dependencies Add backup recovery strategy select Support custom text scaling Optimize the display of different text scale Optimize windows setup experience Optimize startTun performance Optimize android tv experience Optimize default option Optimize computed text size Optimize hyperOS freeform window Add developer mode Update core Optimize more details
618 lines
19 KiB
Dart
618 lines
19 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/providers/app.dart';
|
|
import 'package:fl_clash/providers/config.dart';
|
|
import 'package:fl_clash/providers/state.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 'card.dart';
|
|
import 'common.dart';
|
|
|
|
typedef GroupNameProxiesMap = Map<String, List<Proxy>>;
|
|
|
|
class ProxiesListFragment extends StatefulWidget {
|
|
const ProxiesListFragment({super.key});
|
|
|
|
@override
|
|
State<ProxiesListFragment> createState() => _ProxiesListFragmentState();
|
|
}
|
|
|
|
class _ProxiesListFragmentState extends State<ProxiesListFragment> {
|
|
final _controller = ScrollController();
|
|
final _headerStateNotifier = ValueNotifier<ProxiesListHeaderSelectorState>(
|
|
const ProxiesListHeaderSelectorState(
|
|
offset: 0,
|
|
currentIndex: 0,
|
|
),
|
|
);
|
|
List<double> _headerOffset = [];
|
|
GroupNameProxiesMap _lastGroupNameProxiesMap = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.addListener(_adjustHeader);
|
|
}
|
|
|
|
_adjustHeader() {
|
|
final offset = _controller.offset;
|
|
final index = _headerOffset.findInterval(offset);
|
|
final currentIndex = index;
|
|
double headerOffset = 0.0;
|
|
if (index + 1 <= _headerOffset.length - 1) {
|
|
final endOffset = _headerOffset[index + 1];
|
|
final startOffset = endOffset - listHeaderHeight - 8;
|
|
if (offset > startOffset && offset < endOffset) {
|
|
headerOffset = offset - startOffset;
|
|
}
|
|
}
|
|
_headerStateNotifier.value = _headerStateNotifier.value.copyWith(
|
|
currentIndex: currentIndex,
|
|
offset: max(headerOffset, 0),
|
|
);
|
|
}
|
|
|
|
double _getListItemHeight(Type type, ProxyCardType proxyCardType) {
|
|
return switch (type) {
|
|
const (SizedBox) => 8,
|
|
const (ListHeader) => listHeaderHeight,
|
|
Type() => getItemHeight(proxyCardType),
|
|
};
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_headerStateNotifier.dispose();
|
|
_controller.removeListener(_adjustHeader);
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
_handleChange(Set<String> currentUnfoldSet, String groupName) {
|
|
final tempUnfoldSet = Set<String>.from(currentUnfoldSet);
|
|
if (tempUnfoldSet.contains(groupName)) {
|
|
tempUnfoldSet.remove(groupName);
|
|
} else {
|
|
tempUnfoldSet.add(groupName);
|
|
}
|
|
globalState.appController.updateCurrentUnfoldSet(
|
|
tempUnfoldSet,
|
|
);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_adjustHeader();
|
|
});
|
|
}
|
|
|
|
List<double> _getItemHeightList(
|
|
List<Widget> items,
|
|
ProxyCardType proxyCardType,
|
|
) {
|
|
final itemHeightList = <double>[];
|
|
List<double> headerOffset = [];
|
|
double currentHeight = 0;
|
|
for (final item in items) {
|
|
if (item.runtimeType == ListHeader) {
|
|
headerOffset.add(currentHeight);
|
|
}
|
|
final itemHeight = _getListItemHeight(item.runtimeType, proxyCardType);
|
|
itemHeightList.add(itemHeight);
|
|
currentHeight = currentHeight + itemHeight;
|
|
}
|
|
_headerOffset = headerOffset;
|
|
return itemHeightList;
|
|
}
|
|
|
|
List<Widget> _buildItems(
|
|
WidgetRef ref, {
|
|
required List<String> groupNames,
|
|
required int columns,
|
|
required Set<String> currentUnfoldSet,
|
|
required ProxyCardType type,
|
|
}) {
|
|
final items = <Widget>[];
|
|
final GroupNameProxiesMap groupNameProxiesMap = {};
|
|
for (final groupName in groupNames) {
|
|
final group =
|
|
ref.read(groupsProvider.select((state) => state.getGroup(groupName)));
|
|
if (group == null) {
|
|
continue;
|
|
}
|
|
final isExpand = currentUnfoldSet.contains(groupName);
|
|
items.addAll([
|
|
ListHeader(
|
|
onScrollToSelected: _scrollToGroupSelected,
|
|
key: Key(groupName),
|
|
isExpand: isExpand,
|
|
group: group,
|
|
onChange: (String groupName) {
|
|
_handleChange(currentUnfoldSet, groupName);
|
|
},
|
|
),
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
]);
|
|
if (isExpand) {
|
|
final sortedProxies = globalState.appController.getSortProxies(
|
|
group.all,
|
|
group.testUrl,
|
|
);
|
|
groupNameProxiesMap[groupName] = sortedProxies;
|
|
final chunks = sortedProxies.chunks(columns);
|
|
final rows = chunks.map<Widget>((proxies) {
|
|
final children = proxies
|
|
.map<Widget>(
|
|
(proxy) => Flexible(
|
|
child: ProxyCard(
|
|
testUrl: group.testUrl,
|
|
type: type,
|
|
groupType: group.type,
|
|
key: ValueKey('$groupName.${proxy.name}'),
|
|
proxy: proxy,
|
|
groupName: groupName,
|
|
),
|
|
),
|
|
)
|
|
.fill(
|
|
columns,
|
|
filler: (_) => const Flexible(
|
|
child: SizedBox(),
|
|
),
|
|
)
|
|
.separated(
|
|
const SizedBox(
|
|
width: 8,
|
|
),
|
|
);
|
|
|
|
return Row(
|
|
children: children.toList(),
|
|
);
|
|
}).separated(
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
);
|
|
items.addAll(
|
|
[
|
|
...rows,
|
|
const SizedBox(
|
|
height: 8,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
_lastGroupNameProxiesMap = groupNameProxiesMap;
|
|
return items;
|
|
}
|
|
|
|
_buildHeader(
|
|
WidgetRef ref, {
|
|
required String groupName,
|
|
required Set<String> currentUnfoldSet,
|
|
}) {
|
|
final group =
|
|
ref.read(groupsProvider.select((state) => state.getGroup(groupName)));
|
|
if (group == null) {
|
|
return SizedBox();
|
|
}
|
|
final isExpand = currentUnfoldSet.contains(groupName);
|
|
return SizedBox(
|
|
height: listHeaderHeight,
|
|
child: ListHeader(
|
|
enterAnimated: false,
|
|
onScrollToSelected: _scrollToGroupSelected,
|
|
key: Key(groupName),
|
|
isExpand: isExpand,
|
|
group: group,
|
|
onChange: (String groupName) {
|
|
_handleChange(currentUnfoldSet, groupName);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
_scrollToGroupSelected(String groupName) {
|
|
if (_controller.position.maxScrollExtent == 0) {
|
|
return;
|
|
}
|
|
final appController = globalState.appController;
|
|
final currentGroups = appController.getCurrentGroups();
|
|
final groupNames = currentGroups.map((e) => e.name).toList();
|
|
final findIndex = groupNames.indexWhere((item) => item == groupName);
|
|
final index = findIndex != -1 ? findIndex : 0;
|
|
final currentInitOffset = _headerOffset[index];
|
|
final proxies = _lastGroupNameProxiesMap[groupName];
|
|
_controller.animateTo(
|
|
min(
|
|
currentInitOffset +
|
|
8 +
|
|
getScrollToSelectedOffset(
|
|
groupName: groupName,
|
|
proxies: proxies ?? [],
|
|
),
|
|
_controller.position.maxScrollExtent,
|
|
),
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeIn,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer(
|
|
builder: (_, ref, __) {
|
|
final state = ref.watch(proxiesListSelectorStateProvider);
|
|
if (state.groupNames.isEmpty) {
|
|
return NullStatus(
|
|
label: appLocalizations.nullProxies,
|
|
);
|
|
}
|
|
final items = _buildItems(
|
|
ref,
|
|
groupNames: state.groupNames,
|
|
currentUnfoldSet: state.currentUnfoldSet,
|
|
columns: state.columns,
|
|
type: state.proxyCardType,
|
|
);
|
|
final itemsOffset = _getItemHeightList(items, state.proxyCardType);
|
|
return CommonScrollBar(
|
|
controller: _controller,
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: ScrollConfiguration(
|
|
behavior: HiddenBarScrollBehavior(),
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
controller: _controller,
|
|
itemExtentBuilder: (index, __) {
|
|
return itemsOffset[index];
|
|
},
|
|
itemCount: items.length,
|
|
itemBuilder: (_, index) {
|
|
return items[index];
|
|
},
|
|
),
|
|
),
|
|
),
|
|
LayoutBuilder(builder: (_, container) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: _headerStateNotifier,
|
|
builder: (_, headerState, ___) {
|
|
final index =
|
|
headerState.currentIndex > state.groupNames.length - 1
|
|
? 0
|
|
: headerState.currentIndex;
|
|
if (index < 0 || state.groupNames.isEmpty) {
|
|
return Container();
|
|
}
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
top: -headerState.offset,
|
|
child: Container(
|
|
width: container.maxWidth,
|
|
color: context.colorScheme.surface,
|
|
padding: const EdgeInsets.only(
|
|
top: 16,
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 8,
|
|
),
|
|
child: _buildHeader(
|
|
ref,
|
|
groupName: state.groupNames[index],
|
|
currentUnfoldSet: state.currentUnfoldSet,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ListHeader extends StatefulWidget {
|
|
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
|
|
State<ListHeader> createState() => _ListHeaderState();
|
|
}
|
|
|
|
class _ListHeaderState extends State<ListHeader>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _iconTurns;
|
|
var isLock = false;
|
|
|
|
String get icon => widget.group.icon;
|
|
|
|
String get groupName => widget.group.name;
|
|
|
|
String get groupType => widget.group.type.name;
|
|
|
|
bool get isExpand => widget.isExpand;
|
|
|
|
_delayTest() async {
|
|
if (isLock) return;
|
|
isLock = true;
|
|
await delayTest(
|
|
widget.group.all,
|
|
widget.group.testUrl,
|
|
);
|
|
isLock = false;
|
|
}
|
|
|
|
_handleChange(String groupName) {
|
|
widget.onChange(groupName);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 200),
|
|
vsync: this,
|
|
);
|
|
_iconTurns = _animationController.drive(
|
|
Tween<double>(begin: 0.0, end: 0.5),
|
|
);
|
|
if (isExpand) {
|
|
_animationController.value = 1.0;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ListHeader oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.isExpand != widget.isExpand) {
|
|
if (isExpand) {
|
|
_animationController.forward();
|
|
} else {
|
|
_animationController.reverse();
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildIcon() {
|
|
return Consumer(
|
|
builder: (_, ref, child) {
|
|
final iconStyle = ref.watch(
|
|
proxiesStyleSettingProvider.select(
|
|
(state) => state.iconStyle,
|
|
),
|
|
);
|
|
final icon = ref.watch(proxiesStyleSettingProvider.select((state) {
|
|
final iconMapEntryList = state.iconMap.entries.toList();
|
|
final index = iconMapEntryList.indexWhere((item) {
|
|
try {
|
|
return RegExp(item.key).hasMatch(groupName);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
});
|
|
if (index != -1) {
|
|
return iconMapEntryList[index].value;
|
|
}
|
|
return this.icon;
|
|
}));
|
|
return switch (iconStyle) {
|
|
ProxiesIconStyle.standard => LayoutBuilder(
|
|
builder: (_, constraints) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(
|
|
right: 16,
|
|
),
|
|
child: AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Container(
|
|
height: constraints.maxHeight,
|
|
width: constraints.maxWidth,
|
|
alignment: Alignment.center,
|
|
padding: EdgeInsets.all(6.ap),
|
|
decoration: BoxDecoration(
|
|
color: context.colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: CommonTargetIcon(
|
|
src: icon,
|
|
size: constraints.maxHeight - 12.ap,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
ProxiesIconStyle.icon => Container(
|
|
margin: const EdgeInsets.only(
|
|
right: 16,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (_, constraints) {
|
|
return CommonTargetIcon(
|
|
src: icon,
|
|
size: constraints.maxHeight - 8,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
ProxiesIconStyle.none => Container(),
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CommonCard(
|
|
enterAnimated: widget.enterAnimated,
|
|
key: widget.key,
|
|
radius: 14,
|
|
type: CommonCardType.filled,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Flexible(
|
|
child: Row(
|
|
children: [
|
|
_buildIcon(),
|
|
Flexible(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
groupName,
|
|
style: context.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(
|
|
height: 4,
|
|
),
|
|
Flexible(
|
|
flex: 1,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
groupType,
|
|
style: context.textTheme.labelMedium?.toLight,
|
|
),
|
|
Flexible(
|
|
flex: 1,
|
|
child: Consumer(
|
|
builder: (_, ref, __) {
|
|
final proxyName = ref
|
|
.watch(getSelectedProxyNameProvider(
|
|
groupName,
|
|
))
|
|
.getSafeValue("");
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.start,
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.center,
|
|
children: [
|
|
if (proxyName.isNotEmpty) ...[
|
|
Flexible(
|
|
flex: 1,
|
|
child: EmojiText(
|
|
overflow: TextOverflow.ellipsis,
|
|
" · $proxyName",
|
|
style: context.textTheme
|
|
.labelMedium?.toLight,
|
|
),
|
|
),
|
|
]
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(
|
|
width: 4,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
if (isExpand) ...[
|
|
IconButton(
|
|
visualDensity: VisualDensity.standard,
|
|
onPressed: () {
|
|
widget.onScrollToSelected(groupName);
|
|
},
|
|
icon: const Icon(
|
|
Icons.adjust,
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: _delayTest,
|
|
visualDensity: VisualDensity.standard,
|
|
icon: const Icon(
|
|
Icons.network_ping,
|
|
),
|
|
),
|
|
const SizedBox(
|
|
width: 6,
|
|
),
|
|
],
|
|
AnimatedBuilder(
|
|
animation: _animationController.view,
|
|
builder: (_, __) {
|
|
return IconButton.filledTonal(
|
|
onPressed: () {
|
|
_handleChange(groupName);
|
|
},
|
|
icon: RotationTransition(
|
|
turns: _iconTurns,
|
|
child: const Icon(
|
|
Icons.expand_more,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
onPressed: () {
|
|
_handleChange(groupName);
|
|
},
|
|
);
|
|
}
|
|
}
|