feat(socket): Add connectivity and health tracking to socket service

This commit is contained in:
cogwheel0
2025-12-04 15:05:12 +05:30
parent cbbcdd8305
commit 5704c5cf8a
6 changed files with 744 additions and 23 deletions

View File

@@ -1,9 +1,12 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/socket_health.dart';
import '../../../core/services/socket_service.dart';
import '../../../core/services/settings_service.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/theme/tweakcn_themes.dart';
@@ -76,6 +79,8 @@ class AppCustomizationPage extends ConsumerWidget {
_buildTtsDropdownSection(context, ref, settings),
const SizedBox(height: Spacing.xl),
_buildChatSection(context, ref, settings),
const SizedBox(height: Spacing.xl),
_buildSocketHealthSection(context, ref),
],
),
),
@@ -490,6 +495,29 @@ class AppCustomizationPage extends ConsumerWidget {
);
}
Widget _buildSocketHealthSection(BuildContext context, WidgetRef ref) {
final theme = context.conduitTheme;
final socketService = ref.watch(socketServiceProvider);
if (socketService == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connection Health',
style:
theme.headingSmall?.copyWith(color: theme.sidebarForeground) ??
TextStyle(color: theme.sidebarForeground, fontSize: 18),
),
const SizedBox(height: Spacing.sm),
_SocketHealthCard(socketService: socketService),
],
);
}
String _androidAssistantTriggerLabel(
AppLocalizations l10n,
AndroidAssistantTrigger trigger,
@@ -2405,3 +2433,318 @@ class _ExpandableCardState extends State<_ExpandableCard>
);
}
}
/// Widget that displays socket connection health with real-time updates.
class _SocketHealthCard extends StatefulWidget {
const _SocketHealthCard({required this.socketService});
final SocketService socketService;
@override
State<_SocketHealthCard> createState() => _SocketHealthCardState();
}
class _SocketHealthCardState extends State<_SocketHealthCard> {
SocketHealth? _health;
StreamSubscription<SocketHealth>? _subscription;
@override
void initState() {
super.initState();
_initHealth();
}
void _initHealth() {
_health = widget.socketService.currentHealth;
_subscription = widget.socketService.healthStream.listen((health) {
if (mounted) {
setState(() => _health = health);
}
});
}
@override
void didUpdateWidget(covariant _SocketHealthCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.socketService != widget.socketService) {
_subscription?.cancel();
_initHealth();
}
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final health = _health;
if (health == null) {
return ConduitCard(
padding: const EdgeInsets.all(Spacing.md),
child: Row(
children: [
Icon(
Icons.cloud_off,
color: theme.iconSecondary,
size: IconSize.medium,
),
const SizedBox(width: Spacing.md),
Text(
'Not connected',
style: theme.bodyMedium?.copyWith(color: theme.textSecondary),
),
],
),
);
}
final statusColor = health.isConnected ? theme.success : theme.error;
final qualityColor = _getQualityColor(theme, health.quality);
return ConduitCard(
padding: const EdgeInsets.all(Spacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Connection Status Row
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: statusColor.withValues(alpha: 0.2),
width: BorderWidth.thin,
),
),
alignment: Alignment.center,
child: Icon(
health.isConnected ? Icons.cloud_done : Icons.cloud_off,
color: statusColor,
size: IconSize.medium,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
health.isConnected ? 'Connected' : 'Disconnected',
style: theme.bodyMedium?.copyWith(
color: theme.sidebarForeground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Spacing.xxs),
Text(
_getTransportLabel(health.transport),
style: theme.bodySmall?.copyWith(
color: theme.sidebarForeground.withValues(alpha: 0.75),
),
),
],
),
),
// Connection quality indicator
if (health.isConnected && health.hasLatencyInfo)
Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: qualityColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: qualityColor.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Text(
_getQualityLabel(health.quality),
style: theme.bodySmall?.copyWith(
color: qualityColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (health.isConnected) ...[
const SizedBox(height: Spacing.md),
const Divider(height: 1),
const SizedBox(height: Spacing.md),
// Metrics Grid
Row(
children: [
Expanded(
child: _MetricTile(
icon: Icons.speed,
label: 'Latency',
value: health.hasLatencyInfo
? '${health.latencyMs}ms'
: '',
color: qualityColor,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: _MetricTile(
icon: Icons.refresh,
label: 'Reconnects',
value: '${health.reconnectCount}',
color: health.reconnectCount > 0
? theme.warning
: theme.success,
),
),
],
),
if (health.lastHeartbeat != null) ...[
const SizedBox(height: Spacing.md),
Row(
children: [
Icon(
Icons.favorite,
color: theme.error.withValues(alpha: 0.7),
size: IconSize.small,
),
const SizedBox(width: Spacing.xs),
Text(
'Last heartbeat: ${_formatLastHeartbeat(health.lastHeartbeat!)}',
style: theme.bodySmall?.copyWith(
color: theme.sidebarForeground.withValues(alpha: 0.6),
),
),
],
),
],
],
],
),
);
}
String _getTransportLabel(String transport) {
switch (transport) {
case 'websocket':
return 'WebSocket transport';
case 'polling':
return 'HTTP polling transport';
default:
return 'Unknown transport';
}
}
String _getQualityLabel(String quality) {
switch (quality) {
case 'excellent':
return 'Excellent';
case 'good':
return 'Good';
case 'fair':
return 'Fair';
case 'poor':
return 'Poor';
default:
return '';
}
}
Color _getQualityColor(ConduitThemeExtension theme, String quality) {
switch (quality) {
case 'excellent':
return theme.success;
case 'good':
return theme.success.withValues(alpha: 0.8);
case 'fair':
return theme.warning;
case 'poor':
return theme.error;
default:
return theme.textSecondary;
}
}
String _formatLastHeartbeat(DateTime lastHeartbeat) {
final now = DateTime.now();
final diff = now.difference(lastHeartbeat);
if (diff.inSeconds < 5) {
return 'just now';
} else if (diff.inSeconds < 60) {
return '${diff.inSeconds}s ago';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes}m ago';
} else {
return '${diff.inHours}h ago';
}
}
}
class _MetricTile extends StatelessWidget {
const _MetricTile({
required this.icon,
required this.label,
required this.value,
required this.color,
});
final IconData icon;
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: theme.cardBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: theme.cardBorder.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Row(
children: [
Icon(icon, color: color, size: IconSize.small),
const SizedBox(width: Spacing.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.bodySmall?.copyWith(
color: theme.textSecondary,
fontSize: 10,
),
),
Text(
value,
style: theme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
}