Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
296 lines
9.6 KiB
Dart
296 lines
9.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../app/theme.dart';
|
|
import '../../providers/score_provider.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../shared/api_client.dart';
|
|
import '../../shared/models/match.dart';
|
|
import '../../shared/models/score_state.dart';
|
|
import '../../shared/models/stream_session.dart';
|
|
import '../../shared/websocket_service.dart';
|
|
import '../../shared/widgets/metric_card.dart';
|
|
import '../../shared/widgets/primary_cta.dart';
|
|
import '../../shared/widgets/screen_insets.dart';
|
|
|
|
class StepNetworkTest extends ConsumerStatefulWidget {
|
|
const StepNetworkTest({
|
|
super.key,
|
|
required this.match,
|
|
required this.onBack,
|
|
});
|
|
|
|
final MatchModel match;
|
|
final VoidCallback onBack;
|
|
|
|
@override
|
|
ConsumerState<StepNetworkTest> createState() => _StepNetworkTestState();
|
|
}
|
|
|
|
class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
|
bool _testing = false;
|
|
bool _ready = false;
|
|
double _downloadMbps = 0;
|
|
double _uploadMbps = 0;
|
|
int _latencyMs = 0;
|
|
String _networkType = '—';
|
|
bool _starting = false;
|
|
|
|
Future<void> _runTest() async {
|
|
setState(() {
|
|
_testing = true;
|
|
_ready = false;
|
|
});
|
|
|
|
final connectivity = await Connectivity().checkConnectivity();
|
|
final networkLabel = _connectivityLabel(connectivity);
|
|
|
|
// Simulazione test rete locale (sostituibile con speed test reale).
|
|
await Future<void>.delayed(const Duration(seconds: 2));
|
|
final rng = Random();
|
|
final download = 8 + rng.nextDouble() * 12;
|
|
final upload = 2 + rng.nextDouble() * 4;
|
|
final latency = 20 + rng.nextInt(80);
|
|
|
|
final session = ref.read(sessionProvider).session;
|
|
NetworkTestResult? testResult;
|
|
if (session != null) {
|
|
try {
|
|
final client = ref.read(apiClientProvider);
|
|
testResult = await client.networkTest(
|
|
sessionId: session.id,
|
|
downloadMbps: download,
|
|
uploadMbps: upload,
|
|
latencyMs: latency,
|
|
networkType: networkLabel,
|
|
);
|
|
} catch (_) {
|
|
testResult = null;
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_testing = false;
|
|
_downloadMbps = download;
|
|
_uploadMbps = upload;
|
|
_latencyMs = latency;
|
|
_networkType = networkLabel;
|
|
_ready = testResult?.ready ?? upload >= 2.0;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _connectivityLabel(List<ConnectivityResult> results) {
|
|
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
|
|
if (results.contains(ConnectivityResult.mobile)) return '4G';
|
|
if (results.contains(ConnectivityResult.ethernet)) return 'Ethernet';
|
|
return 'Sconosciuto';
|
|
}
|
|
|
|
Future<void> _startLive() async {
|
|
final sessionState = ref.read(sessionProvider);
|
|
final session = sessionState.session;
|
|
if (session == null) return;
|
|
|
|
setState(() => _starting = true);
|
|
try {
|
|
final client = ref.read(apiClientProvider);
|
|
final started = await client.startSession(session.id);
|
|
ref.read(sessionProvider.notifier).setSession(started, match: widget.match);
|
|
|
|
if (started.score != null) {
|
|
ref.read(scoreProvider.notifier).reset(started.score!);
|
|
} else {
|
|
ref.read(scoreProvider.notifier).reset(const ScoreState());
|
|
}
|
|
|
|
final role = sessionState.deviceRole;
|
|
final path = role == DeviceRole.camera
|
|
? '/session/${started.id}/camera'
|
|
: '/session/${started.id}/controller';
|
|
|
|
if (!mounted) return;
|
|
context.go(path);
|
|
|
|
// WebSocket in background: non bloccare apertura camera/regia
|
|
try {
|
|
final ws = ref.read(websocketServiceProvider);
|
|
await ws.connect(
|
|
sessionId: started.id,
|
|
deviceRole: role == DeviceRole.camera ? 'camera' : 'controller',
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Diretta avviata ma sync regia limitata: $e',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Errore avvio diretta: $e')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _starting = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return SingleChildScrollView(
|
|
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text('Test rete', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Verifica che la connessione regga l\'upload della diretta.',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
MetricCard(
|
|
label: 'Download',
|
|
value: _testing ? '...' : '${_downloadMbps.toStringAsFixed(1)} Mbps',
|
|
icon: Icons.download,
|
|
),
|
|
const SizedBox(width: 8),
|
|
MetricCard(
|
|
label: 'Upload',
|
|
value: _testing ? '...' : '${_uploadMbps.toStringAsFixed(1)} Mbps',
|
|
icon: Icons.upload,
|
|
highlight: _ready,
|
|
),
|
|
const SizedBox(width: 8),
|
|
MetricCard(
|
|
label: 'Latenza',
|
|
value: _testing ? '...' : '${_latencyMs} ms',
|
|
icon: Icons.speed,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: MetricCard(
|
|
label: 'Tipo rete',
|
|
value: _networkType,
|
|
icon: Icons.signal_cellular_alt,
|
|
expand: false,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
if (_ready) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.successGreen.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: AppTheme.successGreen.withValues(alpha: 0.4)),
|
|
),
|
|
child: Text(
|
|
'PRONTO PER ANDARE IN DIRETTA',
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
color: AppTheme.successGreen,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
if (ref.read(sessionProvider).session?.watchPageUrl != null) ...[
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surfaceElevated,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'Link per gli spettatori',
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
SelectableText(
|
|
ref.read(sessionProvider).session!.watchPageUrl!,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
final url = ref.read(sessionProvider).session!.watchPageUrl!;
|
|
Clipboard.setData(ClipboardData(text: url));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Link copiato')),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.link, size: 18),
|
|
label: const Text('COPIA LINK DIRETTA'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surfaceElevated,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
'In caso di disconnessione, lo stream resta attivo con slate per massimo 5 minuti mentre il telefono riconnette.',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
OutlinedButton(
|
|
onPressed: _testing ? null : _runTest,
|
|
child: Text(_testing ? 'TEST IN CORSO...' : 'AVVIA TEST RETE'),
|
|
),
|
|
const SizedBox(height: 32),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: _starting ? null : widget.onBack,
|
|
child: const Text('Indietro'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 2,
|
|
child: PrimaryCta(
|
|
label: 'INIZIA >',
|
|
loading: _starting,
|
|
onPressed: _ready ? _startLive : null,
|
|
icon: Icons.play_arrow,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|