import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:umbrix/core/localization/translations.dart'; import 'package:umbrix/core/model/failures.dart'; import 'package:umbrix/core/router/routes.dart'; import 'package:umbrix/features/common/nested_app_bar.dart'; import 'package:umbrix/features/proxy/model/proxy_entity.dart'; import 'package:umbrix/features/proxy/overview/proxies_overview_notifier.dart'; import 'package:umbrix/features/proxy/widget/proxy_tile.dart'; import 'package:umbrix/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class ProxiesOverviewPage extends HookConsumerWidget with PresLogger { const ProxiesOverviewPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); final interceptBackToHome = !PlatformUtils.isDesktop; Widget withBackToHome(Widget child) { return PopScope( canPop: !interceptBackToHome, onPopInvokedWithResult: (didPop, result) { if (!didPop && interceptBackToHome) { const HomeRoute().go(context); } }, child: child, ); } final asyncProxies = ref.watch(proxiesOverviewNotifierProvider); final notifier = ref.watch(proxiesOverviewNotifierProvider.notifier); final sortBy = ref.watch(proxiesSortNotifierProvider); final selectActiveProxyMutation = useMutation( initialOnFailure: (error) => CustomToast.error(t.presentShortError(error)).show(context), ); final appBar = NestedAppBar( title: Text(t.proxies.pageTitle), actions: [ PopupMenuButton( initialValue: sortBy, onSelected: ref.read(proxiesSortNotifierProvider.notifier).update, icon: const Icon(FluentIcons.arrow_sort_24_regular), tooltip: t.proxies.sortTooltip, itemBuilder: (context) { return [ ...ProxiesSort.values.map( (e) => PopupMenuItem( value: e, child: Text(e.present(t)), ), ), ]; }, ), ], ); switch (asyncProxies) { case AsyncData(value: final groups): if (groups.isEmpty) { return withBackToHome( Scaffold( body: CustomScrollView( slivers: [ appBar, SliverFillRemaining( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(t.proxies.emptyProxiesMsg), ], ), ), ], ), ), ); } // Находим группы "select" и "auto" final selectGroup = groups.firstWhere( (g) => g.type.name == 'selector', orElse: () => groups.first, ); final autoGroup = groups.firstWhere( (g) => g.type.name == 'urltest', orElse: () => groups.last, ); // Текущий выбор из группы "select" final currentSelection = selectGroup.selected; // Все прокси берём из группы "auto" final allProxies = autoGroup.items; // Группировка прокси по странам final proxiesByCountry = >{}; for (final proxy in allProxies) { final country = proxy.countryCode; proxiesByCountry.putIfAbsent(country, () => []).add(proxy); } // Сортировка стран по средней задержке final sortedCountries = proxiesByCountry.keys.toList() ..sort((a, b) { final avgA = _averageDelay(proxiesByCountry[a]!); final avgB = _averageDelay(proxiesByCountry[b]!); return avgA.compareTo(avgB); }); return withBackToHome( Scaffold( body: CustomScrollView( slivers: [ appBar, SliverPadding( padding: const EdgeInsets.only(bottom: 86, left: 12, right: 12, top: 8), sliver: SliverList.builder( // +1 для глобальной карточки "Авто по всем" itemCount: 1 + sortedCountries.length, itemBuilder: (context, index) { // Первая карточка - "Авто по всем локациям" if (index == 0) { return _GlobalAutoCard( currentSelection: currentSelection, avgDelay: autoGroup.items.firstOrNull?.urlTestDelay.toDouble() ?? 999999.0, selectGroup: selectGroup, selectActiveProxyMutation: selectActiveProxyMutation, notifier: notifier, t: t, ); } // Остальные карточки - страны final countryIndex = index - 1; final countryCode = sortedCountries[countryIndex]; final proxies = proxiesByCountry[countryCode]!; final avgDelay = _averageDelay(proxies); return _CountryGroupCard( countryCode: countryCode, proxies: proxies, avgDelay: avgDelay, selectGroup: selectGroup, currentSelection: currentSelection, selectActiveProxyMutation: selectActiveProxyMutation, notifier: notifier, countryFlag: _countryFlag(countryCode), delayColor: _delayColor(context, avgDelay), ); }, ), ), ], ), floatingActionButton: Builder( builder: (context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( gradient: LinearGradient( colors: [ theme.colorScheme.primary, theme.colorScheme.primary.withOpacity(0.8), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: theme.colorScheme.primary.withOpacity(0.4), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: FloatingActionButton( onPressed: () async => notifier.urlTest(selectGroup.tag), tooltip: t.proxies.delayTestTooltip, backgroundColor: Colors.transparent, elevation: 0, child: const Icon(FluentIcons.arrow_clockwise_24_filled), ), ); }, ), ), ); case AsyncError(:final error): return withBackToHome( Scaffold( body: CustomScrollView( slivers: [ appBar, SliverErrorBodyPlaceholder( t.presentShortError(error), icon: null, ), ], ), ), ); case AsyncLoading(): return withBackToHome( Scaffold( body: CustomScrollView( slivers: [ appBar, const SliverLoadingBodyPlaceholder(), ], ), ), ); // TODO: remove default: return const Scaffold(); } } // Вычисление средней задержки double _averageDelay(List proxies) { if (proxies.isEmpty) return 999999.0; final total = proxies.fold(0, (sum, p) => sum + p.urlTestDelay); return total / proxies.length; } // Генерация emoji флага из кода страны String _countryFlag(String countryCode) { if (countryCode == 'XX' || countryCode.length != 2) { return '❓'; // Неизвестная страна } final first = 0x1F1E6 + (countryCode.codeUnitAt(0) - 65); final second = 0x1F1E6 + (countryCode.codeUnitAt(1) - 65); return String.fromCharCodes([first, second]); } // Цвет задержки Color _delayColor(BuildContext context, double delay) { return _getDelayColor(context, delay); } } // Глобальная функция для определения цвета задержки Color _getDelayColor(BuildContext context, double delay) { if (delay >= 999999) return Colors.grey; if (Theme.of(context).brightness == Brightness.dark) { return switch (delay) { < 800 => Colors.lightGreen, < 1500 => Colors.orange, _ => Colors.redAccent, }; } return switch (delay) { < 800 => Colors.green, < 1500 => Colors.deepOrangeAccent, _ => Colors.red, }; } // Виджет глобальной карточки "Авто по всем локациям" class _GlobalAutoCard extends StatelessWidget { final String currentSelection; final double avgDelay; final ProxyGroupEntity selectGroup; final ({ AsyncMutation state, ValueChanged> setFuture, ValueChanged setOnFailure, }) selectActiveProxyMutation; final ProxiesOverviewNotifier notifier; final Translations t; const _GlobalAutoCard({ required this.currentSelection, required this.avgDelay, required this.selectGroup, required this.selectActiveProxyMutation, required this.notifier, required this.t, }); @override Widget build(BuildContext context) { final isSelected = currentSelection.toLowerCase() == 'auto'; return Card( margin: const EdgeInsets.only(bottom: 8), color: isSelected ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3) : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: isSelected ? BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ) : BorderSide.none, ), child: InkWell( onTap: () async { if (selectActiveProxyMutation.state.isInProgress) return; // Выбираем "auto" - автовыбор по всем странам selectActiveProxyMutation.setFuture( notifier.changeProxy(selectGroup.tag, 'auto'), ); }, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ // Иконка глобуса const Text('🌍', style: TextStyle(fontSize: 32)), const SizedBox(width: 16), // Название и описание Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( t.proxies.globalAuto, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(width: 8), if (isSelected) Icon( Icons.check_circle, color: Theme.of(context).colorScheme.primary, size: 20, ), ], ), const SizedBox(height: 4), Text( t.proxies.globalAutoDesc, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ), ], ), ), // Задержка Text( avgDelay >= 999999 ? 'timeout' : '${avgDelay.toInt()}ms', style: TextStyle( color: _getDelayColor(context, avgDelay), fontWeight: FontWeight.w600, fontSize: 14, ), ), ], ), ), ), ); } } // Виджет карточки группы по стране с раскрывающимся списком class _CountryGroupCard extends StatefulWidget { final String countryCode; final List proxies; final double avgDelay; final ProxyGroupEntity selectGroup; final String currentSelection; final ({ AsyncMutation state, ValueChanged> setFuture, ValueChanged setOnFailure, }) selectActiveProxyMutation; final ProxiesOverviewNotifier notifier; final String countryFlag; final Color delayColor; const _CountryGroupCard({ required this.countryCode, required this.proxies, required this.avgDelay, required this.selectGroup, required this.currentSelection, required this.selectActiveProxyMutation, required this.notifier, required this.countryFlag, required this.delayColor, }); @override State<_CountryGroupCard> createState() => _CountryGroupCardState(); } class _CountryGroupCardState extends State<_CountryGroupCard> { bool _isExpanded = false; @override Widget build(BuildContext context) { // Проверяем выбран ли один из прокси этой страны final selectedProxyInCountry = widget.proxies.any( (p) => p.tag == widget.currentSelection, ); return Card( margin: const EdgeInsets.only(bottom: 8), color: selectedProxyInCountry ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3) : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: selectedProxyInCountry ? BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ) : BorderSide.none, ), child: Column( children: [ // Основная карточка - клик выбирает первый прокси из этой страны InkWell( onTap: () async { if (widget.selectActiveProxyMutation.state.isInProgress) return; // Выбираем первый прокси из этой страны (обычно лучший) final firstProxy = widget.proxies.firstOrNull; if (firstProxy != null) { widget.selectActiveProxyMutation.setFuture( widget.notifier.changeProxy(widget.selectGroup.tag, firstProxy.tag), ); } }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // Флаг Text( widget.countryFlag, style: const TextStyle(fontSize: 32), ), const SizedBox(width: 16), // Название и количество Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( widget.countryCode == 'XX' ? 'Other' : widget.countryCode, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(width: 8), Text( '(${widget.proxies.length})', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 14, ), ), ], ), if (widget.countryCode == 'XX') Text( 'Автовыбор из всех локаций', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ), ], ), ), // Задержка Text( widget.avgDelay >= 999999 ? 'timeout' : '${widget.avgDelay.toInt()}ms', style: TextStyle( color: widget.delayColor, fontWeight: FontWeight.w600, ), ), // Кнопка раскрытия (треугольник) - только если не XX if (widget.countryCode != 'XX') IconButton( icon: Icon( _isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, ), onPressed: () { setState(() { _isExpanded = !_isExpanded; }); }, ), ], ), ), ), // Раскрывающийся список прокси if (_isExpanded) ...[ // Все прокси из этой страны ...widget.proxies.map((proxy) { return ProxyTile( proxy, selected: widget.currentSelection == proxy.tag, onSelect: () async { if (widget.selectActiveProxyMutation.state.isInProgress) { return; } widget.selectActiveProxyMutation.setFuture( widget.notifier.changeProxy(widget.selectGroup.tag, proxy.tag), ); }, ); }), ], ], ), ); } }