Initial commit: monorepo Match Live TV.

Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart';
class ArchiveScreen extends ConsumerWidget {
const ArchiveScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamAsync = ref.watch(primaryTeamProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Archivio gare'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'),
),
),
body: teamAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore: $e')),
data: (team) {
if (team == null) {
return const Center(child: Text('Nessuna squadra'));
}
if (!team.canUseRecordings) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Archivio replay con Premium Light o Full',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
context,
message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.',
billingUrl: team.billingUrl,
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
child: const Text('Scopri Premium'),
),
],
),
),
);
}
final recsAsync = ref.watch(recordingsProvider(team.id));
return recsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('$e')),
data: (recs) {
if (recs.isEmpty) {
return const Center(child: Text('Nessuna gara in archivio'));
}
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: recs.length,
itemBuilder: (context, i) {
final r = recs[i];
return Card(
color: AppTheme.surface,
child: ListTile(
title: Text('${r.teamName} vs ${r.opponentName}'),
subtitle: Text(
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '',
),
trailing: const Icon(Icons.play_circle_outline),
onTap: () async {
final url = r.replayUrl;
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: team.canDownloadOnPhone
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
}
},
),
);
},
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../features/matches/matches_screen.dart';
import '../../features/matches/team_providers.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'coach@matchlivetv.test');
final _passwordController = TextEditingController(text: 'password123');
bool _obscure = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final connectivity = await Connectivity().checkConnectivity();
if (connectivity.contains(ConnectivityResult.none)) {
ref.read(authProvider.notifier).setError(
'Nessuna connessione. Disattiva la modalità aereo e verifica WiFi.',
);
return;
}
ref.read(authProvider.notifier).setLoading(true);
try {
final client = ApiClient();
final result = await client.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
ref.read(authProvider.notifier).setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
ref.read(authProvider.notifier).setLoading(false);
if (mounted) context.go('/matches');
return;
} on DioException catch (e) {
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError =>
'Server non raggiungibile. Verifica che il backend sia avviato (docker compose up).',
DioExceptionType.badResponse when e.response?.statusCode == 401 =>
'Email o password non corretti',
_ => 'Errore di rete: ${e.message}',
};
ref.read(authProvider.notifier).setError(message);
} catch (e) {
ref.read(authProvider.notifier).setError('Errore imprevisto: $e');
}
ref.read(authProvider.notifier).setLoading(false);
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final theme = Theme.of(context);
return Scaffold(
body: SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 24, vertical: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
const Center(child: MatchLiveWordmark(showSlogan: true)),
const SizedBox(height: 48),
Text(
'ACCEDI',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Gestisci le dirette della tua squadra',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'coach@squadra.it',
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci la password' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 12),
Text(
auth.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryRed,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
PrimaryCta(
label: 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/auth_provider.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/screen_insets.dart';
/// Splash con wordmark e slogan.
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
await ref.read(authProvider.notifier).restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 800));
if (!mounted) return;
final auth = ref.read(authProvider);
if (auth.isAuthenticated) {
context.go('/matches');
} else {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
body: AppSafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const MatchLiveWordmark(showSlogan: true),
const SizedBox(height: 48),
const CircularProgressIndicator(color: AppTheme.primaryRed),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,652 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:battery_plus/battery_plus.dart';
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 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../app/theme.dart';
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/models/score_state.dart';
import '../../providers/session_provider.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/camera_compact_metrics.dart';
import '../../shared/widgets/camera_score_controls.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/end_stream_dialog.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/screen_insets.dart';
class CameraScreen extends ConsumerStatefulWidget {
const CameraScreen({super.key, required this.sessionId});
final String sessionId;
@override
ConsumerState<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> {
static const _previewKey = ValueKey<String>('camera_streaming_preview');
Timer? _elapsedTimer;
Timer? _telemetryTimer;
Timer? _statsTimer;
int _batteryLevel = 100;
int? _lastDelta;
int _prevHomePoints = 0;
int _prevAwayPoints = 0;
double _bitrateMbps = 0;
int _fps = 0;
String _networkType = '4G';
int _viewerCount = 0;
StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
@override
void initState() {
super.initState();
WakelockPlus.enable();
_lockOrientations();
_bootstrap();
_readBattery();
_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!),
);
}
});
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final orientation = MediaQuery.orientationOf(context);
if (_lastOrientation != null && _lastOrientation != orientation) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await StreamingChannel.bindPreview();
});
}
_lastOrientation = orientation;
}
Future<bool> _ensurePermissions() async {
final camera = await Permission.camera.request();
final mic = await Permission.microphone.request();
if (camera.isGranted && mic.isGranted) return true;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servono permessi fotocamera e microfono per la diretta'),
),
);
}
return false;
}
Future<void> _bootstrap() async {
try {
if (!await _ensurePermissions()) return;
final client = ref.read(apiClientProvider);
var session = await client.fetchSession(widget.sessionId);
if (session.isTerminal) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Questa diretta è già terminata'),
),
);
context.go('/matches');
}
return;
}
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!);
}
// Ripresa dopo crash: la sessione resta connecting/live, riallineiamo lo stato.
if (session.status == 'idle' || session.status == 'paused') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
}
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300));
await StreamingChannel.bindPreview();
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.rtmpIngestUrl != null) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
if (mounted && fresh.canResumeBroadcast) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — stai di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
}
_metricsSub = StreamingChannel.metricsStream
.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e as Map))
.listen((event) {
if (event['type'] != 'metrics') return;
final metrics = Map<String, dynamic>.from(
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
);
setState(() {
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
});
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore streaming: ${e.message ?? e.code}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore avvio camera: $e')),
);
}
}
}
Future<void> _readBattery() async {
final level = await Battery().batteryLevel;
if (mounted) setState(() => _batteryLevel = level);
}
Future<void> _sendTelemetry() async {
try {
await ref.read(apiClientProvider).postTelemetry(
sessionId: widget.sessionId,
deviceRole: 'camera',
batteryLevel: _batteryLevel,
networkType: _networkType,
currentBitrate: (_bitrateMbps * 1_000_000).round(),
fps: _fps,
);
} catch (_) {}
}
Future<void> _fetchStats() async {
try {
final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId);
if (mounted) setState(() => _viewerCount = stats.concurrentViewers);
} catch (_) {}
}
Future<void> _lockOrientations() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
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,
);
}
Future<void> _leaveSession() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(websocketServiceProvider).disconnect();
} catch (_) {}
if (mounted) context.go('/matches');
}
/// Pausa temporanea: si può riprendere la stessa sessione.
Future<void> _interruptStream() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa sessione: $e')),
);
}
}
await _leaveSession();
}
/// Chiusura definitiva: sessione ended, pagina web senza player.
Future<void> _closeStreamPermanently() async {
if (!mounted) return;
final ok = await confirmEndStream(context);
if (!ok || !mounted) return;
try {
await StreamingChannel.stopStream();
} catch (_) {}
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 sessione: $e')),
);
}
}
await _leaveSession();
}
void _trackScoreDelta(ScoreState score) {
if (score.homePoints != _prevHomePoints) {
_lastDelta = score.homePoints - _prevHomePoints;
} else if (score.awayPoints != _prevAwayPoints) {
_lastDelta = score.awayPoints - _prevAwayPoints;
}
_prevHomePoints = score.homePoints;
_prevAwayPoints = score.awayPoints;
}
@override
void dispose() {
_elapsedTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_metricsSub?.cancel();
WakelockPlus.disable();
_unlockOrientation();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
_trackScoreDelta(score);
final match = sessionState.match;
final homeName = match?.teamName ?? 'HOME';
final awayName = match?.opponentName ?? 'AWAY';
final isLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet);
// Preview unica: non viene mai smontata al cambio orientamento.
return Scaffold(
backgroundColor: isLandscape ? Colors.black : AppTheme.background,
body: Stack(
fit: StackFit.expand,
children: [
const Positioned.fill(
child: NativeStreamingPreview(
key: _previewKey,
showGrid: true,
platformLabel: 'LIVE',
),
),
if (isLandscape)
_LandscapeOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
lastDelta: _lastDelta,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
)
else
_PortraitOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
),
],
),
);
}
}
class _PortraitOverlay extends StatelessWidget {
const _PortraitOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
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.onInterrupt,
required this.onCloseStream,
required this.pointsTarget,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
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 onInterrupt;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
),
],
),
),
const Expanded(child: SizedBox.expand()),
Container(
color: AppTheme.background,
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CameraScoreControls(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi (riprendibile)'),
),
const SizedBox(height: 8),
DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
],
),
),
],
);
}
}
class _LandscapeOverlay extends StatelessWidget {
const _LandscapeOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
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.onInterrupt,
required this.onCloseStream,
required this.pointsTarget,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
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 onInterrupt;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, compact: true),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
light: true,
),
],
),
),
const Expanded(child: SizedBox.expand()),
Container(
color: Colors.black.withValues(alpha: 0.82),
padding: EdgeInsets.fromLTRB(
inset.left,
10,
inset.right,
math.max(inset.bottom, 12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CameraScoreControls(
compact: true,
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi', style: TextStyle(fontSize: 11)),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi',
compact: true,
onPressed: () => onCloseStream(),
),
),
],
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenLandscape extends StatelessWidget {
const CameraScreenLandscape({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
NativeStreamingPreview(showGrid: false, platformLabel: platformLabel),
Positioned(
top: 12,
left: 12,
child: LiveBadge(elapsed: elapsed, compact: true),
),
Positioned(
top: 12,
right: 12,
child: Row(
children: [
Icon(Icons.battery_std, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Text(
'$batteryLevel%',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
Positioned(
bottom: 16,
left: 16,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
compact: true,
lastDelta: lastDelta,
),
),
Positioned(
bottom: 16,
left: 0,
right: 100,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'fps',
value: '$fps',
icon: Icons.movie,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Spett.',
value: viewerCount > 0 ? '$viewerCount' : 'LIVE',
icon: Icons.visibility,
expand: false,
),
],
),
),
),
Positioned(
bottom: 16,
right: 16,
child: DestructiveCta(
label: 'INTERROMPI',
compact: true,
onPressed: onStop,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenPortrait extends StatelessWidget {
const CameraScreenPortrait({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
required this.sessionId,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String sessionId;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: AppTheme.background,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
const Spacer(),
const Icon(Icons.battery_std, size: 18, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text('$batteryLevel%', style: theme.textTheme.labelLarge),
],
),
),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: NativeStreamingPreview(
showGrid: true,
platformLabel: platformLabel,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
),
const SizedBox(width: 8),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
),
const SizedBox(width: 8),
MetricCard(label: 'fps', value: '$fps', icon: Icons.movie),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.play_circle, color: AppTheme.primaryRed),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(platformLabel, style: theme.textTheme.titleMedium),
Text(
viewerCount > 0
? '$viewerCount spettatori'
: 'IN DIRETTA',
style: theme.textTheme.bodyMedium,
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Registrazione locale attiva: /sd/match_$sessionId',
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
textAlign: TextAlign.center,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: DestructiveCta(label: 'INTERROMPI', onPressed: onStop),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
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

@@ -0,0 +1,187 @@
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

@@ -0,0 +1,424 @@
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

@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
import 'resume_session_sheet.dart';
import 'no_team_onboarding.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);
return client.fetchMatches(teams.first.id);
});
class MatchesScreen extends ConsumerWidget {
const MatchesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final teamsAsync = ref.watch(teamsProvider);
final matchesAsync = ref.watch(matchesProvider);
final theme = Theme.of(context);
final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT');
final bottomInset = ScreenInsets.of(context).bottom;
return Scaffold(
appBar: AppBar(
title: const MatchLiveWordmark(compact: true),
actions: [
IconButton(
icon: const Icon(Icons.group_outlined),
tooltip: 'Gestisci staff',
onPressed: () async {
final team = await ref.read(primaryTeamProvider.future);
final url = team?.staffManageUrl ?? team?.billingUrl;
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
},
),
IconButton(
icon: const Icon(Icons.video_library_outlined),
tooltip: 'Archivio',
onPressed: () => context.push('/archive'),
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Esci',
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
],
),
body: teamsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore squadre: $e')),
data: (teams) {
if (teams.isEmpty) {
return NoTeamOnboarding(theme: theme);
}
return matchesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Errore nel caricamento', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
e.toString(),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
},
child: const Text('RIPROVA'),
),
],
),
),
),
data: (matches) {
MatchModel? activeMatch;
for (final m in matches) {
if (m.hasActiveSession) {
activeMatch = m;
break;
}
}
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async {
ref.invalidate(matchesProvider);
await ref.read(matchesProvider.future);
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ciao, ${auth.user?.name ?? ''}',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
'Le tue partite',
style: theme.textTheme.bodyMedium,
),
if (activeMatch != null) ...[
const SizedBox(height: 12),
Material(
color: AppTheme.primaryRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => showResumeSessionSheet(
context,
ref,
match: activeMatch!,
),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
Icons.videocam,
color: AppTheme.primaryRed,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Riprendi diretta in corso',
style: theme.textTheme.titleSmall
?.copyWith(
color: AppTheme.primaryRed,
fontWeight: FontWeight.w700,
),
),
Text(
'${activeMatch.teamName} vs ${activeMatch.opponentName}',
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppTheme.primaryRed,
),
],
),
),
),
),
],
],
),
),
),
if (matches.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Nessuna partita programmata',
style: theme.textTheme.bodyMedium,
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final match = matches[index];
return _MatchCard(
match: match,
dateFormat: dateFormat,
onTap: () {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
},
onDelete: match.canResumeCamera
? null
: () => _deleteMatch(context, ref, match),
);
},
childCount: matches.length,
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset),
child: PrimaryCta(
label: 'NUOVA PARTITA',
icon: Icons.add,
onPressed: () => _createMatch(context, ref),
),
),
),
],
),
);
},
);
},
),
);
}
Future<void> _deleteMatch(
BuildContext context,
WidgetRef ref,
MatchModel match,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Elimina partita'),
content: Text(
'Eliminare «${match.teamName} vs ${match.opponentName}»?\n\n'
'L\'operazione non si può annullare.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Elimina'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(apiClientProvider).deleteMatch(match.id);
ref.invalidate(matchesProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partita eliminata')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossibile eliminare: $e')),
);
}
}
}
Future<void> _createMatch(BuildContext context, WidgetRef ref) async {
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
);
}
return;
}
final client = ref.read(apiClientProvider);
final match = await client.createMatch(teams.first.id, {
'opponent_name': 'Avversario',
'sport': 'volleyball',
'sets_to_win': 3,
});
if (context.mounted) context.push('/setup/${match.id}/1');
}
}
class _MatchCard extends StatelessWidget {
const _MatchCard({
required this.match,
required this.dateFormat,
required this.onTap,
this.onDelete,
});
final MatchModel match;
final DateFormat dateFormat;
final VoidCallback onTap;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasSession = match.hasActiveSession;
final canResume = match.canResumeCamera;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${match.teamName} vs ${match.opponentName}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
if (match.scheduledAt != null)
Text(
dateFormat.format(match.scheduledAt!.toLocal()),
style: theme.textTheme.bodyMedium,
),
if (match.location != null && match.location!.isNotEmpty)
Text(
match.location!,
style: theme.textTheme.bodyMedium,
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: hasSession
? AppTheme.primaryRed.withValues(alpha: 0.2)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(8),
),
child: Text(
canResume ? 'RIPRENDI' : (hasSession ? 'IN CORSO' : 'CONFIGURA'),
style: theme.textTheme.labelLarge?.copyWith(
color: hasSession ? AppTheme.primaryRed : AppTheme.textSecondary,
fontSize: 11,
),
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline, color: AppTheme.textSecondary),
tooltip: 'Elimina partita',
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../core/config.dart';
import '../../shared/widgets/primary_cta.dart';
class NoTeamOnboarding extends StatelessWidget {
const NoTeamOnboarding({required this.theme});
final ThemeData theme;
String get _signupUrl {
final base = AppConfig.apiBaseUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
return '$base/signup';
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.groups_outlined, size: 64, color: AppTheme.textSecondary),
const SizedBox(height: 16),
Text(
'Completa l\'iscrizione sul sito',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Crea la tua squadra e scegli il piano Free o Premium da browser. '
'Poi torna qui con le stesse credenziali.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
PrimaryCta(
label: 'VAI AL SITO',
icon: Icons.open_in_new,
onPressed: () async {
final uri = Uri.parse(_signupUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
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';
/// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.).
void showResumeSessionSheet(
BuildContext context,
WidgetRef ref, {
required MatchModel match,
}) {
final sessionId = match.activeSessionId;
if (sessionId == null) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Diretta in corso',
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${match.teamName} vs ${match.opponentName}',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (match.activeSessionStatus != null) ...[
const SizedBox(height: 8),
Text(
'Stato: ${match.activeSessionStatus}',
style: Theme.of(ctx).textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
),
),
],
const SizedBox(height: 20),
if (match.canResumeCamera)
FilledButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
Navigator.pop(ctx);
context.push('/session/$sessionId/camera');
},
icon: const Icon(Icons.videocam),
label: const Text('Riprendi camera e trasmetti'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
if (match.activeSessionStatus == 'idle') ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
context.push('/setup/${match.id}/1');
},
icon: const Icon(Icons.settings),
label: const Text('Continua configurazione'),
),
],
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller);
Navigator.pop(ctx);
context.push('/session/$sessionId/controller');
},
icon: const Icon(Icons.sports_esports),
label: const Text('Apri regia (punteggio)'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annulla'),
),
],
),
),
);
},
);
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/models/team.dart';
final teamsProvider = FutureProvider<List<Team>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final client = ref.read(apiClientProvider);
return client.fetchTeams();
});
final primaryTeamProvider = FutureProvider<Team?>((ref) async {
final teams = await ref.watch(teamsProvider.future);
if (teams.isEmpty) return null;
return teams.first;
});
final teamByIdProvider = FutureProvider.family<Team?, String>((ref, teamId) async {
final teams = await ref.watch(teamsProvider.future);
for (final t in teams) {
if (t.id == teamId) return t;
}
return null;
});
final recordingsProvider = FutureProvider.family<List<RecordingItem>, String>((ref, teamId) async {
final team = await ref.watch(teamByIdProvider(teamId).future);
if (team == null || !team.recordingsEnabled) return [];
final client = ref.read(apiClientProvider);
return client.fetchRecordings(teamId);
});

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepMatch extends ConsumerStatefulWidget {
const StepMatch({
super.key,
required this.match,
required this.onNext,
});
final MatchModel match;
final VoidCallback onNext;
@override
ConsumerState<StepMatch> createState() => _StepMatchState();
}
class _StepMatchState extends ConsumerState<StepMatch> {
late final TextEditingController _opponentController;
late final TextEditingController _locationController;
late final TextEditingController _categoryController;
late final TextEditingController _phaseController;
late final TextEditingController _rosterController;
int _setsToWin = 3;
DateTime? _scheduledAt;
bool _saving = false;
bool _customRules = false;
int _pointsPerSet = 25;
int _pointsDecidingSet = 15;
int _minPointLead = 2;
@override
void initState() {
super.initState();
_opponentController = TextEditingController(text: widget.match.opponentName);
_locationController = TextEditingController(text: widget.match.location ?? '');
_categoryController = TextEditingController(text: widget.match.category ?? '');
_phaseController = TextEditingController(text: widget.match.phase ?? '');
_rosterController = TextEditingController(
text: widget.match.rosterNumbers.join(', '),
);
_setsToWin = widget.match.setsToWin;
_scheduledAt = widget.match.scheduledAt;
final rules = widget.match.scoringRules;
if (rules != null && rules.isNotEmpty) {
_customRules = true;
_pointsPerSet = rules['points_per_set'] as int? ?? 25;
_pointsDecidingSet = rules['points_deciding_set'] as int? ?? 15;
_minPointLead = rules['min_point_lead'] as int? ?? 2;
}
}
@override
void dispose() {
_opponentController.dispose();
_locationController.dispose();
_categoryController.dispose();
_phaseController.dispose();
_rosterController.dispose();
super.dispose();
}
List<int> _parseRoster(String raw) {
return raw
.split(RegExp(r'[,\s]+'))
.where((s) => s.isNotEmpty)
.map(int.tryParse)
.whereType<int>()
.toList();
}
Future<void> _pickDateTime() async {
final date = await showDatePicker(
context: context,
initialDate: _scheduledAt ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date == null || !mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_scheduledAt ?? DateTime.now()),
);
if (time == null) return;
setState(() {
_scheduledAt = DateTime(date.year, date.month, date.day, time.hour, time.minute);
});
}
Future<void> _save() async {
if (_opponentController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inserisci il nome avversario')),
);
return;
}
setState(() => _saving = true);
try {
final client = ref.read(apiClientProvider);
await client.updateMatch(widget.match.id, {
'opponent_name': _opponentController.text.trim(),
'location': _locationController.text.trim(),
'scheduled_at': _scheduledAt?.toIso8601String(),
'sets_to_win': _setsToWin,
if (_customRules)
'scoring_rules': {
'points_per_set': _pointsPerSet,
'points_deciding_set': _pointsDecidingSet,
'min_point_lead': _minPointLead,
},
'category': _categoryController.text.trim(),
'phase': _phaseController.text.trim(),
'roster_numbers': _parseRoster(_rosterController.text),
});
widget.onNext();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore nel salvataggio')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateLabel = _scheduledAt != null
? DateFormat('EEE d MMM yyyy · HH:mm', 'it_IT').format(_scheduledAt!.toLocal())
: 'Seleziona data e ora';
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Dettagli partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 20),
_ReadOnlyField(label: 'Squadra casa', value: widget.match.teamName),
const SizedBox(height: 12),
TextField(
controller: _opponentController,
decoration: const InputDecoration(labelText: 'Avversario'),
),
const SizedBox(height: 12),
TextField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Luogo'),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Orario', style: theme.textTheme.bodyMedium),
subtitle: Text(dateLabel, style: theme.textTheme.titleMedium),
trailing: const Icon(Icons.calendar_today),
onTap: _pickDateTime,
),
const SizedBox(height: 12),
Text('Set da vincere', style: theme.textTheme.bodyMedium),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
],
selected: {_setsToWin},
onSelectionChanged: (s) => setState(() => _setsToWin = s.first),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Regole punteggio personalizzate'),
subtitle: const Text(
'Per tornei non standard (punti set, tie-break, vantaggio minimo)',
),
value: _customRules,
onChanged: (v) => setState(() => _customRules = v),
),
if (_customRules) ...[
const SizedBox(height: 8),
Text('Punti per vincere un set', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 21, label: Text('21')),
ButtonSegment(value: 25, label: Text('25')),
ButtonSegment(value: 30, label: Text('30')),
],
selected: {_pointsPerSet},
onSelectionChanged: (s) => setState(() => _pointsPerSet = s.first),
),
const SizedBox(height: 10),
Text('Punti set decisivo', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 15, label: Text('15')),
ButtonSegment(value: 21, label: Text('21')),
],
selected: {_pointsDecidingSet},
onSelectionChanged: (s) => setState(() => _pointsDecidingSet = s.first),
),
const SizedBox(height: 10),
Text('Vantaggio minimo', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
],
selected: {_minPointLead},
onSelectionChanged: (s) => setState(() => _minPointLead = s.first),
),
],
const SizedBox(height: 12),
TextField(
controller: _categoryController,
decoration: const InputDecoration(labelText: 'Categoria'),
),
const SizedBox(height: 12),
TextField(
controller: _phaseController,
decoration: const InputDecoration(labelText: 'Fase (es. semifinale)'),
),
const SizedBox(height: 12),
TextField(
controller: _rosterController,
decoration: const InputDecoration(
labelText: 'Convocate (numeri maglia)',
hintText: '1, 5, 7, 12',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 32),
PrimaryCta(
label: 'AVANTI >',
loading: _saving,
onPressed: _save,
icon: Icons.arrow_forward,
),
],
),
);
}
}
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleMedium),
],
),
);
}
}

