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:
2026-05-29 07:23:13 +02:00
parent 4083bc5dee
commit f4b7be0f80
156 changed files with 5033 additions and 2033 deletions

View File

@@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import '../features/auth/login_screen.dart';
import '../features/auth/splash_screen.dart';
import '../features/camera_mode/camera_screen.dart';
import '../features/controller_mode/controller_screen.dart';
import '../features/archive/archive_screen.dart';
import '../features/matches/matches_screen.dart';
import '../features/setup_wizard/wizard_shell.dart';
@@ -78,13 +77,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return CameraScreen(sessionId: sessionId);
},
),
GoRoute(
path: '/session/:id/controller',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return ControllerScreen(sessionId: sessionId);
},
),
],
);
});

View File

@@ -0,0 +1,22 @@
import 'package:shared_preferences/shared_preferences.dart';
class TeamSelectionStorage {
static const _keySelectedTeamId = 'selected_team_id';
static Future<String?> load() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getString(_keySelectedTeamId);
if (id == null || id.isEmpty) return null;
return id;
}
static Future<void> save(String teamId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySelectedTeamId, teamId);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keySelectedTeamId);
}
}

View File

@@ -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(

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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. '

View 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);
}
}

View File

@@ -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) {

View File

@@ -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,

View File

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

View File

@@ -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),

View File

@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../core/team_selection_storage.dart';
import '../shared/models/user.dart';
class AuthState {
@@ -82,6 +83,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
void logout() {
state = const AuthState();
AuthStorage.clear();
TeamSelectionStorage.clear();
}
}

View File

