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/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>; class ProxiesListView extends StatefulWidget { const ProxiesListView({super.key}); @override State createState() => _ProxiesListViewState(); } class _ProxiesListViewState extends State { final _controller = ScrollController(); final _headerStateNotifier = ValueNotifier( null, ); List _headerOffset = []; double containerHeight = 0; @override void initState() { super.initState(); _controller.addListener(_adjustHeader); WidgetsBinding.instance.addPostFrameCallback((_) { _adjustHeader(); }); } ProxiesListHeaderSelectorState _getProxiesListHeaderSelectorState( double initOffset, ) { final index = _headerOffset.findInterval(initOffset); final currentIndex = index; double headerOffset = 0.0; if (index + 1 <= _headerOffset.length - 1) { final endOffset = _headerOffset[index + 1]; final startOffset = endOffset - listHeaderHeight - 8; if (initOffset > startOffset && initOffset < endOffset) { headerOffset = initOffset - startOffset; } } return ProxiesListHeaderSelectorState( offset: max(headerOffset, 0), currentIndex: currentIndex, ); } void _adjustHeader() { _headerStateNotifier.value = _getProxiesListHeaderSelectorState( !_controller.hasClients ? 0 : _controller.offset, ); } 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(); } void _handleChange(Set currentUnfoldSet, String groupName) { _autoScrollToGroup(groupName); final tempUnfoldSet = Set.from(currentUnfoldSet); if (tempUnfoldSet.contains(groupName)) { tempUnfoldSet.remove(groupName); } else { tempUnfoldSet.add(groupName); } globalState.appController.updateCurrentUnfoldSet(tempUnfoldSet); WidgetsBinding.instance.addPostFrameCallback((_) { _adjustHeader(); }); } List _getItemHeightList( List items, ProxyCardType proxyCardType, ) { final itemHeightList = []; List 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 _buildItems( WidgetRef ref, { required List groups, required int columns, required Set currentUnfoldSet, required ProxyCardType cardType, }) { final items = []; for (final group in groups) { final groupName = group.name; final isExpand = currentUnfoldSet.contains(groupName); items.addAll([ ListHeader( onScrollToSelected: _scrollToGroupSelected, isExpand: isExpand, group: group, onChange: (String groupName) { _handleChange(currentUnfoldSet, groupName); }, ), const SizedBox(height: 8), ]); if (isExpand) { final proxies = group.all; final chunks = proxies.chunks(columns); final rows = chunks .map((proxies) { final children = proxies .map( (proxy) => Flexible( child: SizedBox( height: getItemHeight(cardType), child: ProxyCard( testUrl: group.testUrl, type: cardType, 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)]); } } return items; } Widget _buildHeader( WidgetRef ref, { required Group group, required Set currentUnfoldSet, }) { final groupName = group.name; 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); }, ), ); } double _getGroupOffset(String groupName) { if (_controller.position.maxScrollExtent == 0) { return 0; } final currentGroups = globalState.appController.getCurrentGroups(); final findIndex = currentGroups.indexWhere( (item) => item.name == groupName, ); final index = findIndex != -1 ? findIndex : 0; return _headerOffset[index]; } 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) { final currentInitOffset = _getGroupOffset(groupName); final currentGroups = globalState.appController.getCurrentGroups(); final proxies = currentGroups.getGroup(groupName)?.all; _jumpTo( currentInitOffset + 8 + getScrollToSelectedOffset( groupName: groupName, proxies: proxies ?? [], ), ); } void _jumpTo(double offset) { if (mounted && _controller.hasClients) { _controller.animateTo( offset.clamp( _controller.position.minScrollExtent, _controller.position.maxScrollExtent, ), duration: const Duration(milliseconds: 300), curve: Curves.easeIn, ); } } @override Widget build(BuildContext context) { return Consumer( builder: (_, ref, _) { final state = ref.watch(proxiesListStateProvider); ref.watch(themeSettingProvider.select((state) => state.textScale)); if (state.groups.isEmpty) { return NullStatus( label: appLocalizations.nullTip(appLocalizations.proxies), ); } final items = _buildItems( ref, groups: state.groups, currentUnfoldSet: state.currentUnfoldSet, columns: state.columns, cardType: state.proxyCardType, ); final itemsOffset = _getItemHeightList(items, state.proxyCardType); return CommonScrollBar( controller: _controller, thumbVisibility: true, trackVisibility: true, child: Stack( children: [ Positioned.fill( child: ScrollConfiguration( behavior: HiddenBarScrollBehavior(), child: ListView.builder( key: proxiesListStoreKey, padding: const EdgeInsets.all(16), controller: _controller, itemExtentBuilder: (index, _) { return itemsOffset[index]; }, itemCount: items.length, itemBuilder: (_, index) { return items[index]; }, ), ), ), LayoutBuilder( builder: (_, container) { containerHeight = container.maxHeight; return ValueListenableBuilder( valueListenable: _headerStateNotifier, builder: (_, headerState, _) { if (headerState == null) { return SizedBox(); } final index = headerState.currentIndex > state.groups.length - 1 ? 0 : headerState.currentIndex; if (index < 0 || state.groups.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, group: state.groups[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 createState() => _ListHeaderState(); } class _ListHeaderState extends State { 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; Future _delayTest() async { if (isLock) return; isLock = true; await delayTest(widget.group.all, widget.group.testUrl); isLock = false; } void _handleChange(String groupName) { widget.onChange(groupName); } 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( borderRadius: BorderRadius.circular(12), color: context.colorScheme.secondaryContainer, ), 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: 18.ap, 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.compact, padding: EdgeInsets.all(2), onPressed: () { widget.onScrollToSelected(groupName); }, iconSize: 19, icon: const Icon(Icons.adjust), ), const SizedBox(width: 2), IconButton( iconSize: 20, visualDensity: VisualDensity.compact, padding: EdgeInsets.all(2), onPressed: _delayTest, icon: const Icon(Icons.network_ping), ), const SizedBox(width: 6), ] else SizedBox(width: 6), IconButton.filledTonal( visualDensity: VisualDensity.compact, padding: EdgeInsets.all(2), iconSize: 24, onPressed: () { _handleChange(groupName); }, icon: CommonExpandIcon(expand: isExpand), ), ], ), ], ), ), onPressed: () { _handleChange(groupName); }, ); } }