Initial Commit

This commit is contained in:
ivan2282
2025-11-15 20:06:40 +03:00
commit 205d11df0d
233 changed files with 52572 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,676 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../connection/connection_logger.dart';
import '../connection/connection_state.dart' as conn_state;
import '../connection/health_monitor.dart';
import '../api_service_v2.dart';
class ConnectionDebugPanel extends StatefulWidget {
final bool isVisible;
final VoidCallback? onClose;
const ConnectionDebugPanel({super.key, this.isVisible = false, this.onClose});
@override
State<ConnectionDebugPanel> createState() => _ConnectionDebugPanelState();
}
class _ConnectionDebugPanelState extends State<ConnectionDebugPanel>
with TickerProviderStateMixin {
late TabController _tabController;
List<LogEntry> _logs = [];
final List<conn_state.ConnectionInfo> _stateHistory = [];
final List<HealthMetrics> _healthMetrics = [];
late StreamSubscription<List<LogEntry>> _logsSubscription;
late StreamSubscription<conn_state.ConnectionInfo> _stateSubscription;
late StreamSubscription<HealthMetrics> _healthSubscription;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_setupSubscriptions();
}
void _setupSubscriptions() {
_logsSubscription = Stream.periodic(const Duration(seconds: 1))
.asyncMap((_) async => ApiServiceV2.instance.logs.take(100).toList())
.listen((logs) {
if (mounted) {
setState(() {
_logs = logs;
});
}
});
_stateSubscription = ApiServiceV2.instance.connectionState.listen((state) {
if (mounted) {
setState(() {
_stateHistory.add(state);
if (_stateHistory.length > 50) {
_stateHistory.removeAt(0);
}
});
}
});
_healthSubscription = ApiServiceV2.instance.healthMetrics.listen((health) {
if (mounted) {
setState(() {
_healthMetrics.add(health);
if (_healthMetrics.length > 50) {
_healthMetrics.removeAt(0);
}
});
}
});
}
@override
void dispose() {
_logsSubscription.cancel();
_stateSubscription.cancel();
_healthSubscription.cancel();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.isVisible) return const SizedBox.shrink();
return Container(
height: 400,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Column(
children: [
_buildHeader(),
_buildTabBar(),
Expanded(child: _buildTabContent()),
],
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
Icon(Icons.bug_report, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
'Отладка подключения',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close),
iconSize: 20,
),
],
),
);
}
Widget _buildTabBar() {
return TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(text: 'Логи'),
Tab(text: 'Состояния'),
Tab(text: 'Здоровье'),
Tab(text: 'Статистика'),
],
);
}
Widget _buildTabContent() {
return TabBarView(
controller: _tabController,
children: [
_buildLogsTab(),
_buildStatesTab(),
_buildHealthTab(),
_buildStatsTab(),
],
);
}
Widget _buildLogsTab() {
return Column(
children: [
_buildLogsControls(),
Expanded(child: _buildLogsList()),
],
);
}
Widget _buildLogsControls() {
return Container(
padding: const EdgeInsets.all(8),
child: Row(
children: [
ElevatedButton.icon(
onPressed: _clearLogs,
icon: const Icon(Icons.clear, size: 16),
label: const Text('Очистить'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _exportLogs,
icon: const Icon(Icons.download, size: 16),
label: const Text('Экспорт'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
const Spacer(),
Text(
'Логов: ${_logs.length}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
Widget _buildLogsList() {
if (_logs.isEmpty) {
return const Center(child: Text('Нет логов'));
}
return ListView.builder(
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
return _buildLogItem(log);
},
);
}
Widget _buildLogItem(LogEntry log) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _getLogColor(log.level).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _getLogColor(log.level).withOpacity(0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getLogIcon(log.level),
size: 16,
color: _getLogColor(log.level),
),
const SizedBox(width: 8),
Text(
log.category,
style: TextStyle(
color: _getLogColor(log.level),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const Spacer(),
Text(
_formatTime(log.timestamp),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
Text(log.message, style: Theme.of(context).textTheme.bodyMedium),
if (log.data != null) ...[
const SizedBox(height: 4),
Text(
'Data: ${log.data}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
],
if (log.error != null) ...[
const SizedBox(height: 4),
Text(
'Error: ${log.error}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red,
fontFamily: 'monospace',
),
),
],
],
),
);
}
Widget _buildStatesTab() {
return ListView.builder(
itemCount: _stateHistory.length,
itemBuilder: (context, index) {
final state = _stateHistory[index];
return _buildStateItem(state);
},
);
}
Widget _buildStateItem(conn_state.ConnectionInfo state) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _getStateColor(state.state).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _getStateColor(state.state).withOpacity(0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getStateIcon(state.state),
size: 16,
color: _getStateColor(state.state),
),
const SizedBox(width: 8),
Text(
_getStateText(state.state),
style: TextStyle(
color: _getStateColor(state.state),
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text(
_formatTime(state.timestamp),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
if (state.message != null) ...[
const SizedBox(height: 4),
Text(state.message!, style: Theme.of(context).textTheme.bodyMedium),
],
if (state.serverUrl != null) ...[
const SizedBox(height: 4),
Text(
'Сервер: ${state.serverUrl}',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.latency != null) ...[
const SizedBox(height: 4),
Text(
'Задержка: ${state.latency}ms',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
Widget _buildHealthTab() {
if (_healthMetrics.isEmpty) {
return const Center(child: Text('Нет данных о здоровье'));
}
final latestHealth = _healthMetrics.last;
return Column(
children: [
_buildHealthSummary(latestHealth),
Expanded(child: _buildHealthChart()),
],
);
}
Widget _buildHealthSummary(HealthMetrics health) {
return Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getHealthColor(health.quality).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getHealthColor(health.quality).withOpacity(0.3),
width: 1,
),
),
child: Column(
children: [
Row(
children: [
Icon(
_getHealthIcon(health.quality),
size: 24,
color: _getHealthColor(health.quality),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Здоровье соединения',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${health.healthScore}/100 - ${_getHealthText(health.quality)}',
style: TextStyle(
color: _getHealthColor(health.quality),
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildHealthMetric('Задержка', '${health.latency}ms'),
_buildHealthMetric('Потери', '${health.packetLoss}%'),
_buildHealthMetric('Переподключения', '${health.reconnects}'),
_buildHealthMetric('Ошибки', '${health.errors}'),
],
),
],
),
);
}
Widget _buildHealthMetric(String label, String value) {
return Column(
children: [
Text(
value,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
Widget _buildHealthChart() {
return Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: const Center(
child: Text('График здоровья соединения\n(в разработке)'),
),
);
}
Widget _buildStatsTab() {
return FutureBuilder<Map<String, dynamic>>(
future: ApiServiceV2.instance
.getStatistics(), // Указываем Future, который нужно ожидать
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Ошибка загрузки статистики: ${snapshot.error}'),
);
}
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: Text('Нет данных для отображения'));
}
final stats = snapshot.data!; // Теперь это точно Map<String, dynamic>
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildStatsSection('API Service', stats['api_service']),
const SizedBox(height: 16),
_buildStatsSection('Connection', stats['connection']),
],
);
},
);
}
Widget _buildStatsSection(String title, Map<String, dynamic>? data) {
if (data == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...data.entries.map((entry) => _buildStatsRow(entry.key, entry.value)),
],
);
}
Widget _buildStatsRow(String key, dynamic value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(key, style: Theme.of(context).textTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text(
value.toString(),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
);
}
Color _getLogColor(LogLevel level) {
switch (level) {
case LogLevel.debug:
return Colors.blue;
case LogLevel.info:
return Colors.green;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return Colors.red;
case LogLevel.critical:
return Colors.red.shade800;
}
}
IconData _getLogIcon(LogLevel level) {
switch (level) {
case LogLevel.debug:
return Icons.bug_report;
case LogLevel.info:
return Icons.info;
case LogLevel.warning:
return Icons.warning;
case LogLevel.error:
return Icons.error;
case LogLevel.critical:
return Icons.dangerous;
}
}
Color _getStateColor(conn_state.ConnectionState state) {
switch (state) {
case conn_state.ConnectionState.ready:
return Colors.green;
case conn_state.ConnectionState.connected:
return Colors.blue;
case conn_state.ConnectionState.connecting:
case conn_state.ConnectionState.reconnecting:
return Colors.orange;
case conn_state.ConnectionState.error:
return Colors.red;
case conn_state.ConnectionState.disconnected:
case conn_state.ConnectionState.disabled:
return Colors.grey;
}
}
IconData _getStateIcon(conn_state.ConnectionState state) {
switch (state) {
case conn_state.ConnectionState.ready:
return Icons.check_circle;
case conn_state.ConnectionState.connected:
return Icons.link;
case conn_state.ConnectionState.connecting:
case conn_state.ConnectionState.reconnecting:
return Icons.sync;
case conn_state.ConnectionState.error:
return Icons.error;
case conn_state.ConnectionState.disconnected:
case conn_state.ConnectionState.disabled:
return Icons.link_off;
}
}
String _getStateText(conn_state.ConnectionState state) {
switch (state) {
case conn_state.ConnectionState.ready:
return 'Готов';
case conn_state.ConnectionState.connected:
return 'Подключен';
case conn_state.ConnectionState.connecting:
return 'Подключение';
case conn_state.ConnectionState.reconnecting:
return 'Переподключение';
case conn_state.ConnectionState.error:
return 'Ошибка';
case conn_state.ConnectionState.disconnected:
return 'Отключен';
case conn_state.ConnectionState.disabled:
return 'Отключен';
}
}
Color _getHealthColor(ConnectionQuality quality) {
switch (quality) {
case ConnectionQuality.excellent:
return Colors.green;
case ConnectionQuality.good:
return Colors.lightGreen;
case ConnectionQuality.fair:
return Colors.orange;
case ConnectionQuality.poor:
return Colors.red;
case ConnectionQuality.critical:
return Colors.red.shade800;
}
}
IconData _getHealthIcon(ConnectionQuality quality) {
switch (quality) {
case ConnectionQuality.excellent:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.good:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.fair:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.poor:
return Icons.signal_cellular_0_bar;
case ConnectionQuality.critical:
return Icons.signal_cellular_0_bar;
}
}
String _getHealthText(ConnectionQuality quality) {
switch (quality) {
case ConnectionQuality.excellent:
return 'Отлично';
case ConnectionQuality.good:
return 'Хорошо';
case ConnectionQuality.fair:
return 'Удовлетворительно';
case ConnectionQuality.poor:
return 'Плохо';
case ConnectionQuality.critical:
return 'Критично';
}
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
void _clearLogs() {
}
void _exportLogs() {
}
}

View File

@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../connection/connection_state.dart' as conn_state;
import '../connection/health_monitor.dart';
import '../api_service_v2.dart';
class ConnectionStatusWidget extends StatefulWidget {
final bool showDetails;
final bool showHealthMetrics;
final VoidCallback? onTap;
const ConnectionStatusWidget({
super.key,
this.showDetails = false,
this.showHealthMetrics = false,
this.onTap,
});
@override
State<ConnectionStatusWidget> createState() => _ConnectionStatusWidgetState();
}
class _ConnectionStatusWidgetState extends State<ConnectionStatusWidget> {
late StreamSubscription<conn_state.ConnectionInfo> _stateSubscription;
late StreamSubscription<HealthMetrics> _healthSubscription;
conn_state.ConnectionInfo? _currentState;
HealthMetrics? _currentHealth;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_setupSubscriptions();
}
void _setupSubscriptions() {
_stateSubscription = ApiServiceV2.instance.connectionState.listen((state) {
if (mounted) {
setState(() {
_currentState = state;
});
}
});
if (widget.showHealthMetrics) {
_healthSubscription = ApiServiceV2.instance.healthMetrics.listen((
health,
) {
if (mounted) {
setState(() {
_currentHealth = health;
});
}
});
}
}
@override
void dispose() {
_stateSubscription.cancel();
_healthSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_currentState == null) {
return const SizedBox.shrink();
}
return GestureDetector(
onTap: widget.onTap ?? _toggleExpanded,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _getStatusColor().withOpacity(0.3),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusIcon(),
const SizedBox(width: 8),
_buildStatusText(),
if (widget.showDetails) ...[
const SizedBox(width: 8),
Icon(
_isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 16,
color: _getStatusColor(),
),
],
],
),
if (_isExpanded && widget.showDetails) ...[
const SizedBox(height: 8),
_buildDetails(),
],
],
),
),
);
}
Widget _buildStatusIcon() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 8,
height: 8,
decoration: BoxDecoration(
color: _getStatusColor(),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.5),
blurRadius: 4,
spreadRadius: 1,
),
],
),
);
}
Widget _buildStatusText() {
return Text(
_getStatusText(),
style: TextStyle(
color: _getStatusColor(),
fontSize: 12,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildDetails() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_currentState?.serverUrl != null)
_buildDetailRow('Сервер', _currentState!.serverUrl!),
if (_currentState?.latency != null)
_buildDetailRow('Задержка', '${_currentState!.latency}ms'),
if (_currentState?.attemptNumber != null)
_buildDetailRow('Попытка', '${_currentState!.attemptNumber}'),
if (_currentState?.reconnectDelay != null)
_buildDetailRow(
'Переподключение',
'через ${_currentState!.reconnectDelay!.inSeconds}с',
),
if (_currentHealth != null) ...[
const SizedBox(height: 4),
_buildHealthMetrics(),
],
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: TextStyle(
color: _getStatusColor().withOpacity(0.7),
fontSize: 10,
),
),
Text(
value,
style: TextStyle(
color: _getStatusColor(),
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildHealthMetrics() {
if (_currentHealth == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _getHealthColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _getHealthColor().withOpacity(0.3), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_getHealthIcon(), size: 12, color: _getHealthColor()),
const SizedBox(width: 4),
Text(
'Здоровье: ${_currentHealth!.healthScore}/100',
style: TextStyle(
color: _getHealthColor(),
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 4),
_buildHealthBar(),
],
),
);
}
Widget _buildHealthBar() {
final score = _currentHealth!.healthScore;
return Container(
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: score / 100,
child: Container(
decoration: BoxDecoration(
color: _getHealthColor(),
borderRadius: BorderRadius.circular(2),
),
),
),
);
}
Color _getStatusColor() {
switch (_currentState?.state) {
case conn_state.ConnectionState.ready:
return Colors.green;
case conn_state.ConnectionState.connected:
return Colors.blue;
case conn_state.ConnectionState.connecting:
case conn_state.ConnectionState.reconnecting:
return Colors.orange;
case conn_state.ConnectionState.error:
return Colors.red;
case conn_state.ConnectionState.disconnected:
case conn_state.ConnectionState.disabled:
default:
return Colors.grey;
}
}
String _getStatusText() {
switch (_currentState?.state) {
case conn_state.ConnectionState.ready:
return 'Готов';
case conn_state.ConnectionState.connected:
return 'Подключен';
case conn_state.ConnectionState.connecting:
return 'Подключение...';
case conn_state.ConnectionState.reconnecting:
return 'Переподключение...';
case conn_state.ConnectionState.error:
return 'Ошибка';
case conn_state.ConnectionState.disconnected:
return 'Отключен';
case conn_state.ConnectionState.disabled:
return 'Отключен';
default:
return 'Неизвестно';
}
}
Color _getHealthColor() {
if (_currentHealth == null) return Colors.grey;
switch (_currentHealth!.quality) {
case ConnectionQuality.excellent:
return Colors.green;
case ConnectionQuality.good:
return Colors.lightGreen;
case ConnectionQuality.fair:
return Colors.orange;
case ConnectionQuality.poor:
return Colors.red;
case ConnectionQuality.critical:
return Colors.red.shade800;
}
}
IconData _getHealthIcon() {
if (_currentHealth == null) return Icons.help_outline;
switch (_currentHealth!.quality) {
case ConnectionQuality.excellent:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.good:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.fair:
return Icons.signal_cellular_4_bar;
case ConnectionQuality.poor:
return Icons.signal_cellular_0_bar;
case ConnectionQuality.critical:
return Icons.signal_cellular_0_bar;
}
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
}
}
class ConnectionIndicator extends StatefulWidget {
final double size;
final bool showPulse;
const ConnectionIndicator({
super.key,
this.size = 12.0,
this.showPulse = true,
});
@override
State<ConnectionIndicator> createState() => _ConnectionIndicatorState();
}
class _ConnectionIndicatorState extends State<ConnectionIndicator> {
@override
Widget build(BuildContext context) {
return StreamBuilder<conn_state.ConnectionInfo>(
stream: ApiServiceV2.instance.connectionState,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const CircularProgressIndicator(strokeWidth: 2),
);
}
final state = snapshot.data!;
final color = _getStatusColor(state.state);
final isActive =
state.state == conn_state.ConnectionState.ready ||
state.state == conn_state.ConnectionState.connected;
if (widget.showPulse && isActive) {
return _buildPulsingIndicator(color);
} else {
return _buildStaticIndicator(color);
}
},
);
}
Widget _buildPulsingIndicator(Color color) {
return TweenAnimationBuilder<double>(
duration: const Duration(seconds: 2),
tween: Tween(begin: 0.0, end: 1.0),
builder: (context, value, child) {
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: color.withOpacity(0.3 + (0.7 * value)),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withOpacity(0.5 * value),
blurRadius: 8 * value,
spreadRadius: 2 * value,
),
],
),
);
},
onEnd: () {
if (mounted) {
setState(() {});
}
},
);
}
Widget _buildStaticIndicator(Color color) {
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withOpacity(0.5),
blurRadius: 4,
spreadRadius: 1,
),
],
),
);
}
Color _getStatusColor(conn_state.ConnectionState state) {
switch (state) {
case conn_state.ConnectionState.ready:
return Colors.green;
case conn_state.ConnectionState.connected:
return Colors.blue;
case conn_state.ConnectionState.connecting:
case conn_state.ConnectionState.reconnecting:
return Colors.orange;
case conn_state.ConnectionState.error:
return Colors.red;
case conn_state.ConnectionState.disconnected:
case conn_state.ConnectionState.disabled:
return Colors.grey;
}
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/contact.dart';
class GroupAvatars extends StatelessWidget {
final Chat chat;
final Map<int, Contact> contacts;
final int maxAvatars;
final double avatarSize;
final double overlap;
const GroupAvatars({
super.key,
required this.chat,
required this.contacts,
this.maxAvatars = 3,
this.avatarSize = 16.0,
this.overlap = 8.0,
});
@override
Widget build(BuildContext context) {
if (!chat.isGroup) {
return const SizedBox.shrink();
}
final participantIds = chat.groupParticipantIds;
if (participantIds.isEmpty) {
return const SizedBox.shrink();
}
final visibleParticipants = participantIds.take(maxAvatars).toList();
final remainingCount = participantIds.length - maxAvatars;
final totalParticipants = participantIds.length;
double adaptiveAvatarSize;
if (totalParticipants <= 2) {
adaptiveAvatarSize =
avatarSize * 1.5; // Большие аватары для 1-2 участников
} else if (totalParticipants <= 4) {
adaptiveAvatarSize =
avatarSize * 1.2; // Средние аватары для 3-4 участников
} else {
adaptiveAvatarSize =
avatarSize * 0.8; // Маленькие аватары для 5+ участников
}
return SizedBox(
height: adaptiveAvatarSize * 2.5,
width: adaptiveAvatarSize * 2.5,
child: Stack(
children: [
...visibleParticipants.asMap().entries.map((entry) {
final index = entry.key;
final participantId = entry.value;
final contact = contacts[participantId];
double x, y;
if (visibleParticipants.length == 1) {
x = adaptiveAvatarSize * 1.25;
y = adaptiveAvatarSize * 1.25;
} else if (visibleParticipants.length == 2) {
x = adaptiveAvatarSize * (0.5 + index * 1.5);
y = adaptiveAvatarSize * 1.25;
} else {
final angle = (index * 2 * pi) / visibleParticipants.length;
final radius = adaptiveAvatarSize * 0.6;
final center = adaptiveAvatarSize * 1.25;
x = center + radius * cos(angle);
y = center + radius * sin(angle);
}
return Positioned(
left: x - adaptiveAvatarSize / 2,
top: y - adaptiveAvatarSize / 2,
child: _buildAvatar(
context,
contact,
participantId,
adaptiveAvatarSize,
),
);
}),
if (remainingCount > 0)
Positioned(
left: adaptiveAvatarSize * 0.75,
top: adaptiveAvatarSize * 0.75,
child: _buildMoreIndicator(
context,
remainingCount,
adaptiveAvatarSize,
),
),
],
),
);
}
Widget _buildAvatar(
BuildContext context,
Contact? contact,
int participantId,
double size,
) {
final colors = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: colors.surface, width: 2),
boxShadow: [
BoxShadow(
color: colors.shadow.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: CircleAvatar(
radius: size / 2,
backgroundColor: contact != null
? colors.primaryContainer
: colors.secondaryContainer,
backgroundImage: contact?.photoBaseUrl != null
? NetworkImage(contact!.photoBaseUrl!)
: null,
child: contact?.photoBaseUrl == null
? Text(
contact?.name.isNotEmpty == true
? contact!.name[0].toUpperCase()
: participantId.toString().substring(
participantId.toString().length - 1,
), // Последняя цифра ID
style: TextStyle(
color: contact != null
? colors.onPrimaryContainer
: colors.onSecondaryContainer,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
)
: null,
),
);
}
Widget _buildMoreIndicator(BuildContext context, int count, double size) {
final colors = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.secondaryContainer,
border: Border.all(color: colors.surface, width: 2),
boxShadow: [
BoxShadow(
color: colors.shadow.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: Text(
'+$count',
style: TextStyle(
color: colors.onSecondaryContainer,
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
),
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/widgets/group_avatars.dart';
import 'package:gwid/widgets/group_management_panel.dart';
class GroupHeader extends StatelessWidget {
final Chat chat;
final Map<int, Contact> contacts;
final int myId;
final VoidCallback? onParticipantsChanged;
const GroupHeader({
super.key,
required this.chat,
required this.contacts,
required this.myId,
this.onParticipantsChanged,
});
@override
Widget build(BuildContext context) {
if (!chat.isGroup) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
final onlineCount = chat.onlineParticipantsCount;
final totalCount = chat.participantsCount;
return GestureDetector(
onTap: () => _showGroupManagementPanel(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
GroupAvatars(
chat: chat,
contacts: contacts,
maxAvatars: 4,
avatarSize: 20.0,
overlap: 6.0,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
chat.displayTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
if (onlineCount > 0) ...[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'$onlineCount онлайн',
style: TextStyle(
fontSize: 12,
color: colors.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
],
Text(
'$totalCount участников',
style: TextStyle(
fontSize: 12,
color: colors.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
),
);
}
void _showGroupManagementPanel(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => GroupManagementPanel(
chat: chat,
contacts: contacts,
myId: myId,
onParticipantsChanged: onParticipantsChanged,
),
);
}
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:gwid/models/chat.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/api_service.dart';
import 'package:gwid/screens/group_settings_screen.dart';
class GroupManagementPanel extends StatefulWidget {
final Chat chat;
final Map<int, Contact> contacts;
final int myId;
final VoidCallback? onParticipantsChanged;
const GroupManagementPanel({
super.key,
required this.chat,
required this.contacts,
required this.myId,
this.onParticipantsChanged,
});
@override
State<GroupManagementPanel> createState() => _GroupManagementPanelState();
}
class _GroupManagementPanelState extends State<GroupManagementPanel> {
final ApiService _apiService = ApiService.instance;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.3,
maxChildSize: 1.0,
builder: (context, scrollController) {
return _buildContent(context, scrollController);
},
);
}
Widget _buildContent(
BuildContext context,
ScrollController scrollController,
) {
final colors = Theme.of(context).colorScheme;
final participantIds = widget.chat.groupParticipantIds;
final participants = participantIds
.map((id) => widget.contacts[id])
.where((contact) => contact != null)
.cast<Contact>()
.toList();
return Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: colors.outline.withOpacity(0.2)),
),
),
child: Row(
children: [
Icon(Icons.group, color: colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.chat.displayTitle,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: colors.onSurface,
),
),
Text(
'${participants.length} участников',
style: TextStyle(
fontSize: 14,
color: colors.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => GroupSettingsScreen(
chatId: widget.chat.id,
initialContact:
widget.contacts[widget.chat.ownerId] ??
Contact(
id: 0,
name: widget.chat.displayTitle,
firstName: '',
lastName: '',
),
myId: widget.myId,
),
),
);
},
icon: Icon(Icons.settings, color: colors.primary),
tooltip: 'Настройки группы',
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: colors.onSurfaceVariant),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _showAddParticipantDialog,
icon: const Icon(Icons.person_add),
label: const Text('Добавить участника'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: participants.length,
itemBuilder: (context, index) {
final participant = participants[index];
final isOwner = participant.id == widget.chat.ownerId;
final isMe = participant.id == widget.myId;
return ListTile(
leading: CircleAvatar(
backgroundImage: participant.photoBaseUrl != null
? NetworkImage(participant.photoBaseUrl!)
: null,
child: participant.photoBaseUrl == null
? Text(
participant.name.isNotEmpty
? participant.name[0].toUpperCase()
: '?',
style: TextStyle(color: colors.onPrimaryContainer),
)
: null,
),
title: Row(
children: [
Text(
participant.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
if (isOwner) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Создатель',
style: TextStyle(
color: colors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
if (isMe) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Вы',
style: TextStyle(
color: colors.onSecondary,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
subtitle: Text(
'ID: ${participant.id}',
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 12,
),
),
trailing: isOwner || isMe
? null
: PopupMenuButton<String>(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (value) =>
_handleParticipantAction(participant, value),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(Icons.person_remove, color: Colors.red),
SizedBox(width: 8),
Text('Удалить из группы'),
],
),
),
const PopupMenuItem(
value: 'remove_with_messages',
child: Row(
children: [
Icon(Icons.delete_forever, color: Colors.red),
SizedBox(width: 8),
Text('Удалить с сообщениями'),
],
),
),
],
),
);
},
),
),
],
),
);
}
void _showAddParticipantDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Добавить участника'),
content: const Text('Введите ID пользователя для добавления в группу'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Функция добавления участника в разработке'),
),
);
},
child: const Text('Добавить'),
),
],
),
);
}
Future<void> _handleParticipantAction(
Contact participant,
String action,
) async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
try {
if (action == 'remove') {
await _removeParticipant(participant.id, cleanMessages: false);
} else if (action == 'remove_with_messages') {
await _removeParticipant(participant.id, cleanMessages: true);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
action == 'remove'
? '${participant.name} удален из группы'
: '${participant.name} удален с сообщениями',
),
),
);
widget.onParticipantsChanged?.call();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _removeParticipant(
int userId, {
required bool cleanMessages,
}) async {
print('Удаляем участника $userId, очистка сообщений: $cleanMessages');
_apiService.sendMessage(widget.chat.id, '', replyToMessageId: null);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
class ReconnectionOverlay extends StatelessWidget {
final bool isReconnecting;
final String? message;
const ReconnectionOverlay({
super.key,
required this.isReconnecting,
this.message,
});
@override
Widget build(BuildContext context) {
if (!isReconnecting) {
return const SizedBox.shrink();
}
return Directionality(
textDirection: TextDirection.ltr,
child: Material(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
message ?? 'Переподключение...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Пожалуйста, подождите',
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}
}
class ReconnectionOverlayController {
static final ReconnectionOverlayController _instance =
ReconnectionOverlayController._internal();
factory ReconnectionOverlayController() => _instance;
ReconnectionOverlayController._internal();
bool _isReconnecting = false;
String? _message;
VoidCallback? _onStateChanged;
bool get isReconnecting => _isReconnecting;
String? get message => _message;
void setOnStateChanged(VoidCallback? callback) {
_onStateChanged = callback;
}
void showReconnecting({String? message}) {
_isReconnecting = true;
_message = message;
_onStateChanged?.call();
}
void hideReconnecting() {
_isReconnecting = false;
_message = null;
_onStateChanged?.call();
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:gwid/services/avatar_cache_service.dart';
class UserProfilePanel extends StatefulWidget {
final int userId;
final String? name;
final String? firstName;
final String? lastName;
final String? avatarUrl;
final String? description;
final int myId;
final int? currentChatId;
final Map<String, dynamic>? contactData;
final int? dialogChatId;
const UserProfilePanel({
super.key,
required this.userId,
this.name,
this.firstName,
this.lastName,
this.avatarUrl,
this.description,
required this.myId,
this.currentChatId,
this.contactData,
this.dialogChatId,
});
@override
State<UserProfilePanel> createState() => _UserProfilePanelState();
}
class _UserProfilePanelState extends State<UserProfilePanel> {
final ScrollController _nameScrollController = ScrollController();
String get _displayName {
if (widget.firstName != null || widget.lastName != null) {
final firstName = widget.firstName ?? '';
final lastName = widget.lastName ?? '';
final fullName = '$firstName $lastName'.trim();
return fullName.isNotEmpty ? fullName : (widget.name ?? 'Неизвестный');
}
return widget.name ?? 'Неизвестный';
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkNameLength();
});
}
void _checkNameLength() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_nameScrollController.hasClients) {
final maxScroll = _nameScrollController.position.maxScrollExtent;
if (maxScroll > 0) {
_startNameScroll();
}
}
});
}
void _startNameScroll() {
if (!_nameScrollController.hasClients) return;
Future.delayed(const Duration(seconds: 2), () {
if (!mounted || !_nameScrollController.hasClients) return;
_nameScrollController
.animateTo(
_nameScrollController.position.maxScrollExtent,
duration: const Duration(seconds: 3),
curve: Curves.easeInOut,
)
.then((_) {
if (!mounted) return;
Future.delayed(const Duration(seconds: 1), () {
if (!mounted || !_nameScrollController.hasClients) return;
_nameScrollController
.animateTo(
0,
duration: const Duration(seconds: 3),
curve: Curves.easeInOut,
)
.then((_) {
if (mounted) {
Future.delayed(const Duration(seconds: 2), () {
if (mounted) _startNameScroll();
});
}
});
});
});
});
}
@override
void dispose() {
_nameScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
AvatarCacheService().getAvatarWidget(
widget.avatarUrl,
userId: widget.userId,
size: 80,
fallbackText: _displayName,
backgroundColor: colors.primaryContainer,
textColor: colors.onPrimaryContainer,
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final textPainter = TextPainter(
text: TextSpan(
text: _displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
maxLines: 1,
textDirection: TextDirection.ltr,
);
textPainter.layout();
final textWidth = textPainter.size.width;
final needsScroll = textWidth > constraints.maxWidth;
if (needsScroll) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkNameLength();
});
return SizedBox(
height: 28,
child: SingleChildScrollView(
controller: _nameScrollController,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Text(
_displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
);
} else {
return Text(
_displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
);
}
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildActionButton(
icon: Icons.phone,
label: 'Позвонить',
onPressed: null,
colors: colors,
),
_buildActionButton(
icon: Icons.person_add,
label: 'В контакты',
onPressed: null,
colors: colors,
),
_buildActionButton(
icon: Icons.message,
label: 'Написать',
onPressed: null,
colors: colors,
),
],
),
if (widget.description != null &&
widget.description!.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
widget.description!,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
),
],
),
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required VoidCallback? onPressed,
required ColorScheme colors,
bool isLoading = false,
}) {
return Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.primaryContainer,
shape: BoxShape.circle,
),
child: isLoading
? Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
),
),
)
: IconButton(
icon: Icon(icon, color: colors.primary),
onPressed: onPressed,
),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant),
),
],
);
}
}