@@ -4,53 +4,38 @@ import '../shared/models/device_state.dart';
import '../shared/models/match.dart';
import '../shared/models/stream_session.dart';
enum DeviceRole { camera, controller }
class SessionState {
const SessionState({
this.session,
this.match,
this.connected = false,
this.deviceRole = DeviceRole.controller,
this.cameraDevice,
this.elapsed = Duration.zero,
this.connected = false,
this.cameraDevice,
this.viewerCount = 0,
this.loading = false,
this.error,
});
final StreamSession? session;
final MatchModel? match;
final bool connected;
final DeviceRole deviceRole;
final DeviceStateModel? cameraDevice;
final Duration elapsed;
final bool connected;
final DeviceStateModel? cameraDevice;
final int viewerCount;
final bool loading;
final String? error;
SessionState copyWith({
StreamSession? session,
MatchModel? match,
bool? connected,
DeviceRole? deviceRole,
DeviceStateModel? cameraDevice,
Duration? elapsed,
bool? connected,
DeviceStateModel? cameraDevice,
int? viewerCount,
bool? loading,
String? error,
bool clearError = false,
}) {
return SessionState(
session: session ?? this.session,
match: match ?? this.match,
connected: connected ?? this.connected,
deviceRole: deviceRole ?? this.deviceRole,
cameraDevice: cameraDevice ?? this.cameraDevice,
elapsed: elapsed ?? this.elapsed,
connected: connected ?? this.connected,
cameraDevice: cameraDevice ?? this.cameraDevice,
viewerCount: viewerCount ?? this.viewerCount,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
@@ -59,23 +44,27 @@ class SessionNotifier extends StateNotifier<SessionState> {
SessionNotifier() : super(const SessionState());
void setSession(StreamSession session, {MatchModel? match}) {
state = state.copyWith(
session: session,
match: match,
clearError: true,
);
state = state.copyWith(session: session, match: match);
}
void setMatch(MatchModel match) {
state = state.copyWith(match: match);
}
void setElapsed(Duration elapsed) {
state = state.copyWith(elapsed: elapsed);
}
void setConnected(bool connected) {
state = state.copyWith(connected: connected);
}
void setDeviceRole(DeviceRole role) {
state = state.copyWith(deviceRole: role);
void setCameraDevice(DeviceStateModel? device) {
state = state.copyWith(cameraDevice: device);
}
void setViewerCount(int count) {
state = state.copyWith(viewerCount: count);
}
void updateDeviceState(DeviceStateModel device) {
@@ -83,34 +72,29 @@ class SessionNotifier extends StateNotifier<SessionState> {
state = state.copyWith(cameraDevice: device);
}
if (device.status != null && state.session != null) {
final s = state.session!;
state = state.copyWith(
session: StreamSession(
id: state.session!.id,
matchId: state.session!.matchId,
id: s.id,
matchId: s.matchId,
status: device.status!,
platform: state.session!.platform,
rtmpIngestUrl: state.session!.rtmpIngestUrl,
youtubeBroadcastId: state.session!.youtubeBroadcastId,
privacyStatus: state.session!.privacyStatus,
qualityPreset: state.session!.qualityPreset,
targetBitrate: state.session!.targetBitrate,
startedAt: state.session!.startedAt,
disconnectionCount: state.session!.disconnectionCount,
score: state.session!.score,
devices: state.session!.devices,
platform: s.platform,
rtmpIngestUrl: s.rtmpIngestUrl,
hlsPlaybackUrl: s.hlsPlaybackUrl,
watchPageUrl: s.watchPageUrl,
youtubeBroadcastId: s.youtubeBroadcastId,
privacyStatus: s.privacyStatus,
qualityPreset: s.qualityPreset,
targetBitrate: s.targetBitrate,
startedAt: s.startedAt,
disconnectionCount: s.disconnectionCount,
score: s.score,
devices: s.devices,
),
);
}
}
void setElapsed(Duration elapsed) {
state = state.copyWith(elapsed: elapsed);
}
void setViewerCount(int count) {
state = state.copyWith(viewerCount: count);
}
void applyCommand(String action) {
final session = state.session;
if (session == null) return;
@@ -128,38 +112,35 @@ class SessionNotifier extends StateNotifier<SessionState> {
break;
}
state = state.copyWith(
session: StreamSession(
id: session.id,
matchId: session.matchId,
status: newStatus,
platform: session.platform,
rtmpIngestUrl: session.rtmpIngestUrl,
youtubeBroadcastId: session.youtubeBroadcastId,
privacyStatus: session.privacyStatus,
qualityPreset: session.qualityPreset,
targetBitrate: session.targetBitrate,
startedAt: session.startedAt,
disconnectionCount: session.disconnectionCount,
score: session.score,
devices: session.devices,
),
);
if (newStatus != session.status) {
state = state.copyWith(
session: StreamSession(
id: session.id,
matchId: session.matchId,
status: newStatus,
platform: session.platform,
rtmpIngestUrl: session.rtmpIngestUrl,
hlsPlaybackUrl: session.hlsPlaybackUrl,
watchPageUrl: session.watchPageUrl,
youtubeBroadcastId: session.youtubeBroadcastId,
privacyStatus: session.privacyStatus,
qualityPreset: session.qualityPreset,
targetBitrate: session.targetBitrate,
startedAt: session.startedAt,
disconnectionCount: session.disconnectionCount,
score: session.score,
devices: session.devices,
),
);
}
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void reset() {
void clear() {
state = const SessionState();
}
}
final sessionProvider = StateNotifierProvider<SessionNotifier, SessionState>(
(ref) => SessionNotifier(),
);
final sessionProvider =
StateNotifierProvider<SessionNotifier, SessionState>((ref) {
return SessionNotifier();
});

View File

@@ -176,6 +176,13 @@ class ApiClient {
return StreamSession.fromJson(response.data!);
}
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/regia_link',
);
return RegiaLinkResponse.fromJson(response.data!);
}
Future<PairingTokenResponse> createPairingToken(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/pairing_token',

View File

@@ -74,6 +74,23 @@ class StreamSession {
}
}
class RegiaLinkResponse {
const RegiaLinkResponse({
required this.regiaUrl,
required this.expiresAt,
});
final String regiaUrl;
final DateTime expiresAt;
factory RegiaLinkResponse.fromJson(Map<String, dynamic> json) {
return RegiaLinkResponse(
regiaUrl: json['regia_url'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String),
);
}
}
class PairingTokenResponse {
const PairingTokenResponse({
required this.pairingToken,

View File

@@ -22,12 +22,18 @@ class Team {
this.recordingsEnabled = false,
this.phoneDownloadEnabled = false,
this.youtubeEnabled = false,
this.clubName,
this.staffRole,
this.canStream = true,
this.youtubeMode,
});
final String id;
final String name;
final String sport;
final String? clubName;
final String? staffRole;
final bool canStream;
final String? logoUrl;
final bool youtubeConnected;
final String planSlug;
@@ -54,16 +60,10 @@ class Team {
bool get canDownloadOnPhone => phoneDownloadEnabled;
String get staffLimitLabel {
if (maxStaffTransmission == null && maxStaffRegia == null) {
return 'illimitato';
if (maxStaffTransmission == null) {
return 'trasmissione illimitata';
}
final tx = maxStaffTransmission == null
? 'tx ∞'
: 'tx $staffTransmissionUsed/$maxStaffTransmission';
final rg = maxStaffRegia == null
? 'rg ∞'
: 'rg $staffRegiaUsed/$maxStaffRegia';
return '$tx · $rg';
return 'trasmissione $staffTransmissionUsed/$maxStaffTransmission';
}
factory Team.fromJson(Map<String, dynamic> json) {
@@ -71,6 +71,9 @@ class Team {
id: json['id'] as String,
name: json['name'] as String,
sport: json['sport'] as String? ?? 'volleyball',
clubName: json['club_name'] as String?,
staffRole: json['staff_role'] as String?,
canStream: json['can_stream'] as bool? ?? true,
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import 'api_client.dart';
Future<void> shareRegiaLink(
BuildContext context,
WidgetRef ref, {
required String sessionId,
required String shareSubject,
}) async {
try {
final link = await ref.read(apiClientProvider).createRegiaLink(sessionId);
await Share.share(
'Apri questo link per gestire il punteggio della diretta:\n${link.regiaUrl}',
subject: shareSubject,
);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore generazione link regia')),
);
}
}
}

View File

@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../../features/controller_mode/controller_screen.dart';
import 'score_overlay_bar.dart';
/// Controlli punteggio per overlay camera (portrait e landscape).
/// Punteggio in sola lettura sulla camera (la regia avviene via link web).
class CameraScoreControls extends StatelessWidget {
const CameraScoreControls({
super.key,
@@ -15,11 +13,6 @@ class CameraScoreControls extends StatelessWidget {
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.lastDelta,
this.compact = false,
this.pointsTarget,
@@ -32,85 +25,10 @@ class CameraScoreControls extends StatelessWidget {
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? lastDelta;
final bool compact;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
if (compact) {
return _LandscapeControls(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
);
}
return _PortraitControls(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
);
}
}
class _PortraitControls extends StatelessWidget {
const _PortraitControls({
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
return Column(
@@ -125,179 +43,19 @@ class _PortraitControls extends StatelessWidget {
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
pointsTarget: pointsTarget,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ScoreTapButton(label: '+1 $homeName', onTap: onHomePoint, large: true),
),
const SizedBox(width: 8),
Expanded(
child: ScoreTapButton(label: '+1 $awayName', onTap: onAwayPoint, large: true),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: homePoints > 0 ? onHomeMinus : null,
child: Text('-1 $homeName', style: const TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: awayPoints > 0 ? onAwayMinus : null,
child: Text('-1 $awayName', style: const TextStyle(fontSize: 12)),
),
),
],
),
const SizedBox(height: 8),
YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet),
],
);
}
}
class _LandscapeControls extends StatelessWidget {
const _LandscapeControls({
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.lastDelta,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? lastDelta;
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: true,
compact: compact,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _CompactScoreBtn(label: '+1', sub: homeName, onTap: onHomePoint)),
const SizedBox(width: 6),
Expanded(child: _CompactScoreBtn(label: '+1', sub: awayName, onTap: onAwayPoint)),
const SizedBox(width: 6),
Expanded(
child: _CompactScoreBtn(
label: '-1',
sub: homeName,
onTap: homePoints > 0 ? onHomeMinus : null,
muted: true,
),
),
const SizedBox(width: 6),
Expanded(
child: _CompactScoreBtn(
label: '-1',
sub: awayName,
onTap: awayPoints > 0 ? onAwayMinus : null,
muted: true,
),
),
],
),
const SizedBox(height: 6),
YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet),
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,
),
],
],
);
}
}
class _CompactScoreBtn extends StatelessWidget {
const _CompactScoreBtn({
required this.label,
required this.sub,
required this.onTap,
this.muted = false,
});
final String label;
final String sub;
final VoidCallback? onTap;
final bool muted;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Material(
color: muted
? Colors.black.withValues(alpha: enabled ? 0.65 : 0.35)
: AppTheme.primaryRed.withValues(alpha: enabled ? 0.92 : 0.45),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: enabled ? Colors.white : Colors.white38,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: enabled ? Colors.white70 : Colors.white30,
fontSize: 9,
),
),
],
),
),
),
);
}
}