Files
umbrix/lib/features/proxy/overview/proxies_overview_page.dart
Umbrix Developer 76a374950f feat: mobile-like window size and always-visible stats
- 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
2026-01-17 13:09:20 +03:00

542 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
);
},
);
}),
],
],
),
);
}
}