Aggiunge tabellone punteggio in app senza link regia.

Controlli +1/− e chiudi set in camera e dopo test rete, sync via WebSocket.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 23:19:17 +02:00
parent 8882e7854f
commit 1bd81d84af
4 changed files with 249 additions and 74 deletions

View File

@@ -21,7 +21,7 @@ import '../../shared/models/score_state.dart';
import '../../providers/session_provider.dart'; import '../../providers/session_provider.dart';
import '../../shared/websocket_service.dart'; import '../../shared/websocket_service.dart';
import '../../shared/widgets/camera_compact_metrics.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/destructive_cta.dart';
import '../../shared/widgets/end_stream_dialog.dart'; import '../../shared/widgets/end_stream_dialog.dart';
import '../../shared/widgets/live_badge.dart'; import '../../shared/widgets/live_badge.dart';
@@ -484,15 +484,11 @@ class _PortraitOverlay extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CameraScoreControls( LiveScoreControls(
homeName: homeName, homeName: homeName,
awayName: awayName, awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
pointsTarget: pointsTarget, pointsTarget: pointsTarget,
onStopStream: onCloseStream,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
OutlinedButton.icon( OutlinedButton.icon(
@@ -586,17 +582,13 @@ class _LandscapeOverlay extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CameraScoreControls( LiveScoreControls(
compact: true, compact: true,
homeName: homeName, homeName: homeName,
awayName: awayName, awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
lastDelta: lastDelta, lastDelta: lastDelta,
pointsTarget: pointsTarget, pointsTarget: pointsTarget,
onStopStream: onCloseStream,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(

View File

@@ -16,6 +16,8 @@ import '../../shared/models/stream_session.dart';
import '../../shared/websocket_service.dart'; import '../../shared/websocket_service.dart';
import '../../shared/regia_share.dart'; import '../../shared/regia_share.dart';
import '../../shared/watch_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/metric_card.dart';
import '../../shared/widgets/primary_cta.dart'; import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart'; import '../../shared/widgets/screen_insets.dart';
@@ -44,6 +46,17 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
String _networkType = ''; String _networkType = '';
bool _starting = false; 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<void> _runTest() async { Future<void> _runTest() async {
setState(() { setState(() {
_testing = true; _testing = true;
@@ -87,6 +100,23 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
_networkType = networkLabel; _networkType = networkLabel;
_ready = testResult?.ready ?? upload >= 2.0; _ready = testResult?.ready ?? upload >= 2.0;
}); });
await _ensureScoreboardLink();
}
}
Future<void> _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<StepNetworkTest> {
final session = ref.watch(sessionProvider).session; final session = ref.watch(sessionProvider).session;
final shareUrl = session != null ? watchShareUrl(session) : null; final shareUrl = session != null ? watchShareUrl(session) : null;
final shareTitle = 'Diretta — ${widget.match.teamName} vs ${widget.match.opponentName}'; 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( return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
@@ -268,6 +300,14 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
icon: const Icon(Icons.scoreboard), icon: const Icon(Icons.scoreboard),
label: const Text('Condividi link regia (punteggio)'), 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) ...[ if (_testCompleted) ...[
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -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,
),
],
],
);
}
}

View File

@@ -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<void> Function()? onStopStream;
void _sync(WidgetRef ref) {
if (!ref.read(websocketServiceProvider).isConnected) return;
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
}
Future<void> _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<void> _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<void> _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'),
),
],
);
}
}