- Changed window size to mobile phone format (400x800) - Removed width condition for ActiveProxyFooter - now always visible - Added run-umbrix.sh launch script with icon copying - Stats cards now display on all screen sizes
542 lines
19 KiB
Dart
542 lines
19 KiB
Dart
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<void>(
|
||
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<ProxiesSort>(
|
||
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 = <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);
|
||
});
|
||
|
||
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<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),
|
||
);
|
||
},
|
||
);
|
||
}),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|