Files
umbrix/lib/features/proxy/overview/proxies_overview_page.dart

542 lines
19 KiB
Dart
Raw Normal View History

2024-02-15 15:23:02 +03:30
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
2023-07-06 17:18:41 +03:30
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';
2023-07-06 17:18:41 +03:30
import 'package:hooks_riverpod/hooks_riverpod.dart';
2023-12-01 12:56:24 +03:30
class ProxiesOverviewPage extends HookConsumerWidget with PresLogger {
const ProxiesOverviewPage({super.key});
2023-07-06 17:18:41 +03:30
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final interceptBackToHome = !PlatformUtils.isDesktop;
Widget withBackToHome(Widget child) {
return PopScope<void>(
canPop: !interceptBackToHome,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && interceptBackToHome) {
const HomeRoute().go(context);
}
},
child: child,
);
}
2023-12-01 12:56:24 +03:30
final asyncProxies = ref.watch(proxiesOverviewNotifierProvider);
final notifier = ref.watch(proxiesOverviewNotifierProvider.notifier);
2023-09-17 14:24:25 +03:30
final sortBy = ref.watch(proxiesSortNotifierProvider);
2023-07-06 17:18:41 +03:30
final selectActiveProxyMutation = useMutation(
initialOnFailure: (error) => CustomToast.error(t.presentShortError(error)).show(context),
2023-07-06 17:18:41 +03:30
);
2023-12-31 10:49:37 +03:30
final appBar = NestedAppBar(
title: Text(t.proxies.pageTitle),
actions: [
PopupMenuButton<ProxiesSort>(
initialValue: sortBy,
onSelected: ref.read(proxiesSortNotifierProvider.notifier).update,
2024-02-15 15:23:02 +03:30
icon: const Icon(FluentIcons.arrow_sort_24_regular),
2023-12-31 10:49:37 +03:30
tooltip: t.proxies.sortTooltip,
itemBuilder: (context) {
return [
...ProxiesSort.values.map(
(e) => PopupMenuItem(
value: e,
child: Text(e.present(t)),
),
),
];
},
),
],
);
2023-07-06 17:18:41 +03:30
switch (asyncProxies) {
2023-08-25 13:29:39 +03:30
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),
],
),
2023-07-06 17:18:41 +03:30
),
],
),
2023-07-06 17:18:41 +03:30
),
);
}
// Находим группы "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 = <String, List<ProxyItemEntity>>{};
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);
});
2023-08-25 13:29:39 +03:30
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),
2023-08-25 13:29:39 +03:30
);
},
),
),
],
),
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),
),
2025-12-26 02:47:20 +03:00
],
),
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),
),
);
},
),
2023-07-06 17:18:41 +03:30
),
);
case AsyncError(:final error):
return withBackToHome(
Scaffold(
body: CustomScrollView(
slivers: [
appBar,
SliverErrorBodyPlaceholder(
t.presentShortError(error),
icon: null,
),
],
),
2023-07-06 17:18:41 +03:30
),
);
case AsyncLoading():
return withBackToHome(
Scaffold(
body: CustomScrollView(
slivers: [
appBar,
const SliverLoadingBodyPlaceholder(),
],
),
2023-07-06 17:18:41 +03:30
),
);
// TODO: remove
default:
return const Scaffold();
}
}
// Вычисление средней задержки
double _averageDelay(List<ProxyItemEntity> proxies) {
if (proxies.isEmpty) return 999999.0;
final total = proxies.fold<int>(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<Future<void>> setFuture,
ValueChanged<void Function(Object error)> 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<ProxyItemEntity> proxies;
final double avgDelay;
final ProxyGroupEntity selectGroup;
final String currentSelection;
final ({
AsyncMutation state,
ValueChanged<Future<void>> setFuture,
ValueChanged<void Function(Object error)> 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),
);
},
);
}),
],
],
),
);
}
2023-07-06 17:18:41 +03:30
}