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,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/auth/login_screen.dart';
import '../features/auth/splash_screen.dart';
import '../features/camera_mode/camera_screen.dart';
import '../features/controller_mode/controller_screen.dart';
import '../features/archive/archive_screen.dart';
import '../features/matches/matches_screen.dart';
import '../features/setup_wizard/wizard_shell.dart';
import '../providers/auth_provider.dart';
/// Notifica GoRouter solo su cambi auth rilevanti (non su ogni loading tick).
class _AuthRefreshNotifier extends ChangeNotifier {
_AuthRefreshNotifier(this._ref) {
_ref.listen<AuthState>(authProvider, (prev, next) {
final authChanged = prev?.isAuthenticated != next.isAuthenticated;
if (authChanged) notifyListeners();
});
}
final Ref _ref;
}
final routerProvider = Provider<GoRouter>((ref) {
final refresh = _AuthRefreshNotifier(ref);
return GoRouter(
initialLocation: '/',
refreshListenable: refresh,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggingIn = state.matchedLocation == '/login';
final onSplash = state.matchedLocation == '/';
if (onSplash) return null;
if (!auth.isAuthenticated && !loggingIn) {
return '/login';
}
if (auth.isAuthenticated && loggingIn) {
return '/matches';
}
return null;
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/matches',
builder: (context, state) => const MatchesScreen(),
),
GoRoute(
path: '/archive',
builder: (context, state) => const ArchiveScreen(),
),
GoRoute(
path: '/setup/:matchId/:step',
builder: (context, state) {
final matchId = state.pathParameters['matchId']!;
final step = int.tryParse(state.pathParameters['step'] ?? '1') ?? 1;
return WizardShell(matchId: matchId, step: step);
},
),
GoRoute(
path: '/session/:id/camera',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return CameraScreen(sessionId: sessionId);
},
),
GoRoute(
path: '/session/:id/controller',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return ControllerScreen(sessionId: sessionId);
},
),
],
);
});

93
mobile/lib/app/theme.dart Normal file
View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Brand Match Live TV — dark theme, rosso #FF2D2D, Barlow Condensed.
class AppTheme {
AppTheme._();
static const Color primaryRed = Color(0xFFFF2D2D);
static const Color background = Color(0xFF0A0A0A);
static const Color surface = Color(0xFF1E1E1E);
static const Color surfaceElevated = Color(0xFF2A2A2A);
static const Color accentYellow = Color(0xFFF5C518);
static const Color successGreen = Color(0xFF22C55E);
static const Color textSecondary = Color(0xFF9CA3AF);
static TextTheme get _textTheme {
final base = GoogleFonts.barlowCondensedTextTheme(
ThemeData.dark().textTheme,
);
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
),
displayMedium: base.displayMedium?.copyWith(fontWeight: FontWeight.w900),
displaySmall: base.displaySmall?.copyWith(fontWeight: FontWeight.w900),
headlineLarge: base.headlineLarge?.copyWith(fontWeight: FontWeight.w900),
headlineMedium: base.headlineMedium?.copyWith(fontWeight: FontWeight.w800),
headlineSmall: base.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700),
titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700),
labelLarge: base.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
bodyLarge: base.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
bodyMedium: base.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: textSecondary,
),
);
}
static ThemeData get dark {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: background,
colorScheme: const ColorScheme.dark(
primary: primaryRed,
secondary: accentYellow,
surface: surface,
error: primaryRed,
onPrimary: Colors.white,
onSecondary: Colors.black,
onSurface: Colors.white,
),
textTheme: _textTheme,
appBarTheme: AppBarTheme(
backgroundColor: background,
elevation: 0,
centerTitle: true,
titleTextStyle: _textTheme.titleLarge?.copyWith(color: Colors.white),
iconTheme: const IconThemeData(color: Colors.white),
),
cardTheme: CardThemeData(
color: surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceElevated,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
labelStyle: _textTheme.bodyMedium,
hintStyle: _textTheme.bodyMedium,
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return primaryRed;
return surfaceElevated;
}),
foregroundColor: WidgetStateProperty.all(Colors.white),
),
),
dividerColor: surfaceElevated,
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../shared/models/user.dart';
class AuthStorage {
static const _keyAccess = 'auth_access_token';
static const _keyRefresh = 'auth_refresh_token';
static const _keyUserId = 'auth_user_id';
static const _keyUserEmail = 'auth_user_email';
static const _keyUserName = 'auth_user_name';
static const _keyUserRole = 'auth_user_role';
static Future<({User user, String accessToken, String refreshToken})?> load() async {
final prefs = await SharedPreferences.getInstance();
final access = prefs.getString(_keyAccess);
final refresh = prefs.getString(_keyRefresh);
final userId = prefs.getString(_keyUserId);
final email = prefs.getString(_keyUserEmail);
final name = prefs.getString(_keyUserName);
if (access == null ||
access.isEmpty ||
refresh == null ||
refresh.isEmpty ||
userId == null ||
email == null ||
name == null) {
return null;
}
return (
user: User(
id: userId,
email: email,
name: name,
role: prefs.getString(_keyUserRole) ?? 'coach',
),
accessToken: access,
refreshToken: refresh,
);
}
static Future<void> save({
required User user,
required String accessToken,
required String refreshToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAccess, accessToken);
await prefs.setString(_keyRefresh, refreshToken);
await prefs.setString(_keyUserId, user.id);
await prefs.setString(_keyUserEmail, user.email);
await prefs.setString(_keyUserName, user.name);
await prefs.setString(_keyUserRole, user.role);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAccess);
await prefs.remove(_keyRefresh);
await prefs.remove(_keyUserId);
await prefs.remove(_keyUserEmail);
await prefs.remove(_keyUserName);
await prefs.remove(_keyUserRole);
}
}

View File

@@ -0,0 +1,24 @@
/// Configurazione globale dell'app Match Live TV.
class AppConfig {
AppConfig._();
/// URL base API Rails. Default emulatore Android → host localhost.
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:3000',
);
static String get apiV1 => '$apiBaseUrl/api/v1';
static String get cableUrl {
final uri = Uri.parse(apiBaseUrl);
final wsScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final port = uri.hasPort ? uri.port : defaultPort;
// Evita :443/:80 espliciti — alcuni client WebSocket/NPM li gestiscono male
if (port == defaultPort) {
return '$wsScheme://${uri.host}/cable';
}
return '$wsScheme://${uri.host}:$port/cable';
}
}

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

