Initial Commit
This commit is contained in:
4048
lib/widgets/chat_message_bubble.dart
Normal file
4048
lib/widgets/chat_message_bubble.dart
Normal file
File diff suppressed because it is too large
Load Diff
676
lib/widgets/connection_debug_panel.dart
Normal file
676
lib/widgets/connection_debug_panel.dart
Normal 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() {
|
||||
|
||||
}
|
||||
}
|
||||
440
lib/widgets/connection_status_widget.dart
Normal file
440
lib/widgets/connection_status_widget.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
lib/widgets/group_avatars.dart
Normal file
189
lib/widgets/group_avatars.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/widgets/group_header.dart
Normal file
124
lib/widgets/group_header.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
358
lib/widgets/group_management_panel.dart
Normal file
358
lib/widgets/group_management_panel.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
116
lib/widgets/reconnection_overlay.dart
Normal file
116
lib/widgets/reconnection_overlay.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
396
lib/widgets/user_profile_panel.dart
Normal file
396
lib/widgets/user_profile_panel.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user