import 'dart:async'; 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/match_rules_provider.dart'; import '../../providers/score_provider.dart'; import '../../providers/session_provider.dart'; import '../shared/live_score_actions.dart'; import '../../shared/widgets/end_stream_dialog.dart'; import '../../shared/api_client.dart'; import '../../shared/websocket_service.dart'; import '../../shared/widgets/connected_badge.dart'; import '../../shared/widgets/match_live_wordmark.dart'; import '../../shared/widgets/screen_insets.dart'; import 'controller_landscape.dart'; import 'controller_portrait.dart'; class ControllerScreen extends ConsumerStatefulWidget { const ControllerScreen({super.key, required this.sessionId}); final String sessionId; @override ConsumerState createState() => _ControllerScreenState(); } class _ControllerScreenState extends ConsumerState { Timer? _elapsedTimer; Timer? _statsTimer; @override void initState() { super.initState(); _lockLandscape(); _bootstrap(); _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { final session = ref.read(sessionProvider).session; if (session?.startedAt != null) { ref.read(sessionProvider.notifier).setElapsed( DateTime.now().difference(session!.startedAt!), ); } }); _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); } Future _bootstrap() async { try { final client = ref.read(apiClientProvider); final session = await client.fetchSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session); if (session.score != null) { ref.read(scoreProvider.notifier).reset(session.score!); } final ws = ref.read(websocketServiceProvider); await ws.connect(sessionId: widget.sessionId, deviceRole: 'controller'); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Errore connessione sessione')), ); } } } Future _fetchStats() async { try { final client = ref.read(apiClientProvider); final stats = await client.youtubeStats(widget.sessionId); ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers); } catch (_) {} } Future _lockLandscape() async { await SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, DeviceOrientation.portraitUp, ]); } Future _unlockOrientation() async { await SystemChrome.setPreferredOrientations(DeviceOrientation.values); } Future _leaveSession() async { try { await ref.read(websocketServiceProvider).disconnect(); } catch (_) {} if (mounted) context.go('/matches'); } Future _interruptStream() async { try { ref.read(websocketServiceProvider).sendCommand('pause_stream'); await ref.read(apiClientProvider).pauseSession(widget.sessionId); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Pausa: $e')), ); } } await _leaveSession(); } Future _closeStreamPermanently() async { if (!mounted) return; final ok = await confirmEndStream(context); if (!ok || !mounted) return; try { ref.read(websocketServiceProvider).sendCommand('stop_stream'); } catch (_) {} try { await ref.read(apiClientProvider).stopSession(widget.sessionId); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Chiusura: $e')), ); } } await _leaveSession(); } @override void dispose() { _elapsedTimer?.cancel(); _statsTimer?.cancel(); _unlockOrientation(); super.dispose(); } void _syncScore() { ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); } Future _homePoint() async { ref.read(scoreProvider.notifier).incrementHome(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterPointChange( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onCloseSetConfirmed: () => _closeSetConfirmed(), ); } Future _awayPoint() async { ref.read(scoreProvider.notifier).incrementAway(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterPointChange( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onCloseSetConfirmed: () => _closeSetConfirmed(), ); } void _homeMinus() { ref.read(scoreProvider.notifier).decrementHome(); _syncScore(); } void _awayMinus() { ref.read(scoreProvider.notifier).decrementAway(); _syncScore(); } Future _closeSet() async { await LiveScoreActions(ref).requestCloseSet( context, onCloseSetConfirmed: () => _closeSetConfirmed(), ); } Future _closeSetConfirmed() async { ref.read(scoreProvider.notifier).closeSet(); _syncScore(); final match = ref.read(sessionProvider).match; await LiveScoreActions(ref).afterCloseSet( context, homeName: match?.teamName ?? 'Casa', awayName: match?.opponentName ?? 'Ospiti', onStopStream: _closeStreamPermanently, ); } @override Widget build(BuildContext context) { final sessionState = ref.watch(sessionProvider); final score = ref.watch(scoreProvider); final match = sessionState.match; final isLandscape = MediaQuery.orientationOf(context) == Orientation.landscape; final homeName = match?.teamName ?? 'CASA'; final awayName = match?.opponentName ?? 'OSPITI'; final rules = ref.watch(matchScoringRulesProvider); final pointsTarget = rules.pointsTarget(score.currentSet); final body = isLandscape ? ControllerLandscape( homeName: homeName, awayName: awayName, score: score, sessionState: sessionState, pointsTarget: pointsTarget, onHomePoint: () => _homePoint(), onAwayPoint: () => _awayPoint(), onHomeMinus: _homeMinus, onAwayMinus: _awayMinus, onCloseSet: () => _closeSet(), onInterrupt: _interruptStream, onCloseStream: _closeStreamPermanently, ) : ControllerPortrait( homeName: homeName, awayName: awayName, score: score, sessionState: sessionState, pointsTarget: pointsTarget, onHomePoint: () => _homePoint(), onAwayPoint: () => _awayPoint(), onHomeMinus: _homeMinus, onAwayMinus: _awayMinus, onCloseSet: () => _closeSet(), onInterrupt: _interruptStream, onCloseStream: _closeStreamPermanently, ); final inset = ScreenInsets.of(context); return Scaffold( backgroundColor: AppTheme.background, body: Column( children: [ if (!isLandscape) Padding( padding: EdgeInsets.fromLTRB( inset.left, inset.top, inset.right, 8, ), child: Row( children: [ const MatchLiveWordmark(compact: true), const Spacer(), ConnectedBadge(connected: sessionState.connected), ], ), ), Expanded( child: Padding( padding: EdgeInsets.only( left: inset.left, right: inset.right, top: isLandscape ? inset.top : 0, bottom: inset.bottom, ), child: body, ), ), ], ), ); } } /// Pulsante score +1 con stile gamepad. class ScoreTapButton extends StatelessWidget { const ScoreTapButton({ super.key, required this.label, required this.onTap, this.large = false, }); final String label; final VoidCallback onTap; final bool large; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( color: AppTheme.surfaceElevated, borderRadius: BorderRadius.circular(large ? 16 : 12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(large ? 16 : 12), child: Container( width: large ? double.infinity : null, padding: EdgeInsets.symmetric( vertical: large ? 20 : 12, horizontal: large ? 24 : 16, ), child: Text( label, textAlign: TextAlign.center, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, fontSize: large ? 28 : 18, ), ), ), ), ); } } /// CTA gialla per azioni secondarie (PAUSA, CHIUDI SET). class YellowActionButton extends StatelessWidget { const YellowActionButton({ super.key, required this.label, required this.onPressed, }); final String label; final VoidCallback onPressed; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SizedBox( width: double.infinity, child: ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.accentYellow, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: Text( label, style: theme.textTheme.labelLarge?.copyWith(color: Colors.black), ), ), ); } } /// Footer metriche stream (timer, segnale, Mbps, batteria). class StreamMetricsFooter extends StatelessWidget { const StreamMetricsFooter({ super.key, required this.elapsed, required this.connected, this.cameraDevice, this.viewerCount, this.compact = false, }); final Duration elapsed; final bool connected; final dynamic cameraDevice; final int? viewerCount; final bool compact; String _format(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final network = cameraDevice?.networkType ?? '—'; final mbps = cameraDevice?.bitrateMbps != null ? cameraDevice.bitrateMbps.toStringAsFixed(1) : '—'; final battery = cameraDevice?.batteryLevel?.toString() ?? '—'; return Container( padding: EdgeInsets.all(compact ? 8 : 12), decoration: BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.circular(compact ? 8 : 10), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _MetricChip(icon: Icons.timer, label: _format(elapsed)), _MetricChip(icon: Icons.signal_cellular_alt, label: network), _MetricChip(icon: Icons.speed, label: '$mbps Mbps'), _MetricChip(icon: Icons.battery_std, label: '$battery%'), if (viewerCount != null) _MetricChip(icon: Icons.visibility, label: '$viewerCount'), ConnectedBadge(connected: connected, label: compact ? null : 'COLLEGATO'), ], ), ); } } class _MetricChip extends StatelessWidget { const _MetricChip({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: AppTheme.textSecondary), const SizedBox(width: 4), Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 11)), ], ); } }