38
mobile/lib/main.dart Normal file
View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'app/router.dart';
import 'app/theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting('it_IT', null);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.light,
),
);
runApp(const ProviderScope(child: MatchLiveTvApp()));
}
class MatchLiveTvApp extends ConsumerWidget {
const MatchLiveTvApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'Match Live TV',
debugShowCheckedModeBanner: false,
theme: AppTheme.dark,
routerConfig: router,
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/services.dart';
/// Bridge verso engine nativo Android (CameraX + RootEncoder).
class StreamingChannel {
StreamingChannel._();
static const MethodChannel _channel =
MethodChannel('com.matchlivetv.match_live_tv/streaming');
static const EventChannel _metricsChannel =
EventChannel('com.matchlivetv.match_live_tv/streaming_events');
static Future<void> startPreview() async {
await _channel.invokeMethod('startPreview');
}
static Future<bool> bindPreview() async {
final ok = await _channel.invokeMethod<bool>('bindPreview');
return ok ?? false;
}
static Future<void> stopPreview() async {
await _channel.invokeMethod('stopPreview');
}
static Future<void> startStream({
required String rtmpUrl,
int targetBitrate = 2500000,
int targetFps = 30,
}) async {
await _channel.invokeMethod('startStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
});
}
static Future<void> stopStream() async {
await _channel.invokeMethod('stopStream');
}
static Future<void> pauseStream() async {
await _channel.invokeMethod('pauseStream');
}
static Future<Map<String, dynamic>?> getMetrics() async {
final result =
await _channel.invokeMethod<Map<dynamic, dynamic>>('getMetrics');
return result?.map((key, value) => MapEntry(key.toString(), value));
}
static EventChannel get metricsStream => _metricsChannel;
}

View File