View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
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/score_provider.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/models/score_state.dart';
import '../../shared/models/stream_session.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepNetworkTest extends ConsumerStatefulWidget {
const StepNetworkTest({
super.key,
required this.match,
required this.onBack,
});
final MatchModel match;
final VoidCallback onBack;
@override
ConsumerState<StepNetworkTest> createState() => _StepNetworkTestState();
}
class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
bool _testing = false;
bool _ready = false;
double _downloadMbps = 0;
double _uploadMbps = 0;
int _latencyMs = 0;
String _networkType = '';
bool _starting = false;
Future<void> _runTest() async {
setState(() {
_testing = true;
_ready = false;
});
final connectivity = await Connectivity().checkConnectivity();
final networkLabel = _connectivityLabel(connectivity);
// Simulazione test rete locale (sostituibile con speed test reale).
await Future<void>.delayed(const Duration(seconds: 2));
final rng = Random();
final download = 8 + rng.nextDouble() * 12;
final upload = 2 + rng.nextDouble() * 4;
final latency = 20 + rng.nextInt(80);
final session = ref.read(sessionProvider).session;
NetworkTestResult? testResult;
if (session != null) {
try {
final client = ref.read(apiClientProvider);
testResult = await client.networkTest(
sessionId: session.id,
downloadMbps: download,
uploadMbps: upload,
latencyMs: latency,
networkType: networkLabel,
);
} catch (_) {
testResult = null;
}
}
if (mounted) {
setState(() {
_testing = false;
_downloadMbps = download;
_uploadMbps = upload;
_latencyMs = latency;
_networkType = networkLabel;
_ready = testResult?.ready ?? upload >= 2.0;
});
}
}
String _connectivityLabel(List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'Ethernet';
return 'Sconosciuto';
}
Future<void> _startLive() async {
final sessionState = ref.read(sessionProvider);
final session = sessionState.session;
if (session == null) return;
setState(() => _starting = true);
try {
final client = ref.read(apiClientProvider);
final started = await client.startSession(session.id);
ref.read(sessionProvider.notifier).setSession(started, match: widget.match);
if (started.score != null) {
ref.read(scoreProvider.notifier).reset(started.score!);
} else {
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);
// 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',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Diretta avviata ma sync regia limitata: $e',
),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore avvio diretta: $e')),
);
}
} finally {
if (mounted) setState(() => _starting = false);
}
}
@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('Test rete', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'Verifica che la connessione regga l\'upload della diretta.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
Row(
children: [
MetricCard(
label: 'Download',
value: _testing ? '...' : '${_downloadMbps.toStringAsFixed(1)} Mbps',
icon: Icons.download,
),
const SizedBox(width: 8),
MetricCard(
label: 'Upload',
value: _testing ? '...' : '${_uploadMbps.toStringAsFixed(1)} Mbps',
icon: Icons.upload,
highlight: _ready,
),
const SizedBox(width: 8),
MetricCard(
label: 'Latenza',
value: _testing ? '...' : '${_latencyMs} ms',
icon: Icons.speed,
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: MetricCard(
label: 'Tipo rete',
value: _networkType,
icon: Icons.signal_cellular_alt,
expand: false,
),
),
const SizedBox(height: 20),
if (_ready) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.successGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.successGreen.withValues(alpha: 0.4)),
),
child: Text(
'PRONTO PER ANDARE IN DIRETTA',
style: theme.textTheme.titleMedium?.copyWith(
color: AppTheme.successGreen,
),
textAlign: TextAlign.center,
),
),
if (ref.read(sessionProvider).session?.watchPageUrl != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Link per gli spettatori',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SelectableText(
ref.read(sessionProvider).session!.watchPageUrl!,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
final url = ref.read(sessionProvider).session!.watchPageUrl!;
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link copiato')),
);
},
icon: const Icon(Icons.link, size: 18),
label: const Text('COPIA LINK DIRETTA'),
),
],
),
),
],
],
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'In caso di disconnessione, lo stream resta attivo con slate per massimo 5 minuti mentre il telefono riconnette.',
style: theme.textTheme.bodyMedium,
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _testing ? null : _runTest,
child: Text(_testing ? 'TEST IN CORSO...' : 'AVVIA TEST RETE'),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _starting ? null : widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'INIZIA >',
loading: _starting,
onPressed: _ready ? _startLive : null,
icon: Icons.play_arrow,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,238 @@
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

