diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart index 24feec0..4a3708d 100644 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -21,7 +21,7 @@ import '../../shared/models/score_state.dart'; import '../../providers/session_provider.dart'; import '../../shared/websocket_service.dart'; import '../../shared/widgets/camera_compact_metrics.dart'; -import '../../shared/widgets/camera_score_controls.dart'; +import '../../shared/widgets/live_score_controls.dart'; import '../../shared/widgets/destructive_cta.dart'; import '../../shared/widgets/end_stream_dialog.dart'; import '../../shared/widgets/live_badge.dart'; @@ -484,15 +484,11 @@ class _PortraitOverlay extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - CameraScoreControls( + LiveScoreControls( homeName: homeName, awayName: awayName, - homePoints: score.homePoints, - awayPoints: score.awayPoints, - currentSet: score.currentSet, - homeSets: score.homeSets, - awaySets: score.awaySets, pointsTarget: pointsTarget, + onStopStream: onCloseStream, ), const SizedBox(height: 10), OutlinedButton.icon( @@ -586,17 +582,13 @@ class _LandscapeOverlay extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - CameraScoreControls( + LiveScoreControls( compact: true, homeName: homeName, awayName: awayName, - homePoints: score.homePoints, - awayPoints: score.awayPoints, - currentSet: score.currentSet, - homeSets: score.homeSets, - awaySets: score.awaySets, lastDelta: lastDelta, pointsTarget: pointsTarget, + onStopStream: onCloseStream, ), const SizedBox(height: 8), Row( diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart index cccf7f3..e3e548d 100644 --- a/mobile/lib/features/setup_wizard/step_network_test.dart +++ b/mobile/lib/features/setup_wizard/step_network_test.dart @@ -16,6 +16,8 @@ import '../../shared/models/stream_session.dart'; import '../../shared/websocket_service.dart'; import '../../shared/regia_share.dart'; import '../../shared/watch_share.dart'; +import '../../providers/match_rules_provider.dart'; +import '../../shared/widgets/live_score_controls.dart'; import '../../shared/widgets/metric_card.dart'; import '../../shared/widgets/primary_cta.dart'; import '../../shared/widgets/screen_insets.dart'; @@ -44,6 +46,17 @@ class _StepNetworkTestState extends ConsumerState { String _networkType = '—'; bool _starting = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final session = ref.read(sessionProvider).session; + if (session?.score != null) { + ref.read(scoreProvider.notifier).reset(session!.score!); + } + }); + } + Future _runTest() async { setState(() { _testing = true; @@ -87,6 +100,23 @@ class _StepNetworkTestState extends ConsumerState { _networkType = networkLabel; _ready = testResult?.ready ?? upload >= 2.0; }); + await _ensureScoreboardLink(); + } + } + + Future _ensureScoreboardLink() async { + final session = ref.read(sessionProvider).session; + if (session == null) return; + if (session.score != null) { + ref.read(scoreProvider.notifier).reset(session.score!); + } + try { + await ref.read(websocketServiceProvider).connect( + sessionId: session.id, + deviceRole: 'controller', + ); + } catch (_) { + // Tabellone locale; sync quando la camera si connette. } } @@ -144,6 +174,8 @@ class _StepNetworkTestState extends ConsumerState { final session = ref.watch(sessionProvider).session; final shareUrl = session != null ? watchShareUrl(session) : null; final shareTitle = 'Diretta — ${widget.match.teamName} vs ${widget.match.opponentName}'; + final score = ref.watch(scoreProvider); + final pointsTarget = ref.watch(matchScoringRulesProvider).pointsTarget(score.currentSet); return SingleChildScrollView( padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), @@ -268,6 +300,14 @@ class _StepNetworkTestState extends ConsumerState { icon: const Icon(Icons.scoreboard), label: const Text('Condividi link regia (punteggio)'), ), + const SizedBox(height: 16), + Text('Tabellone', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + LiveScoreControls( + homeName: widget.match.teamName, + awayName: widget.match.opponentName, + pointsTarget: pointsTarget, + ), ], if (_testCompleted) ...[ const SizedBox(height: 16), diff --git a/mobile/lib/shared/widgets/camera_score_controls.dart b/mobile/lib/shared/widgets/camera_score_controls.dart deleted file mode 100644 index f5704bc..0000000 --- a/mobile/lib/shared/widgets/camera_score_controls.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'score_overlay_bar.dart'; - -/// Punteggio in sola lettura sulla camera (la regia avviene via link web). -class CameraScoreControls extends StatelessWidget { - const CameraScoreControls({ - super.key, - required this.homeName, - required this.awayName, - required this.homePoints, - required this.awayPoints, - required this.currentSet, - required this.homeSets, - required this.awaySets, - this.lastDelta, - this.compact = false, - this.pointsTarget, - }); - - final String homeName; - final String awayName; - final int homePoints; - final int awayPoints; - final int currentSet; - final int homeSets; - final int awaySets; - final int? lastDelta; - final bool compact; - final int? pointsTarget; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ScoreOverlayBar( - homeName: homeName, - awayName: awayName, - homePoints: homePoints, - awayPoints: awayPoints, - currentSet: currentSet, - homeSets: homeSets, - awaySets: awaySets, - compact: compact, - lastDelta: lastDelta, - pointsTarget: pointsTarget, - ), - if (!compact) ...[ - const SizedBox(height: 8), - Text( - 'Punteggio: condividi il link regia con chi gestisce il tabellone.', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white54), - textAlign: TextAlign.center, - ), - ], - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/live_score_controls.dart b/mobile/lib/shared/widgets/live_score_controls.dart new file mode 100644 index 0000000..2b774da --- /dev/null +++ b/mobile/lib/shared/widgets/live_score_controls.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../features/shared/live_score_actions.dart'; +import '../../providers/score_provider.dart'; +import '../websocket_service.dart'; +import 'score_overlay_bar.dart'; + +/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia). +class LiveScoreControls extends ConsumerWidget { + const LiveScoreControls({ + super.key, + required this.homeName, + required this.awayName, + this.compact = false, + this.lastDelta, + this.pointsTarget, + this.onStopStream, + }); + + final String homeName; + final String awayName; + final bool compact; + final int? lastDelta; + final int? pointsTarget; + final Future Function()? onStopStream; + + void _sync(WidgetRef ref) { + if (!ref.read(websocketServiceProvider).isConnected) return; + ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); + } + + Future _pointHome(BuildContext context, WidgetRef ref) async { + ref.read(scoreProvider.notifier).incrementHome(); + _sync(ref); + if (!context.mounted) return; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: homeName, + awayName: awayName, + onCloseSetConfirmed: () => _closeSet(context, ref), + ); + } + + Future _pointAway(BuildContext context, WidgetRef ref) async { + ref.read(scoreProvider.notifier).incrementAway(); + _sync(ref); + if (!context.mounted) return; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: homeName, + awayName: awayName, + onCloseSetConfirmed: () => _closeSet(context, ref), + ); + } + + Future _closeSet(BuildContext context, WidgetRef ref) async { + await LiveScoreActions(ref).requestCloseSet( + context, + onCloseSetConfirmed: () async { + ref.read(scoreProvider.notifier).closeSet(); + _sync(ref); + if (!context.mounted) return; + await LiveScoreActions(ref).afterCloseSet( + context, + homeName: homeName, + awayName: awayName, + onStopStream: onStopStream ?? () async {}, + ); + }, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final score = ref.watch(scoreProvider); + final target = pointsTarget ?? 25; + final btnStyle = compact + ? const ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10, vertical: 6)), + minimumSize: WidgetStatePropertyAll(Size(44, 32)), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null; + + Widget teamControls({ + required String label, + required int points, + required VoidCallback onPlus, + required VoidCallback onMinus, + }) { + return Expanded( + child: Column( + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: compact ? Colors.white70 : null, + fontSize: compact ? 10 : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + '$points', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: compact ? Colors.white : AppTheme.primaryRed, + fontWeight: FontWeight.bold, + fontSize: compact ? 22 : null, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + style: btnStyle?.merge(FilledButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + foregroundColor: Colors.white, + )), + onPressed: onPlus, + child: Text(compact ? '+1' : '+1 punto', style: TextStyle(fontSize: compact ? 12 : 13)), + ), + const SizedBox(width: 6), + OutlinedButton( + style: btnStyle, + onPressed: onMinus, + child: Text('−', style: TextStyle(fontSize: compact ? 16 : 18)), + ), + ], + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ScoreOverlayBar( + homeName: homeName, + awayName: awayName, + homePoints: score.homePoints, + awayPoints: score.awayPoints, + currentSet: score.currentSet, + homeSets: score.homeSets, + awaySets: score.awaySets, + compact: compact, + lastDelta: lastDelta, + pointsTarget: pointsTarget, + ), + SizedBox(height: compact ? 8 : 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + teamControls( + label: homeName, + points: score.homePoints, + onPlus: () => _pointHome(context, ref), + onMinus: () { + ref.read(scoreProvider.notifier).decrementHome(); + _sync(ref); + }, + ), + Padding( + padding: EdgeInsets.only(top: compact ? 18 : 24), + child: Text( + '$target pt', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: compact ? Colors.white54 : AppTheme.textSecondary, + ), + ), + ), + teamControls( + label: awayName, + points: score.awayPoints, + onPlus: () => _pointAway(context, ref), + onMinus: () { + ref.read(scoreProvider.notifier).decrementAway(); + _sync(ref); + }, + ), + ], + ), + SizedBox(height: compact ? 6 : 10), + OutlinedButton( + style: compact + ? OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + padding: const EdgeInsets.symmetric(vertical: 8), + ) + : null, + onPressed: () => _closeSet(context, ref), + child: Text(compact ? 'Chiudi set' : 'CHIUDI SET'), + ), + ], + ); + } +}