@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme.
class NativeStreamingPreview extends StatelessWidget {
const NativeStreamingPreview({
super.key,
this.showGrid = true,
this.platformLabel = 'LIVE',
});
final bool showGrid;
final String platformLabel;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.android) {
return Stack(
fit: StackFit.expand,
children: [
AndroidView(
key: key,
viewType: 'match_live_tv/streaming_preview',
layoutDirection: TextDirection.ltr,
creationParamsCodec: StandardMessageCodec(),
),
if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel),
],
);
}
return CameraPreviewPlaceholder(showGrid: showGrid);
}
}
class CameraPreviewPlaceholder extends StatelessWidget {
const CameraPreviewPlaceholder({super.key, this.showGrid = true});
final bool showGrid;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: const Center(
child: Icon(Icons.videocam, size: 64, color: Colors.white24),
),
);
}
}
/// Overlay REC / badge sopra la preview nativa.
class CameraPreviewOverlay extends StatelessWidget {
const CameraPreviewOverlay({super.key, this.platformLabel = 'LIVE'});
final String platformLabel;
@override
Widget build(BuildContext context) {
final inset = MediaQuery.viewPaddingOf(context);
const gap = 8.0;
return Stack(
fit: StackFit.expand,
children: [
Positioned(
top: inset.top + gap,
left: inset.left + gap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'REC',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
),
Positioned(
top: inset.top + gap,
right: inset.right + gap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
platformLabel,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../shared/models/user.dart';
class AuthState {
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.loading = false,
this.error,
});
final User? user;
final String? accessToken;
final String? refreshToken;
final bool loading;
final String? error;
bool get isAuthenticated =>
accessToken != null && accessToken!.isNotEmpty && user != null;
AuthState copyWith({
User? user,
String? accessToken,
String? refreshToken,
bool? loading,
String? error,
bool clearError = false,
}) {
return AuthState(
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
void setSession({
required User user,
required String accessToken,
required String refreshToken,
}) {
state = AuthState(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
loading: false,
);
AuthStorage.save(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
Future<void> restoreSession() async {
final stored = await AuthStorage.load();
if (stored == null) return;
state = AuthState(
user: stored.user,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
loading: false,
);
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading, clearError: true);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void logout() {
state = const AuthState();
AuthStorage.clear();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/scoring/match_scoring_rules.dart';
import 'session_provider.dart';
final matchScoringRulesProvider = Provider<MatchScoringRules>((ref) {
final match = ref.watch(sessionProvider).match;
if (match == null) {
return const MatchScoringRules(setsToWin: 3);
}
return MatchScoringRules.fromMatch(match);
});

View File

@@ -0,0 +1,77 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/models/score_state.dart';
class ScoreNotifier extends StateNotifier<ScoreState> {
ScoreNotifier() : super(const ScoreState());
void reset(ScoreState score) {
state = score;
}
void applyRemote(ScoreState score) {
state = score;
}
void incrementHome() {
state = state.copyWith(homePoints: state.homePoints + 1);
}
void incrementAway() {
state = state.copyWith(awayPoints: state.awayPoints + 1);
}
void decrementHome() {
if (state.homePoints > 0) {
state = state.copyWith(homePoints: state.homePoints - 1);
}
}
void decrementAway() {
if (state.awayPoints > 0) {
state = state.copyWith(awayPoints: state.awayPoints - 1);
}
}
void closeSet() {
final homeWon = state.homePoints > state.awayPoints;
final partials = List<SetPartial>.from(state.setPartials);
if (state.homePoints > 0 || state.awayPoints > 0) {
partials.add(SetPartial(
set: state.currentSet,
home: state.homePoints,
away: state.awayPoints,
));
}
state = state.copyWith(
homeSets: homeWon ? state.homeSets + 1 : state.homeSets,
awaySets: homeWon ? state.awaySets : state.awaySets + 1,
homePoints: 0,
awayPoints: 0,
currentSet: state.currentSet + 1,
setPartials: partials,
timeoutHome: false,
timeoutAway: false,
);
}
void timeoutHome() {
state = state.copyWith(timeoutHome: true);
}
void timeoutAway() {
state = state.copyWith(timeoutAway: true);
}
void setTimeoutHome(bool value) {
state = state.copyWith(timeoutHome: value);
}
void setTimeoutAway(bool value) {
state = state.copyWith(timeoutAway: value);
}
}
final scoreProvider = StateNotifierProvider<ScoreNotifier, ScoreState>(
(ref) => ScoreNotifier(),
);

View File

@@ -0,0 +1,165 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/models/device_state.dart';
import '../shared/models/match.dart';
import '../shared/models/stream_session.dart';
enum DeviceRole { camera, controller }
class SessionState {
const SessionState({
this.session,
this.match,
this.connected = false,
this.deviceRole = DeviceRole.controller,
this.cameraDevice,
this.elapsed = Duration.zero,
this.viewerCount = 0,
this.loading = false,
this.error,
});
final StreamSession? session;
final MatchModel? match;
final bool connected;
final DeviceRole deviceRole;
final DeviceStateModel? cameraDevice;
final Duration elapsed;
final int viewerCount;
final bool loading;
final String? error;
SessionState copyWith({
StreamSession? session,
MatchModel? match,
bool? connected,
DeviceRole? deviceRole,
DeviceStateModel? cameraDevice,
Duration? elapsed,
int? viewerCount,
bool? loading,
String? error,
bool clearError = false,
}) {
return SessionState(
session: session ?? this.session,
match: match ?? this.match,
connected: connected ?? this.connected,
deviceRole: deviceRole ?? this.deviceRole,
cameraDevice: cameraDevice ?? this.cameraDevice,
elapsed: elapsed ?? this.elapsed,
viewerCount: viewerCount ?? this.viewerCount,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
class SessionNotifier extends StateNotifier<SessionState> {
SessionNotifier() : super(const SessionState());
void setSession(StreamSession session, {MatchModel? match}) {
state = state.copyWith(
session: session,
match: match,
clearError: true,
);
}
void setMatch(MatchModel match) {
state = state.copyWith(match: match);
}
void setConnected(bool connected) {
state = state.copyWith(connected: connected);
}
void setDeviceRole(DeviceRole role) {
state = state.copyWith(deviceRole: role);
}
void updateDeviceState(DeviceStateModel device) {
if (device.deviceRole == 'camera') {
state = state.copyWith(cameraDevice: device);
}
if (device.status != null && state.session != null) {
state = state.copyWith(
session: StreamSession(
id: state.session!.id,
matchId: state.session!.matchId,
status: device.status!,
platform: state.session!.platform,
rtmpIngestUrl: state.session!.rtmpIngestUrl,
youtubeBroadcastId: state.session!.youtubeBroadcastId,
privacyStatus: state.session!.privacyStatus,
qualityPreset: state.session!.qualityPreset,
targetBitrate: state.session!.targetBitrate,
startedAt: state.session!.startedAt,
disconnectionCount: state.session!.disconnectionCount,
score: state.session!.score,
devices: state.session!.devices,
),
);
}
}
void setElapsed(Duration elapsed) {
state = state.copyWith(elapsed: elapsed);
}
void setViewerCount(int count) {
state = state.copyWith(viewerCount: count);
}
void applyCommand(String action) {
final session = state.session;
if (session == null) return;
String newStatus = session.status;
switch (action) {
case 'start_stream':
newStatus = 'live';
break;
case 'pause_stream':
newStatus = 'paused';
break;
case 'stop_stream':
newStatus = 'ended';
break;
}
state = state.copyWith(
session: StreamSession(
id: session.id,
matchId: session.matchId,
status: newStatus,
platform: session.platform,
rtmpIngestUrl: session.rtmpIngestUrl,
youtubeBroadcastId: session.youtubeBroadcastId,
privacyStatus: session.privacyStatus,
qualityPreset: session.qualityPreset,
targetBitrate: session.targetBitrate,
startedAt: session.startedAt,
disconnectionCount: session.disconnectionCount,
score: session.score,
devices: session.devices,
),
);
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void reset() {
state = const SessionState();
}
}
final sessionProvider = StateNotifierProvider<SessionNotifier, SessionState>(
(ref) => SessionNotifier(),
);

View File

@@ -0,0 +1,249 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import 'models/match.dart';
import 'models/recording_item.dart';
import 'models/stream_session.dart';
import 'models/team.dart';
import 'models/user.dart';
final apiClientProvider = Provider<ApiClient>((ref) {
final auth = ref.watch(authProvider);
return ApiClient(accessToken: auth.accessToken);
});
class ApiClient {
ApiClient({this.accessToken}) {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiV1,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (accessToken != null && accessToken!.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
},
),
);
}
late final Dio _dio;
final String? accessToken;
// --- Auth ---
Future<({User user, AuthTokens tokens})> login({
required String email,
required String password,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/login',
data: {'email': email, 'password': password},
);
final data = response.data!;
return (
user: User.fromJson(data['user'] as Map<String, dynamic>),
tokens: AuthTokens.fromJson(data),
);
}
Future<AuthTokens> refresh(String refreshToken) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
return AuthTokens.fromJson(response.data!);
}
Future<User> me() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/me');
return User.fromJson(response.data!);
}
Future<void> logout() async {
await _dio.post('/auth/logout');
}
// --- Teams ---
Future<List<Team>> fetchTeams() async {
final response = await _dio.get<List<dynamic>>('/teams');
return response.data!
.map((e) => Team.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<RecordingItem>> fetchRecordings(String teamId) async {
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
return response.data!
.map((e) => RecordingItem.fromJson(e as Map<String, dynamic>))
.toList();
}
// --- Matches ---
Future<List<MatchModel>> fetchMatches(String teamId) async {
final response =
await _dio.get<List<dynamic>>('/teams/$teamId/matches');
return response.data!
.map((e) => MatchModel.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<MatchModel> fetchMatch(String matchId) async {
final response =
await _dio.get<Map<String, dynamic>>('/matches/$matchId');
return MatchModel.fromJson(response.data!);
}
Future<MatchModel> updateMatch(String matchId, Map<String, dynamic> body) async {
final response = await _dio.patch<Map<String, dynamic>>(
'/matches/$matchId',
data: {'match': body},
);
return MatchModel.fromJson(response.data!);
}
Future<void> deleteMatch(String matchId) async {
await _dio.delete<void>('/matches/$matchId');
}
Future<MatchModel> createMatch(String teamId, Map<String, dynamic> body) async {
final response = await _dio.post<Map<String, dynamic>>(
'/teams/$teamId/matches',
data: {'match': body},
);
return MatchModel.fromJson(response.data!);
}
// --- Sessions ---
Future<StreamSession> createSession(
String matchId, {
String platform = 'matchlivetv',
String privacyStatus = 'unlisted',
String qualityPreset = '720p_30_2.5mbps',
int? targetBitrate,
int? targetFps,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/matches/$matchId/sessions',
data: {
'platform': platform,
'privacy_status': privacyStatus,
'quality_preset': qualityPreset,
if (targetBitrate != null) 'target_bitrate': targetBitrate,
if (targetFps != null) 'target_fps': targetFps,
},
);
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> fetchSession(String sessionId) async {
final response =
await _dio.get<Map<String, dynamic>>('/sessions/$sessionId');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> startSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/start');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> stopSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/stop');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> pauseSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/pause');
return StreamSession.fromJson(response.data!);
}
Future<PairingTokenResponse> createPairingToken(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/pairing_token',
);
return PairingTokenResponse.fromJson(response.data!);
}
Future<StreamSession> claimPairing({
required String sessionId,
required String pairingToken,
required String deviceRole,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/claim_pairing',
data: {
'pairing_token': pairingToken,
'device_role': deviceRole,
},
);
return StreamSession.fromJson(response.data!);
}
Future<NetworkTestResult> networkTest({
required String sessionId,
required double downloadMbps,
required double uploadMbps,
required int latencyMs,
required String networkType,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/network_test',
data: {
'download_mbps': downloadMbps,
'upload_mbps': uploadMbps,
'latency_ms': latencyMs,
'network_type': networkType,
},
);
return NetworkTestResult.fromJson(response.data!);
}
Future<YoutubeStats> youtubeStats(String sessionId) async {
final response =
await _dio.get<Map<String, dynamic>>('/sessions/$sessionId/youtube_stats');
return YoutubeStats.fromJson(response.data!);
}
Future<void> postTelemetry({
required String sessionId,
required String deviceRole,
int? batteryLevel,
String? networkType,
int? signalStrength,
int? currentBitrate,
int? targetBitrate,
int? fps,
}) async {
await _dio.post(
'/sessions/$sessionId/telemetry',
data: {
'device_role': deviceRole,
if (batteryLevel != null) 'battery_level': batteryLevel,
if (networkType != null) 'network_type': networkType,
if (signalStrength != null) 'signal_strength': signalStrength,
if (currentBitrate != null) 'current_bitrate': currentBitrate,
if (targetBitrate != null) 'target_bitrate': targetBitrate,
if (fps != null) 'fps': fps,
},
);
}
}

View File

@@ -0,0 +1,32 @@
class ApiException implements Exception {
ApiException({
required this.message,
this.statusCode,
this.errorCode,
this.billingUrl,
});
final String message;
final int? statusCode;
final String? errorCode;
final String? billingUrl;
bool get isPremiumRequired => errorCode == 'premium_required';
@override
String toString() => message;
static ApiException? fromDio(dynamic error) {
// ignore: avoid_dynamic_calls
final response = error.response;
if (response == null) return null;
final data = response.data;
if (data is! Map<String, dynamic>) return null;
return ApiException(
message: data['error'] as String? ?? 'Errore di rete',
statusCode: response.statusCode as int?,
errorCode: data['error_code'] as String?,
billingUrl: data['billing_url'] as String?,
);
}
}

View File

@@ -0,0 +1,37 @@
class DeviceStateModel {
const DeviceStateModel({
this.deviceRole = 'camera',
this.batteryLevel,
this.networkType,
this.signalStrength,
this.currentBitrate,
this.targetBitrate,
this.fps,
this.status,
});
final String deviceRole;
final int? batteryLevel;
final String? networkType;
final int? signalStrength;
final int? currentBitrate;
final int? targetBitrate;
final int? fps;
final String? status;
factory DeviceStateModel.fromJson(Map<String, dynamic> json) {
return DeviceStateModel(
deviceRole: json['device_role'] as String? ?? 'camera',
batteryLevel: json['battery'] as int? ?? json['battery_level'] as int?,
networkType: json['network'] as String? ?? json['network_type'] as String?,
signalStrength: json['signal_strength'] as int?,
currentBitrate: json['bitrate'] as int? ?? json['current_bitrate'] as int?,
targetBitrate: json['target_bitrate'] as int?,
fps: json['fps'] as int?,
status: json['status'] as String?,
);
}
double get bitrateMbps =>
currentBitrate != null ? currentBitrate! / 1_000_000 : 0;
}

View File

@@ -0,0 +1,81 @@
class MatchModel {
const MatchModel({
required this.id,
required this.teamId,
required this.teamName,
required this.opponentName,
this.location,
this.scheduledAt,
this.sport = 'volleyball',
this.setsToWin = 3,
this.scoringRules,
this.rosterNumbers = const [],
this.category,
this.phase,
this.activeSessionId,
this.activeSessionStatus,
});
final String id;
final String teamId;
final String teamName;
final String opponentName;
final String? location;
final DateTime? scheduledAt;
final String sport;
final int setsToWin;
final Map<String, dynamic>? scoringRules;
final List<int> rosterNumbers;
final String? category;
final String? phase;
final String? activeSessionId;
final String? activeSessionStatus;
/// Sessione avviata (o in ripresa) — si può rientrare in camera senza rifare il wizard.
bool get canResumeCamera {
const resumable = {'connecting', 'live', 'reconnecting', 'paused'};
return activeSessionId != null &&
activeSessionStatus != null &&
resumable.contains(activeSessionStatus);
}
bool get hasActiveSession => activeSessionId != null;
factory MatchModel.fromJson(Map<String, dynamic> json) {
return MatchModel(
id: json['id'] as String,
teamId: json['team_id'] as String,
teamName: json['team_name'] as String? ?? '',
opponentName: json['opponent_name'] as String,
location: json['location'] as String?,
scheduledAt: json['scheduled_at'] != null
? DateTime.tryParse(json['scheduled_at'] as String)
: null,
sport: json['sport'] as String? ?? 'volleyball',
setsToWin: json['sets_to_win'] as int? ?? 3,
scoringRules: json['scoring_rules'] is Map
? Map<String, dynamic>.from(json['scoring_rules'] as Map)
: null,
rosterNumbers: (json['roster_numbers'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
category: json['category'] as String?,
phase: json['phase'] as String?,
activeSessionId: json['active_session_id'] as String?,
activeSessionStatus: json['active_session_status'] as String?,
);
}
Map<String, dynamic> toJson() => {
'opponent_name': opponentName,
'location': location,
'scheduled_at': scheduledAt?.toIso8601String(),
'sport': sport,
'sets_to_win': setsToWin,
if (scoringRules != null) 'scoring_rules': scoringRules,
'roster_numbers': rosterNumbers,
'category': category,
'phase': phase,
};
}

View File

@@ -0,0 +1,35 @@
class RecordingItem {
const RecordingItem({
required this.id,
required this.sessionId,
required this.opponentName,
required this.teamName,
this.endedAt,
this.replayUrl,
this.expiresAt,
});
final String id;
final String sessionId;
final String opponentName;
final String teamName;
final DateTime? endedAt;
final String? replayUrl;
final DateTime? expiresAt;
factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem(
id: json['id'] as String,
sessionId: json['session_id'] as String,
opponentName: json['opponent_name'] as String,
teamName: json['team_name'] as String,
endedAt: json['ended_at'] != null
? DateTime.parse(json['ended_at'] as String)
: null,
replayUrl: json['replay_url'] as String?,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,
);
}
}

View File

@@ -0,0 +1,100 @@
class SetPartial {
const SetPartial({
required this.set,
required this.home,
required this.away,
});
final int set;
final int home;
final int away;
factory SetPartial.fromJson(Map<String, dynamic> json) {
return SetPartial(
set: json['set'] as int? ?? 1,
home: json['home'] as int? ?? 0,
away: json['away'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'set': set,
'home': home,
'away': away,
};
}
class ScoreState {
const ScoreState({
this.homeSets = 0,
this.awaySets = 0,
this.homePoints = 0,
this.awayPoints = 0,
this.currentSet = 1,
this.setPartials = const [],
this.timeoutHome = false,
this.timeoutAway = false,
});
final int homeSets;
final int awaySets;
final int homePoints;
final int awayPoints;
final int currentSet;
final List<SetPartial> setPartials;
final bool timeoutHome;
final bool timeoutAway;
factory ScoreState.fromJson(Map<String, dynamic> json) {
final rawPartials = json['set_partials'];
final partials = rawPartials is List
? rawPartials
.whereType<Map>()
.map((e) => SetPartial.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <SetPartial>[];
return ScoreState(
homeSets: json['home_sets'] as int? ?? 0,
awaySets: json['away_sets'] as int? ?? 0,
homePoints: json['home_points'] as int? ?? 0,
awayPoints: json['away_points'] as int? ?? 0,
currentSet: json['current_set'] as int? ?? 1,
setPartials: partials,
timeoutHome: json['timeout_home'] as bool? ?? false,
timeoutAway: json['timeout_away'] as bool? ?? false,
);
}
Map<String, dynamic> toCablePayload() => {
'type': 'score_update',
'home_sets': homeSets,
'away_sets': awaySets,
'home_points': homePoints,
'away_points': awayPoints,
'current_set': currentSet,
'set_partials': setPartials.map((p) => p.toJson()).toList(),
};
ScoreState copyWith({
int? homeSets,
int? awaySets,
int? homePoints,
int? awayPoints,
int? currentSet,
List<SetPartial>? setPartials,
bool? timeoutHome,
bool? timeoutAway,
}) {
return ScoreState(
homeSets: homeSets ?? this.homeSets,
awaySets: awaySets ?? this.awaySets,
homePoints: homePoints ?? this.homePoints,
awayPoints: awayPoints ?? this.awayPoints,
currentSet: currentSet ?? this.currentSet,
setPartials: setPartials ?? this.setPartials,
timeoutHome: timeoutHome ?? this.timeoutHome,
timeoutAway: timeoutAway ?? this.timeoutAway,
);
}
}

View File

@@ -0,0 +1,129 @@
import 'device_state.dart';
import 'score_state.dart';
class StreamSession {
const StreamSession({
required this.id,
required this.matchId,
required this.status,
this.platform = 'matchlivetv',
this.rtmpIngestUrl,
this.hlsPlaybackUrl,
this.watchPageUrl,
this.youtubeBroadcastId,
this.privacyStatus = 'unlisted',
this.qualityPreset = '720p_30_2.5mbps',
this.targetBitrate = 2500000,
this.startedAt,
this.disconnectionCount = 0,
this.score,
this.devices = const [],
});
final String id;
final String matchId;
final String status;
final String platform;
final String? rtmpIngestUrl;
final String? hlsPlaybackUrl;
final String? watchPageUrl;
final String? youtubeBroadcastId;
final String privacyStatus;
final String qualityPreset;
final int targetBitrate;
final DateTime? startedAt;
final int disconnectionCount;
final ScoreState? score;
final List<DeviceStateModel> devices;
bool get isLive => status == 'live' || status == 'reconnecting';
bool get isTerminal => status == 'ended' || status == 'error';
bool get canResumeBroadcast =>
status == 'connecting' ||
status == 'live' ||
status == 'reconnecting' ||
status == 'paused';
factory StreamSession.fromJson(Map<String, dynamic> json) {
return StreamSession(
id: json['id'] as String,
matchId: json['match_id'] as String,
status: json['status'] as String? ?? 'idle',
platform: json['platform'] as String? ?? 'matchlivetv',
rtmpIngestUrl: json['rtmp_ingest_url'] as String?,
hlsPlaybackUrl: json['hls_playback_url'] as String?,
watchPageUrl: json['watch_page_url'] as String?,
youtubeBroadcastId: json['youtube_broadcast_id'] as String?,
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps',
targetBitrate: json['target_bitrate'] as int? ?? 2500000,
startedAt: json['started_at'] != null
? DateTime.tryParse(json['started_at'] as String)
: null,
disconnectionCount: json['disconnection_count'] as int? ?? 0,
score: json['score'] != null
? ScoreState.fromJson(json['score'] as Map<String, dynamic>)
: null,
devices: (json['devices'] as List<dynamic>?)
?.map((e) => DeviceStateModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}
}
class PairingTokenResponse {
const PairingTokenResponse({
required this.pairingToken,
required this.expiresAt,
required this.qrPayload,
});
final String pairingToken;
final DateTime expiresAt;
final Map<String, dynamic> qrPayload;
factory PairingTokenResponse.fromJson(Map<String, dynamic> json) {
return PairingTokenResponse(
pairingToken: json['pairing_token'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String),
qrPayload: json['qr_payload'] as Map<String, dynamic>,
);
}
}
class NetworkTestResult {
const NetworkTestResult({
required this.ready,
required this.targetUploadMbps,
});
final bool ready;
final double targetUploadMbps;
factory NetworkTestResult.fromJson(Map<String, dynamic> json) {
return NetworkTestResult(
ready: json['ready'] as bool? ?? false,
targetUploadMbps: (json['target_upload_mbps'] as num?)?.toDouble() ?? 2.5,
);
}
}
class YoutubeStats {
const YoutubeStats({
required this.concurrentViewers,
required this.live,
});
final int concurrentViewers;
final bool live;
factory YoutubeStats.fromJson(Map<String, dynamic> json) {
return YoutubeStats(
concurrentViewers: json['concurrent_viewers'] as int? ?? 0,
live: json['live'] as bool? ?? false,
);
}
}

View File

@@ -0,0 +1,96 @@
class Team {
const Team({
required this.id,
required this.name,
required this.sport,
this.logoUrl,
this.youtubeConnected = false,
this.planSlug = 'free',
this.planName = 'Free',
this.premiumActive = false,
this.premiumFull = false,
this.billingUrl,
this.staffManageUrl,
this.maxStaff = 2,
this.staffUsed = 0,
this.maxStaffTransmission,
this.maxStaffRegia,
this.staffTransmissionUsed = 0,
this.staffRegiaUsed = 0,
this.concurrentStreamsUsed = 0,
this.concurrentStreamsLimit,
this.recordingsEnabled = false,
this.phoneDownloadEnabled = false,
this.youtubeEnabled = false,
this.youtubeMode,
});
final String id;
final String name;
final String sport;
final String? logoUrl;
final bool youtubeConnected;
final String planSlug;
final String planName;
final bool premiumActive;
final bool premiumFull;
final String? billingUrl;
final String? staffManageUrl;
final int maxStaff;
final int staffUsed;
final int? maxStaffTransmission;
final int? maxStaffRegia;
final int staffTransmissionUsed;
final int staffRegiaUsed;
final int concurrentStreamsUsed;
final int? concurrentStreamsLimit;
final bool recordingsEnabled;
final bool phoneDownloadEnabled;
final bool youtubeEnabled;
final String? youtubeMode;
bool get canUseYoutube => premiumFull && youtubeEnabled;
bool get canUseRecordings => recordingsEnabled;
bool get canDownloadOnPhone => phoneDownloadEnabled;
String get staffLimitLabel {
if (maxStaffTransmission == null && maxStaffRegia == null) {
return 'illimitato';
}
final tx = maxStaffTransmission == null
? 'tx ∞'
: 'tx $staffTransmissionUsed/$maxStaffTransmission';
final rg = maxStaffRegia == null
? 'rg ∞'
: 'rg $staffRegiaUsed/$maxStaffRegia';
return '$tx · $rg';
}
factory Team.fromJson(Map<String, dynamic> json) {
return Team(
id: json['id'] as String,
name: json['name'] as String,
sport: json['sport'] as String? ?? 'volleyball',
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',
planName: json['plan_name'] as String? ?? 'Free',
premiumActive: json['premium_active'] as bool? ?? false,
premiumFull: json['premium_full'] as bool? ?? false,
billingUrl: json['billing_url'] as String?,
staffManageUrl: json['staff_manage_url'] as String?,
maxStaff: json['max_staff'] as int? ?? 2,
staffUsed: json['staff_used'] as int? ?? 0,
maxStaffTransmission: json['max_staff_transmission'] as int?,
maxStaffRegia: json['max_staff_regia'] as int?,
staffTransmissionUsed: json['staff_transmission_used'] as int? ?? 0,
staffRegiaUsed: json['staff_regia_used'] as int? ?? 0,
concurrentStreamsUsed: json['concurrent_streams_used'] as int? ?? 0,
concurrentStreamsLimit: json['concurrent_streams_limit'] as int?,
recordingsEnabled: json['recordings_enabled'] as bool? ?? false,
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
youtubeMode: json['youtube_mode'] as String?,
);
}
}

View File

@@ -0,0 +1,46 @@
class User {
const User({
required this.id,
required this.email,
required this.name,
required this.role,
});
final String id;
final String email;
final String name;
final String role;
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
role: json['role'] as String? ?? 'coach',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'role': role,
};
}
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
});
final String accessToken;
final String refreshToken;
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app/theme.dart';
Future<void> showPremiumRequiredDialog(
BuildContext context, {
required String message,
String? billingUrl,
}) async {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Premium richiesto'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Chiudi'),
),
if (billingUrl != null && billingUrl.isNotEmpty)
FilledButton(
onPressed: () async {
final uri = Uri.parse(billingUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
if (ctx.mounted) Navigator.pop(ctx);
},
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Attiva sul sito'),
),
],
),
);
}

View File

@@ -0,0 +1,70 @@
import '../models/match.dart';
/// Lato che ha vinto set o partita.
enum ScoringSide { home, away }
/// Regole punteggio (pallavolo di default, campi sovrascrivibili per tornei).
class MatchScoringRules {
const MatchScoringRules({
required this.setsToWin,
this.pointsPerSet = 25,
this.pointsDecidingSet = 15,
this.minPointLead = 2,
});
final int setsToWin;
final int pointsPerSet;
final int pointsDecidingSet;
final int minPointLead;
int get maxSets => (setsToWin * 2) - 1;
factory MatchScoringRules.fromMatch(MatchModel match) {
final overrides = match.scoringRules;
return MatchScoringRules(
setsToWin: match.setsToWin,
pointsPerSet: overrides?['points_per_set'] as int? ?? 25,
pointsDecidingSet: overrides?['points_deciding_set'] as int? ?? 15,
minPointLead: overrides?['min_point_lead'] as int? ?? 2,
);
}
Map<String, dynamic> toJson() => {
'points_per_set': pointsPerSet,
'points_deciding_set': pointsDecidingSet,
'min_point_lead': minPointLead,
};
/// Punti necessari per chiudere il set corrente.
int pointsTarget(int currentSet) {
if (currentSet >= maxSets) return pointsDecidingSet;
return pointsPerSet;
}
/// Chi ha vinto il set in corso in base ai punti (null se ancora aperto).
ScoringSide? setWinnerFromPoints({
required int homePoints,
required int awayPoints,
required int currentSet,
}) {
final target = pointsTarget(currentSet);
final lead = minPointLead;
if (homePoints >= target && homePoints - awayPoints >= lead) {
return ScoringSide.home;
}
if (awayPoints >= target && awayPoints - homePoints >= lead) {
return ScoringSide.away;
}
return null;
}
bool isMatchOver({required int homeSets, required int awaySets}) {
return homeSets >= setsToWin || awaySets >= setsToWin;
}
ScoringSide? matchWinner({required int homeSets, required int awaySets}) {
if (homeSets >= setsToWin) return ScoringSide.home;
if (awaySets >= setsToWin) return ScoringSide.away;
return null;
}
}

View File

@@ -0,0 +1,158 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:x_action_cable_v2/x_action_cable.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import '../providers/score_provider.dart';
import '../providers/session_provider.dart';
import 'models/device_state.dart';
import 'models/score_state.dart';
final websocketServiceProvider = Provider<WebsocketService>((ref) {
final service = WebsocketService(ref);
ref.onDispose(service.dispose);
return service;
});
typedef CableMessageHandler = void Function(Map<String, dynamic> data);
class WebsocketService {
WebsocketService(this._ref);
final Ref _ref;
ActionCable? _cable;
ActionChannel? _channel;
String? _sessionId;
bool get isConnected => _cable != null && _channel != null;
Future<void> connect({
required String sessionId,
required String deviceRole,
}) async {
if (_sessionId == sessionId && isConnected) return;
await disconnect();
final token = _ref.read(authProvider).accessToken;
if (token == null || token.isEmpty) {
throw StateError('Token di accesso mancante');
}
_sessionId = sessionId;
_cable = ActionCable.connect(
AppConfig.cableUrl,
headers: {'Authorization': 'Bearer $token'},
onConnected: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onConnectionLost: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
onCannotConnect: (_) {
_ref.read(sessionProvider.notifier).setConnected(false);
},
);
_channel = _cable!.subscribe(
'SessionChannel',
channelParams: {
'session_id': sessionId,
'device_role': deviceRole,
},
onSubscribed: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onDisconnected: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
callbacks: [
ActionCallback(
name: 'receive_message',
callback: _handleMessage,
),
],
);
}
void _handleMessage(ActionResponse response) {
if (response.hasError || response.data == null) return;
_dispatch(Map<String, dynamic>.from(response.data!));
}
void _dispatch(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case 'score_update':
_ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data));
break;
case 'device_state':
_ref
.read(sessionProvider.notifier)
.updateDeviceState(DeviceStateModel.fromJson(data));
break;
case 'timeout':
final team = data['team'] as String?;
if (team == 'home') {
_ref.read(scoreProvider.notifier).setTimeoutHome(true);
} else if (team == 'away') {
_ref.read(scoreProvider.notifier).setTimeoutAway(true);
}
break;
case 'command':
final action = data['action'] as String?;
if (action != null) {
_ref.read(sessionProvider.notifier).applyCommand(action);
}
break;
default:
break;
}
}
void sendScoreUpdate(ScoreState score) {
_perform({'type': 'score_update', ...score.toCablePayload()});
}
void sendTimeout(String team) {
_perform({'type': 'timeout', 'team': team});
}
void sendCommand(String action) {
_perform({'type': 'command', 'action': action});
}
void sendDeviceState(DeviceStateModel state) {
_perform({
'type': 'device_state',
'device_role': state.deviceRole,
'battery': state.batteryLevel,
'network': state.networkType,
'bitrate': state.currentBitrate,
'fps': state.fps,
});
}
void _perform(Map<String, dynamic> payload) {
_channel?.performAction('receive', params: payload);
}
Future<void> disconnect() async {
_channel?.unsubscribe();
_channel = null;
_cable?.disconnect();
_cable = null;
_sessionId = null;
_ref.read(sessionProvider.notifier).setConnected(false);
}
void dispose() {
disconnect();
}
String encodeQrPayload(Map<String, dynamic> payload) => jsonEncode(payload);
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
/// Riga compatta metriche stream (sostituisce le MetricCard ingombranti in camera).
class CameraCompactMetrics extends StatelessWidget {
const CameraCompactMetrics({
super.key,
required this.networkType,
required this.bitrateMbps,
required this.fps,
this.batteryLevel,
this.viewerCount,
this.light = false,
});
final String networkType;
final double bitrateMbps;
final int fps;
final int? batteryLevel;
final int? viewerCount;
final bool light;
@override
Widget build(BuildContext context) {
final color = light ? Colors.white70 : Theme.of(context).textTheme.bodySmall?.color;
final parts = <String>[
networkType,
'${bitrateMbps.toStringAsFixed(1)} Mbps',
'$fps fps',
if (batteryLevel != null) '$batteryLevel%',
if (viewerCount != null && viewerCount! > 0) '$viewerCount spett.',
];
return Text(
parts.join(' · '),
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}

View File

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

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Dot verde + etichetta COLLEGATO.
class ConnectedBadge extends StatelessWidget {
const ConnectedBadge({
super.key,
this.connected = true,
this.label,
});
final bool connected;
final String? label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = connected ? AppTheme.successGreen : AppTheme.textSecondary;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
label ?? (connected ? 'COLLEGATO' : 'DISCONNESSO'),
style: theme.textTheme.labelLarge?.copyWith(
color: color,
fontSize: 12,
),
),
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// CTA distruttiva nera con icona rossa (INTERROMPI, FERMA TRASMISSIONE).
class DestructiveCta extends StatelessWidget {
const DestructiveCta({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.compact = false,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: compact ? null : double.infinity,
height: compact ? 44 : 52,
child: OutlinedButton(
onPressed: loading ? null : onPressed,
style: OutlinedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: AppTheme.primaryRed,
side: BorderSide(
color: AppTheme.primaryRed.withValues(alpha: 0.6),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(compact ? 10 : 12),
),
),
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primaryRed,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max,
children: [
const Icon(Icons.stop_circle_outlined, size: 20),
const SizedBox(width: 8),
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
fontSize: compact ? 12 : 14,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Conferma chiusura definitiva della diretta (non riprendibile).
Future<bool> confirmEndStream(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Chiudi diretta'),
content: const Text(
'La diretta verrà chiusa definitivamente. '
'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n'
'Per una pausa temporanea usa «Interrompi».',
),
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('Chiudi definitivamente'),
),
],
),
);
return result == true;
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Pill rossa con dot pulsante e timer diretta.
class LiveBadge extends StatelessWidget {
const LiveBadge({
super.key,
required this.elapsed,
this.compact = false,
});
final Duration elapsed;
final bool compact;
String _format(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (h > 0) return '$h:$m:$s';
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 4 : 6,
),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
SizedBox(width: compact ? 6 : 8),
Text(
compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}',
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.white,
fontSize: compact ? 11 : 13,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Wordmark orizzontale: MATCH + pill LIVE + TV.
class MatchLiveWordmark extends StatelessWidget {
const MatchLiveWordmark({
super.key,
this.compact = false,
this.showSlogan = false,
});
final bool compact;
final bool showSlogan;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final matchStyle = theme.textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w900,
letterSpacing: 1,
);
final tvStyle = theme.textTheme.displaySmall?.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w900,
letterSpacing: 1,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('MATCH', style: matchStyle),
const SizedBox(width: 8),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 12,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'LIVE',
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.white,
fontSize: compact ? 14 : 18,
),
),
),
const SizedBox(width: 8),
Text('TV', style: tvStyle),
],
),
if (showSlogan) ...[
const SizedBox(height: 12),
Text(
'LO STREAMING CHE NON MUORE',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
fontSize: 12,
letterSpacing: 2,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Card metrica singola (segnale, Mbps, fps).
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
this.icon,
this.highlight = false,
this.expand = true,
});
final String label;
final String value;
final IconData? icon;
final bool highlight;
final bool expand;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final card = Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: highlight
? Border.all(color: AppTheme.successGreen.withValues(alpha: 0.5))
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: AppTheme.textSecondary),
const SizedBox(height: 4),
],
Text(
value,
style: theme.textTheme.titleLarge?.copyWith(
color: highlight ? AppTheme.successGreen : Colors.white,
fontWeight: FontWeight.w900,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
textAlign: TextAlign.center,
),
],
),
);
if (!expand) {
return SizedBox(width: 72, child: card);
}
return Expanded(child: card);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// CTA primaria rossa full-width (AVANTI, INIZIA).
class PrimaryCta extends StatelessWidget {
const PrimaryCta({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: loading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white,
disabledBackgroundColor: AppTheme.primaryRed.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: 1,
),
),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, size: 20),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../scoring/match_scoring_rules.dart';
Future<bool> showSetWonDialog(
BuildContext context, {
required ScoringSide winner,
required String homeName,
required String awayName,
required int homePoints,
required int awayPoints,
}) async {
final winnerName = winner == ScoringSide.home ? homeName : awayName;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Set concluso'),
content: Text(
'$winnerName vince il set $homePoints-$awayPoints.\n\nChiudere il set e passare al successivo?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continua a segnare'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.accentYellow),
child: const Text('Chiudi set', style: TextStyle(color: Colors.black)),
),
],
),
);
return result == true;
}
Future<bool> showCloseSetAnywayDialog(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Chiudi set'),
content: const Text(
'Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Chiudi comunque'),
),
],
),
);
return result == true;
}
Future<bool> showMatchWonDialog(
BuildContext context, {
required ScoringSide winner,
required String homeName,
required String awayName,
required int homeSets,
required int awaySets,
}) async {
final winnerName = winner == ScoringSide.home ? homeName : awayName;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Partita terminata'),
content: Text(
'$winnerName vince la partita ($homeSets-$awaySets set).\n\nChiudere definitivamente la diretta?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continua in onda'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Chiudi diretta'),
),
],
),
);
return result == true;
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Barra overlay punteggio: SET n | HOME x - AWAY y.
class ScoreOverlayBar extends StatelessWidget {
const ScoreOverlayBar({
super.key,
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
this.homeSets,
this.awaySets,
this.compact = false,
this.lastDelta,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int? homeSets;
final int? awaySets;
final bool compact;
final int? lastDelta;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = Colors.black.withValues(alpha: 0.72);
final bar = Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 6 : 8,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(compact ? 8 : 10),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
pointsTarget != null ? 'SET $currentSet$pointsTarget' : 'SET $currentSet',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.accentYellow,
fontSize: compact ? 11 : 13,
),
),
Container(
width: 1,
height: compact ? 14 : 18,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.white24,
),
Text(
homeName.toUpperCase(),
style: theme.textTheme.labelLarge?.copyWith(
fontSize: compact ? 11 : 13,
),
),
const SizedBox(width: 6),
Text(
'$homePoints',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
fontSize: compact ? 16 : 20,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'-',
style: theme.textTheme.titleMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
),
Text(
'$awayPoints',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
fontSize: compact ? 16 : 20,
),
),
const SizedBox(width: 6),
Text(
awayName.toUpperCase(),
style: theme.textTheme.labelLarge?.copyWith(
fontSize: compact ? 11 : 13,
),
),
if (homeSets != null && awaySets != null) ...[
const SizedBox(width: 10),
Text(
'($homeSets-$awaySets)',
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
),
],
if (lastDelta != null && lastDelta != 0) ...[
const SizedBox(width: 8),
Text(
lastDelta! > 0 ? '+$lastDelta' : '$lastDelta',
style: theme.textTheme.labelLarge?.copyWith(
color: lastDelta! > 0 ? AppTheme.successGreen : AppTheme.primaryRed,
fontSize: compact ? 11 : 13,
),
),
],
],
),
);
return LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedWidth) {
return bar;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: bar,
);
},
);
}
}