@@ -0,0 +1,331 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/api_error.dart';
import '../../shared/models/match.dart';
import '../../shared/premium_dialog.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
import '../../shared/models/team.dart';
import '../matches/team_providers.dart';
class StepTransmission extends ConsumerStatefulWidget {
const StepTransmission({
super.key,
required this.match,
required this.onBack,
required this.onNext,
});
final MatchModel match;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<StepTransmission> createState() => _StepTransmissionState();
}
class _StepTransmissionState extends ConsumerState<StepTransmission> {
String _platform = 'matchlivetv';
String _privacy = 'unlisted';
bool _creating = false;
Future<void> _createSession() async {
setState(() => _creating = true);
try {
final client = ref.read(apiClientProvider);
final session = await client.createSession(
widget.match.id,
platform: _platform,
privacyStatus: _privacy,
qualityPreset: '720p_30_2.5mbps',
targetBitrate: 2500000,
targetFps: 30,
);
ref.read(sessionProvider.notifier).setSession(session, match: widget.match);
widget.onNext();
} on DioException catch (e) {
final apiErr = ApiException.fromDio(e);
if (mounted) {
if (apiErr?.isPremiumRequired == true) {
await showPremiumRequiredDialog(
context,
message: apiErr!.message,
billingUrl: apiErr.billingUrl,
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(apiErr?.message ?? 'Errore nella creazione sessione')),
);
}
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore nella creazione sessione')),
);
}
} finally {
if (mounted) setState(() => _creating = false);
}
}
void _onYoutubeTap(Team? team) {
if (team == null) return;
if (team.canUseYoutube && team.youtubeConnected) {
setState(() => _platform = 'youtube');
return;
}
if (team.canUseYoutube && !team.youtubeConnected) {
showPremiumRequiredDialog(
context,
message: 'Collega il canale YouTube dalla dashboard sul sito, poi seleziona YouTube qui.',
billingUrl: team.billingUrl,
);
return;
}
showPremiumRequiredDialog(
context,
message: 'YouTube Live è incluso nel piano Premium Full.',
billingUrl: team.billingUrl,
);
}
void _onExternalPremiumTap(String platform, Team? team) {
showPremiumRequiredDialog(
context,
message: '$platform è disponibile con Premium (in arrivo).',
billingUrl: team?.billingUrl,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final teamAsync = ref.watch(teamByIdProvider(widget.match.teamId));
return teamAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)),
error: (_, __) => const Center(child: Text('Errore caricamento squadra')),
data: (team) {
final youtubeSelectable = team != null && team.canUseYoutube && team.youtubeConnected;
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (team != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.3)),
),
child: Text(
'Piano ${team.planName} · Staff ${team.staffLimitLabel}'
'${team.concurrentStreamsLimit != null ? ' · Dirette ${team.concurrentStreamsUsed}/${team.concurrentStreamsLimit}' : ''}',
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 12),
],
Text('Piattaforma', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
_PlatformCard(
title: 'Match Live TV',
subtitle: 'Diretta sul nostro sito (incluso)',
selected: _platform == 'matchlivetv',
enabled: true,
onTap: () => setState(() => _platform = 'matchlivetv'),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'YouTube Live',
subtitle: team?.canUseYoutube == true
? (team!.youtubeConnected
? 'Canale collegato'
: 'Collega canale sul sito')
: 'Premium Full — collegamento canale',
selected: _platform == 'youtube',
enabled: youtubeSelectable,
badge: team?.canUseYoutube == true ? null : 'Full',
onTap: () => _onYoutubeTap(team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Facebook Live',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Facebook Live', team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Twitch',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Twitch', team),
),
const SizedBox(height: 24),
Text('Privacy', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'public', label: Text('Pubblico')),
ButtonSegment(value: 'unlisted', label: Text('Non in elenco')),
],
selected: {_privacy},
onSelectionChanged: (s) => setState(() => _privacy = s.first),
),
const SizedBox(height: 24),
Text('Qualità', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.4)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Bitrate fisso consigliato per reti instabili',
style: theme.textTheme.bodyMedium,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryRed.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'AUTO',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
fontSize: 11,
),
),
),
],
),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _creating ? null : widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'AVANTI >',
loading: _creating,
onPressed: _createSession,
icon: Icons.arrow_forward,
),
),
],
),
],
),
);
},
);
}
}
class _PlatformCard extends StatelessWidget {
const _PlatformCard({
required this.title,
this.subtitle,
required this.selected,
required this.enabled,
this.badge,
this.onTap,
});
final String title;
final String? subtitle;
final bool selected;
final bool enabled;
final String? badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Opacity(
opacity: enabled ? 1 : 0.55,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: selected
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? AppTheme.primaryRed : Colors.transparent,
),
),
child: Row(
children: [
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.bodyMedium),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(badge!, style: theme.textTheme.labelSmall),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
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/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 {
const WizardShell({
super.key,
required this.matchId,
required this.step,
});
final String matchId;
final int step;
@override
ConsumerState<WizardShell> createState() => _WizardShellState();
}
class _WizardShellState extends ConsumerState<WizardShell> {
MatchModel? _match;
bool _loading = true;
static const _stepTitles = [
'01 · Partita',
'02 · Trasmissione',
'03 · Ruoli',
'04 · Test',
];
@override
void initState() {
super.initState();
_loadMatch();
}
@override
void didUpdateWidget(WizardShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.matchId != widget.matchId) {
_loadMatch();
}
}
Future<void> _loadMatch() async {
setState(() => _loading = true);
try {
final client = ref.read(apiClientProvider);
final match = await client.fetchMatch(widget.matchId);
ref.read(sessionProvider.notifier).setMatch(match);
if (mounted) {
setState(() {
_match = match;
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _goToStep(int step) {
context.go('/setup/${widget.matchId}/$step');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stepIndex = widget.step.clamp(1, 4) - 1;
return Scaffold(
appBar: AppBar(
title: Text(_stepTitles[stepIndex]),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/matches'),
),
),
body: _loading
? const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
)
: Column(
children: [
_StepIndicator(current: widget.step),
Expanded(
child: AppSafeArea(
top: false,
child: _buildStep(stepIndex),
),
),
],
),
);
}
Widget _buildStep(int index) {
final match = _match;
if (match == null) {
return Center(
child: Text(
'Partita non trovata',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
switch (index) {
case 0:
return StepMatch(
match: match,
onNext: () => _goToStep(2),
);
case 1:
return StepTransmission(
match: match,
onBack: () => _goToStep(1),
onNext: () => _goToStep(3),
);
case 2:
return StepRoles(
match: match,
onBack: () => _goToStep(2),
onNext: () => _goToStep(4),
);
case 3:
return StepNetworkTest(
match: match,
onBack: () => _goToStep(3),
);
default:
return const SizedBox.shrink();
}
}
}
class _StepIndicator extends StatelessWidget {
const _StepIndicator({required this.current});
final int current;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: List.generate(4, (i) {
final step = i + 1;
final active = step <= current;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(right: i < 3 ? 6 : 0),
decoration: BoxDecoration(
color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../../shared/scoring/match_scoring_rules.dart';
import '../../shared/widgets/score_outcome_dialogs.dart';
/// Gestione regole punteggio, dialoghi set/partita e sync.
class LiveScoreActions {
LiveScoreActions(this.ref);
final WidgetRef ref;
bool _dialogOpen = false;
MatchScoringRules get _rules => ref.read(matchScoringRulesProvider);
Future<void> afterPointChange(
BuildContext context, {
required String homeName,
required String awayName,
required Future<void> Function() onCloseSetConfirmed,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
);
if (winner == null) return;
_dialogOpen = true;
final close = await showSetWonDialog(
context,
winner: winner,
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
);
_dialogOpen = false;
if (close && context.mounted) {
await onCloseSetConfirmed();
}
}
Future<void> requestCloseSet(
BuildContext context, {
required Future<void> Function() onCloseSetConfirmed,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
);
if (winner == null) {
final ok = await showCloseSetAnywayDialog(context);
if (!ok) return;
}
await onCloseSetConfirmed();
}
Future<void> afterCloseSet(
BuildContext context, {
required String homeName,
required String awayName,
required Future<void> Function() onStopStream,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.matchWinner(
homeSets: score.homeSets,
awaySets: score.awaySets,
);
if (winner == null) return;
_dialogOpen = true;
final stop = await showMatchWonDialog(
context,
winner: winner,
homeName: homeName,
awayName: awayName,
homeSets: score.homeSets,
awaySets: score.awaySets,
);
_dialogOpen = false;
if (stop && context.mounted) {
await onStopStream();
}
}
}