Billing Stripe, link regia mobile e staff solo trasmissione.
Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@ class ArchiveScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final teamAsync = ref.watch(primaryTeamProvider);
|
||||
final teamAsync = ref.watch(activeTeamProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -14,8 +14,8 @@ import '../../platform/streaming_channel.dart';
|
||||
import '../../platform/streaming_preview.dart';
|
||||
import '../../providers/match_rules_provider.dart';
|
||||
import '../../providers/score_provider.dart';
|
||||
import '../shared/live_score_actions.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/regia_share.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/websocket_service.dart';
|
||||
@@ -120,8 +120,6 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
|
||||
final match = await client.fetchMatch(session.matchId);
|
||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
|
||||
|
||||
if (session.score != null) {
|
||||
ref.read(scoreProvider.notifier).reset(session.score!);
|
||||
}
|
||||
@@ -223,60 +221,14 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
|
||||
void _syncScore() {
|
||||
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
|
||||
}
|
||||
|
||||
Future<void> _homePoint() async {
|
||||
ref.read(scoreProvider.notifier).incrementHome();
|
||||
_syncScore();
|
||||
Future<void> _shareRegia() async {
|
||||
final match = ref.read(sessionProvider).match;
|
||||
await LiveScoreActions(ref).afterPointChange(
|
||||
await shareRegiaLink(
|
||||
context,
|
||||
homeName: match?.teamName ?? 'Casa',
|
||||
awayName: match?.opponentName ?? 'Ospiti',
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _closeSet() async {
|
||||
await LiveScoreActions(ref).requestCloseSet(
|
||||
context,
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
ref,
|
||||
sessionId: widget.sessionId,
|
||||
shareSubject:
|
||||
'Regia — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -390,11 +342,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bitrateMbps: _bitrateMbps,
|
||||
fps: _fps,
|
||||
viewerCount: _viewerCount,
|
||||
onHomePoint: () => _homePoint(),
|
||||
onAwayPoint: () => _awayPoint(),
|
||||
onHomeMinus: _homeMinus,
|
||||
onAwayMinus: _awayMinus,
|
||||
onCloseSet: () => _closeSet(),
|
||||
onShareRegia: _shareRegia,
|
||||
onInterrupt: _interruptStream,
|
||||
onCloseStream: _closeStreamPermanently,
|
||||
pointsTarget: pointsTarget,
|
||||
@@ -410,11 +358,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bitrateMbps: _bitrateMbps,
|
||||
fps: _fps,
|
||||
viewerCount: _viewerCount,
|
||||
onHomePoint: () => _homePoint(),
|
||||
onAwayPoint: () => _awayPoint(),
|
||||
onHomeMinus: _homeMinus,
|
||||
onAwayMinus: _awayMinus,
|
||||
onCloseSet: () => _closeSet(),
|
||||
onShareRegia: _shareRegia,
|
||||
onInterrupt: _interruptStream,
|
||||
onCloseStream: _closeStreamPermanently,
|
||||
pointsTarget: pointsTarget,
|
||||
@@ -436,11 +380,7 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
required this.bitrateMbps,
|
||||
required this.fps,
|
||||
required this.viewerCount,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onShareRegia,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
@@ -455,11 +395,7 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
final double bitrateMbps;
|
||||
final int fps;
|
||||
final int viewerCount;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onShareRegia;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
@@ -476,6 +412,11 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
children: [
|
||||
LiveBadge(elapsed: elapsed),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: onShareRegia,
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: 'Condividi link regia',
|
||||
),
|
||||
CameraCompactMetrics(
|
||||
networkType: networkType,
|
||||
bitrateMbps: bitrateMbps,
|
||||
@@ -507,13 +448,14 @@ class _PortraitOverlay extends StatelessWidget {
|
||||
homeSets: score.homeSets,
|
||||
awaySets: score.awaySets,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: onHomePoint,
|
||||
onAwayPoint: onAwayPoint,
|
||||
onHomeMinus: onHomeMinus,
|
||||
onAwayMinus: onAwayMinus,
|
||||
onCloseSet: onCloseSet,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onShareRegia,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text('Condividi link regia'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
child: const Text('Interrompi (riprendibile)'),
|
||||
@@ -543,11 +485,7 @@ class _LandscapeOverlay extends StatelessWidget {
|
||||
required this.bitrateMbps,
|
||||
required this.fps,
|
||||
required this.viewerCount,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onShareRegia,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
@@ -563,11 +501,7 @@ class _LandscapeOverlay extends StatelessWidget {
|
||||
final double bitrateMbps;
|
||||
final int fps;
|
||||
final int viewerCount;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onShareRegia;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
@@ -618,15 +552,15 @@ class _LandscapeOverlay extends StatelessWidget {
|
||||
awaySets: score.awaySets,
|
||||
lastDelta: lastDelta,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: onHomePoint,
|
||||
onAwayPoint: onAwayPoint,
|
||||
onHomeMinus: onHomeMinus,
|
||||
onAwayMinus: onAwayMinus,
|
||||
onCloseSet: onCloseSet,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onShareRegia,
|
||||
icon: const Icon(Icons.share, color: Colors.white),
|
||||
tooltip: 'Condividi link regia',
|
||||
),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/connected_badge.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import 'controller_screen.dart';
|
||||
|
||||
/// Layout gamepad a 3 colonne per uso landscape.
|
||||
class ControllerLandscape extends StatelessWidget {
|
||||
const ControllerLandscape({
|
||||
super.key,
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.sessionState,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final SessionState sessionState;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamColumn(
|
||||
teamLabel: 'CASA',
|
||||
teamName: homeName,
|
||||
sets: score.homeSets,
|
||||
points: score.homePoints,
|
||||
onPoint: onHomePoint,
|
||||
onMinus: onHomeMinus,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('REGIA', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(width: 12),
|
||||
ConnectedBadge(connected: sessionState.connected),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'SET ${score.currentSet} → $pointsTarget pt',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.accentYellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet),
|
||||
const SizedBox(height: 16),
|
||||
Text('ULTIME AZIONI', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
StreamMetricsFooter(
|
||||
elapsed: sessionState.elapsed,
|
||||
connected: sessionState.connected,
|
||||
cameraDevice: sessionState.cameraDevice,
|
||||
viewerCount: sessionState.viewerCount,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
child: const Text('Interrompi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DestructiveCta(
|
||||
label: 'Chiudi',
|
||||
compact: true,
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamColumn(
|
||||
teamLabel: 'OSPITI',
|
||||
teamName: awayName,
|
||||
sets: score.awaySets,
|
||||
points: score.awayPoints,
|
||||
onPoint: onAwayPoint,
|
||||
onMinus: onAwayMinus,
|
||||
alignRight: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamColumn extends StatelessWidget {
|
||||
const _TeamColumn({
|
||||
required this.teamLabel,
|
||||
required this.teamName,
|
||||
required this.sets,
|
||||
required this.points,
|
||||
required this.onPoint,
|
||||
required this.onMinus,
|
||||
this.alignRight = false,
|
||||
});
|
||||
|
||||
final String teamLabel;
|
||||
final String teamName;
|
||||
final int sets;
|
||||
final int points;
|
||||
final VoidCallback onPoint;
|
||||
final VoidCallback onMinus;
|
||||
final bool alignRight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final crossAlign =
|
||||
alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: crossAlign,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(teamLabel, style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
teamName.toUpperCase(),
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: alignRight ? TextAlign.right : TextAlign.left,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('SETS $sets', style: theme.textTheme.bodyMedium),
|
||||
Text(
|
||||
'$points',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
fontSize: 56,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ScoreTapButton(label: '+1', onTap: onPoint, large: true),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: points > 0 ? onMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'controller_screen.dart';
|
||||
|
||||
class ControllerPortrait extends StatelessWidget {
|
||||
const ControllerPortrait({
|
||||
super.key,
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.sessionState,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final SessionState sessionState;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final match = sessionState.match;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: ScreenInsets.contentPadding(context, horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (match != null)
|
||||
Text(
|
||||
'${match.sport.toUpperCase()} · ${match.category ?? ''} · ${match.phase ?? ''} · SET ${score.currentSet}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Scoreboard(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
score: score,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: onHomePoint,
|
||||
onAwayPoint: onAwayPoint,
|
||||
onHomeMinus: onHomeMinus,
|
||||
onAwayMinus: onAwayMinus,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet),
|
||||
const SizedBox(height: 16),
|
||||
StreamMetricsFooter(
|
||||
elapsed: sessionState.elapsed,
|
||||
connected: sessionState.connected,
|
||||
cameraDevice: sessionState.cameraDevice,
|
||||
viewerCount: sessionState.viewerCount,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
child: const Text('Interrompi (riprendibile)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DestructiveCta(
|
||||
label: 'Chiudi diretta',
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scoreboard extends StatelessWidget {
|
||||
const _Scoreboard({
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.pointsTarget,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final int pointsTarget;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(homeName.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
'SETS ${score.homeSets}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'${score.homePoints}',
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
),
|
||||
Text('→ $pointsTarget pt', style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 8),
|
||||
ScoreTapButton(label: '+1', onTap: onHomePoint),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedButton(
|
||||
onPressed: score.homePoints > 0 ? onHomeMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'-',
|
||||
style: theme.textTheme.headlineLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(awayName.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
'SETS ${score.awaySets}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'${score.awayPoints}',
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ScoreTapButton(label: '+1', onTap: onAwayPoint),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedButton(
|
||||
onPressed: score.awayPoints > 0 ? onAwayMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
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<ControllerScreen> createState() => _ControllerScreenState();
|
||||
}
|
||||
|
||||
class _ControllerScreenState extends ConsumerState<ControllerScreen> {
|
||||
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<void> _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<void> _fetchStats() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final stats = await client.youtubeStats(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _lockLandscape() async {
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _unlockOrientation() async {
|
||||
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
|
||||
Future<void> _leaveSession() async {
|
||||
try {
|
||||
await ref.read(websocketServiceProvider).disconnect();
|
||||
} catch (_) {}
|
||||
if (mounted) context.go('/matches');
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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<void> _closeSet() async {
|
||||
await LiveScoreActions(ref).requestCloseSet(
|
||||
context,
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,26 +14,9 @@ import '../../shared/widgets/screen_insets.dart';
|
||||
import 'resume_session_sheet.dart';
|
||||
import 'no_team_onboarding.dart';
|
||||
import 'schedule_match_sheet.dart';
|
||||
import 'team_picker.dart';
|
||||
import 'team_providers.dart';
|
||||
|
||||
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isAuthenticated) return [];
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) return [];
|
||||
final client = ref.read(apiClientProvider);
|
||||
final list = await client.fetchMatches(teams.first.id);
|
||||
list.sort((a, b) {
|
||||
final sa = a.scheduledAt;
|
||||
final sb = b.scheduledAt;
|
||||
if (sa == null && sb == null) return 0;
|
||||
if (sa == null) return 1;
|
||||
if (sb == null) return -1;
|
||||
return sa.compareTo(sb);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
class MatchesScreen extends ConsumerWidget {
|
||||
const MatchesScreen({super.key});
|
||||
|
||||
@@ -54,7 +37,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
tooltip: 'Gestisci staff',
|
||||
onPressed: () async {
|
||||
final team = await ref.read(primaryTeamProvider.future);
|
||||
final team = await ref.read(activeTeamProvider.future);
|
||||
final url = team?.staffManageUrl ?? team?.billingUrl;
|
||||
if (url != null && await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
@@ -126,6 +109,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
return RefreshIndicator(
|
||||
color: AppTheme.primaryRed,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(teamsProvider);
|
||||
ref.invalidate(matchesProvider);
|
||||
await ref.read(matchesProvider.future);
|
||||
},
|
||||
@@ -146,6 +130,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
'Le tue partite',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const TeamPickerBar(),
|
||||
if (activeMatch != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Material(
|
||||
@@ -315,8 +300,8 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) {
|
||||
final team = await ref.read(activeTeamProvider.future);
|
||||
if (team == null) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nessuna squadra disponibile')),
|
||||
@@ -325,12 +310,12 @@ class MatchesScreen extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = await showScheduleMatchSheet(context);
|
||||
final data = await showScheduleMatchSheet(context, teamName: team.name);
|
||||
if (data == null || !context.mounted) return;
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final match = await client.createMatch(teams.first.id, {
|
||||
final match = await client.createMatch(team.id, {
|
||||
'opponent_name': data.opponentName,
|
||||
'scheduled_at': data.scheduledAt.toUtc().toIso8601String(),
|
||||
if (data.location != null) 'location': data.location,
|
||||
@@ -350,7 +335,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
backgroundColor: AppTheme.surface,
|
||||
title: const Text('Configurare ora?'),
|
||||
content: const Text(
|
||||
'Puoi preparare telefono e regia subito, oppure tornare quando sei in palestra.',
|
||||
'Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -378,8 +363,8 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _createMatchQuick(BuildContext context, WidgetRef ref) async {
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) {
|
||||
final team = await ref.read(activeTeamProvider.future);
|
||||
if (team == null) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nessuna squadra disponibile')),
|
||||
@@ -389,7 +374,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
final match = await client.createMatch(teams.first.id, {
|
||||
final match = await client.createMatch(team.id, {
|
||||
'opponent_name': 'Avversario',
|
||||
'sport': 'volleyball',
|
||||
'sets_to_win': 3,
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
import '../../shared/regia_share.dart';
|
||||
|
||||
/// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.).
|
||||
void showResumeSessionSheet(
|
||||
@@ -51,7 +51,6 @@ void showResumeSessionSheet(
|
||||
if (match.canResumeCamera)
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
|
||||
Navigator.pop(ctx);
|
||||
context.push('/session/$sessionId/camera');
|
||||
},
|
||||
@@ -76,12 +75,16 @@ void showResumeSessionSheet(
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller);
|
||||
Navigator.pop(ctx);
|
||||
context.push('/session/$sessionId/controller');
|
||||
shareRegiaLink(
|
||||
ctx,
|
||||
ref,
|
||||
sessionId: sessionId,
|
||||
shareSubject:
|
||||
'Regia — ${match.teamName} vs ${match.opponentName}',
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.sports_esports),
|
||||
label: const Text('Apri regia (punteggio)'),
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Condividi link regia'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
|
||||
@@ -16,7 +16,10 @@ class ScheduleMatchResult {
|
||||
final String? location;
|
||||
}
|
||||
|
||||
Future<ScheduleMatchResult?> showScheduleMatchSheet(BuildContext context) {
|
||||
Future<ScheduleMatchResult?> showScheduleMatchSheet(
|
||||
BuildContext context, {
|
||||
String? teamName,
|
||||
}) {
|
||||
return showModalBottomSheet<ScheduleMatchResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -24,12 +27,14 @@ Future<ScheduleMatchResult?> showScheduleMatchSheet(BuildContext context) {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => const _ScheduleMatchSheet(),
|
||||
builder: (ctx) => _ScheduleMatchSheet(teamName: teamName),
|
||||
);
|
||||
}
|
||||
|
||||
class _ScheduleMatchSheet extends StatefulWidget {
|
||||
const _ScheduleMatchSheet();
|
||||
const _ScheduleMatchSheet({this.teamName});
|
||||
|
||||
final String? teamName;
|
||||
|
||||
@override
|
||||
State<_ScheduleMatchSheet> createState() => _ScheduleMatchSheetState();
|
||||
@@ -123,6 +128,16 @@ class _ScheduleMatchSheetState extends State<_ScheduleMatchSheet> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Programma partita', style: theme.textTheme.titleLarge),
|
||||
if (widget.teamName != null && widget.teamName!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.teamName!,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'La diretta non parte ora: comparirà sul sito con data e ora. '
|
||||
|
||||
125
mobile/lib/features/matches/team_picker.dart
Normal file
125
mobile/lib/features/matches/team_picker.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../shared/models/team.dart';
|
||||
import 'team_providers.dart';
|
||||
|
||||
/// Selettore squadra quando l'utente ne gestisce più di una (staff trasmissione / società).
|
||||
class TeamPickerBar extends ConsumerWidget {
|
||||
const TeamPickerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final teamsAsync = ref.watch(teamsProvider);
|
||||
final activeAsync = ref.watch(activeTeamProvider);
|
||||
|
||||
return teamsAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (teams) {
|
||||
if (teams.length <= 1) return const SizedBox.shrink();
|
||||
|
||||
final active = activeAsync.valueOrNull;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Material(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: () => _showTeamSheet(context, ref, teams, active?.id),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.groups_outlined, color: AppTheme.primaryRed, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Squadra per la diretta',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
active?.name ?? 'Seleziona squadra',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (active?.clubName != null && active!.clubName!.isNotEmpty)
|
||||
Text(
|
||||
active.clubName!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand_more, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showTeamSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Team> teams,
|
||||
String? selectedId,
|
||||
) async {
|
||||
final picked = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
'Scegli squadra',
|
||||
style: Theme.of(ctx).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
...teams.map((team) {
|
||||
final selected = team.id == selectedId;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
|
||||
),
|
||||
title: Text(team.name),
|
||||
subtitle: team.clubName != null && team.clubName!.isNotEmpty
|
||||
? Text(team.clubName!)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(ctx, team.id),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked == null) return;
|
||||
await ref.read(selectedTeamIdProvider.notifier).select(picked);
|
||||
ref.invalidate(matchesProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/team_selection_storage.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
import '../../shared/models/recording_item.dart';
|
||||
import '../../shared/models/team.dart';
|
||||
|
||||
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isAuthenticated) return [];
|
||||
final team = await ref.watch(activeTeamProvider.future);
|
||||
if (team == null) return [];
|
||||
final client = ref.read(apiClientProvider);
|
||||
final list = await client.fetchMatches(team.id);
|
||||
list.sort((a, b) {
|
||||
final sa = a.scheduledAt;
|
||||
final sb = b.scheduledAt;
|
||||
if (sa == null && sb == null) return 0;
|
||||
if (sa == null) return 1;
|
||||
if (sb == null) return -1;
|
||||
return sa.compareTo(sb);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
final teamsProvider = FutureProvider<List<Team>>((ref) async {
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isAuthenticated) return [];
|
||||
@@ -12,12 +32,38 @@ final teamsProvider = FutureProvider<List<Team>>((ref) async {
|
||||
return client.fetchTeams();
|
||||
});
|
||||
|
||||
final primaryTeamProvider = FutureProvider<Team?>((ref) async {
|
||||
final selectedTeamIdProvider =
|
||||
AsyncNotifierProvider<SelectedTeamIdNotifier, String?>(SelectedTeamIdNotifier.new);
|
||||
|
||||
class SelectedTeamIdNotifier extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() => TeamSelectionStorage.load();
|
||||
|
||||
Future<void> select(String teamId) async {
|
||||
await TeamSelectionStorage.save(teamId);
|
||||
state = AsyncData(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Squadra attiva: preferisce la selezione salvata, altrimenti la prima disponibile.
|
||||
final activeTeamProvider = FutureProvider<Team?>((ref) async {
|
||||
final teams = await ref.watch(teamsProvider.future);
|
||||
if (teams.isEmpty) return null;
|
||||
return teams.first;
|
||||
|
||||
var selectedId = ref.watch(selectedTeamIdProvider).valueOrNull;
|
||||
final valid = selectedId != null && teams.any((t) => t.id == selectedId);
|
||||
|
||||
if (!valid) {
|
||||
selectedId = teams.first.id;
|
||||
await ref.read(selectedTeamIdProvider.notifier).select(selectedId);
|
||||
}
|
||||
|
||||
return teams.firstWhere((t) => t.id == selectedId);
|
||||
});
|
||||
|
||||
/// Compatibilità con codice esistente.
|
||||
final primaryTeamProvider = activeTeamProvider;
|
||||
|
||||
final teamByIdProvider = FutureProvider.family<Team?, String>((ref, teamId) async {
|
||||
final teams = await ref.watch(teamsProvider.future);
|
||||
for (final t in teams) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../shared/models/match.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/models/stream_session.dart';
|
||||
import '../../shared/websocket_service.dart';
|
||||
import '../../shared/regia_share.dart';
|
||||
import '../../shared/widgets/metric_card.dart';
|
||||
import '../../shared/widgets/primary_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
@@ -111,29 +112,16 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
||||
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);
|
||||
context.go('/session/${started.id}/camera');
|
||||
|
||||
// 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',
|
||||
);
|
||||
await ws.connect(sessionId: started.id, deviceRole: 'camera');
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Diretta avviata ma sync regia limitata: $e',
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text('Diretta avviata ma sync limitata: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,6 +250,20 @@ class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: ref.read(sessionProvider).session == null
|
||||
? null
|
||||
: () => shareRegiaLink(
|
||||
context,
|
||||
ref,
|
||||
sessionId: ref.read(sessionProvider).session!.id,
|
||||
shareSubject:
|
||||
'Regia — ${widget.match.teamName} vs ${widget.match.opponentName}',
|
||||
),
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Condividi link regia (punteggio)'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _testing ? null : _runTest,
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
import '../../shared/widgets/primary_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
|
||||
class StepRoles extends ConsumerStatefulWidget {
|
||||
const StepRoles({
|
||||
super.key,
|
||||
required this.match,
|
||||
required this.onBack,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final MatchModel match;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
ConsumerState<StepRoles> createState() => _StepRolesState();
|
||||
}
|
||||
|
||||
class _StepRolesState extends ConsumerState<StepRoles> {
|
||||
DeviceRole _role = DeviceRole.controller;
|
||||
String? _qrData;
|
||||
bool _loadingQr = false;
|
||||
|
||||
Future<void> _generateQr() async {
|
||||
final session = ref.read(sessionProvider).session;
|
||||
if (session == null) return;
|
||||
|
||||
setState(() => _loadingQr = true);
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.createPairingToken(session.id);
|
||||
setState(() {
|
||||
_qrData = jsonEncode(response.qrPayload);
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Errore generazione QR')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingQr = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
ref.read(sessionProvider.notifier).setDeviceRole(_role);
|
||||
widget.onNext();
|
||||
}
|
||||
|
||||
@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('Scegli il tuo ruolo', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _RoleCard(
|
||||
title: 'CAMERA',
|
||||
subtitle: 'Sul cavalletto',
|
||||
icon: Icons.videocam,
|
||||
selected: _role == DeviceRole.camera,
|
||||
onTap: () => setState(() => _role = DeviceRole.camera),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _RoleCard(
|
||||
title: 'REGIA',
|
||||
subtitle: 'In mano',
|
||||
icon: Icons.sports_esports,
|
||||
selected: _role == DeviceRole.controller,
|
||||
onTap: () => setState(() => _role = DeviceRole.controller),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentYellow.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.accentYellow.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.battery_charging_full, color: AppTheme.accentYellow),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Mantieni in carica. Saranno 90 minuti a pieno carico.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Collega il secondo telefono', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: _qrData != null
|
||||
? QrImageView(
|
||||
data: _qrData!,
|
||||
version: QrVersions.auto,
|
||||
size: 180,
|
||||
backgroundColor: Colors.white,
|
||||
)
|
||||
: SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: _loadingQr
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.qr_code, size: 48, color: Colors.black54),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _generateQr,
|
||||
child: const Text(
|
||||
'GENERA QR',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Scansiona con l\'altro dispositivo per collegare camera e regia.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onBack,
|
||||
child: const Text('Indietro'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryCta(
|
||||
label: 'AVANTI >',
|
||||
onPressed: _confirm,
|
||||
icon: Icons.arrow_forward,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleCard extends StatelessWidget {
|
||||
const _RoleCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: selected
|
||||
? AppTheme.primaryRed.withValues(alpha: 0.15)
|
||||
: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? AppTheme.primaryRed : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 32, color: selected ? AppTheme.primaryRed : Colors.white),
|
||||
const SizedBox(height: 8),
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import '../../shared/api_client.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'step_match.dart';
|
||||
import 'step_network_test.dart';
|
||||
import 'step_roles.dart';
|
||||
import 'step_transmission.dart';
|
||||
|
||||
class WizardShell extends ConsumerStatefulWidget {
|
||||
@@ -33,8 +32,7 @@ class _WizardShellState extends ConsumerState<WizardShell> {
|
||||
static const _stepTitles = [
|
||||
'01 · Partita',
|
||||
'02 · Trasmissione',
|
||||
'03 · Ruoli',
|
||||
'04 · Test',
|
||||
'03 · Test rete',
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -74,8 +72,7 @@ class _WizardShellState extends ConsumerState<WizardShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stepIndex = widget.step.clamp(1, 4) - 1;
|
||||
final stepIndex = widget.step.clamp(1, 3) - 1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -127,15 +124,9 @@ class _WizardShellState extends ConsumerState<WizardShell> {
|
||||
onNext: () => _goToStep(3),
|
||||
);
|
||||
case 2:
|
||||
return StepRoles(
|
||||
match: match,
|
||||
onBack: () => _goToStep(2),
|
||||
onNext: () => _goToStep(4),
|
||||
);
|
||||
case 3:
|
||||
return StepNetworkTest(
|
||||
match: match,
|
||||
onBack: () => _goToStep(3),
|
||||
onBack: () => _goToStep(2),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
@@ -153,13 +144,13 @@ class _StepIndicator extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: List.generate(4, (i) {
|
||||
children: List.generate(3, (i) {
|
||||
final step = i + 1;
|
||||
final active = step <= current;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 4,
|
||||
margin: EdgeInsets.only(right: i < 3 ? 6 : 0),
|
||||
margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
|
||||
Reference in New Issue
Block a user