View File

@@ -0,0 +1,84 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Padding di sistema (status bar, notch, barra navigazione) con minimo configurabile.
class ScreenInsets {
ScreenInsets._();
static EdgeInsets of(BuildContext context, {double minimum = 8}) {
final mq = MediaQuery.of(context);
final padding = mq.padding;
final view = mq.viewPadding;
return EdgeInsets.only(
left: math.max(math.max(padding.left, view.left), minimum),
top: math.max(math.max(padding.top, view.top), minimum),
right: math.max(math.max(padding.right, view.right), minimum),
bottom: math.max(math.max(padding.bottom, view.bottom), minimum),
);
}
/// Padding per contenuti scrollabili (login, wizard, elenco partite).
/// Overlay camera: in landscape su Android la barra nav può stare a sinistra.
static EdgeInsets cameraOverlay(BuildContext context, {double minimum = 12}) {
final base = of(context, minimum: minimum);
final landscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
if (landscape && base.left < 48) {
return base.copyWith(left: 48);
}
return base;
}
static EdgeInsets contentPadding(
BuildContext context, {
double horizontal = 20,
double vertical = 16,
double minimum = 8,
}) {
final inset = of(context, minimum: minimum);
return EdgeInsets.fromLTRB(
math.max(inset.left, horizontal),
math.max(inset.top, vertical),
math.max(inset.right, horizontal),
math.max(inset.bottom, vertical),
);
}
}
/// SafeArea con padding minimo su tutti i lati (gestisce edge-to-edge Android).
class AppSafeArea extends StatelessWidget {
const AppSafeArea({
super.key,
required this.child,
this.minimum = 8,
this.top = true,
this.bottom = true,
this.left = true,
this.right = true,
});
final Widget child;
final double minimum;
final bool top;
final bool bottom;
final bool left;
final bool right;
@override
Widget build(BuildContext context) {
return SafeArea(
minimum: EdgeInsets.only(
left: left ? minimum : 0,
top: top ? minimum : 0,
right: right ? minimum : 0,
bottom: bottom ? minimum : 0,
),
top: top,
bottom: bottom,
left: left,
right: right,
child: child,
);
}
}