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:
90
mobile/lib/app/router.dart
Normal file
90
mobile/lib/app/router.dart
Normal 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
93
mobile/lib/app/theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
64
mobile/lib/core/auth_storage.dart
Normal file
64
mobile/lib/core/auth_storage.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
24
mobile/lib/core/config.dart
Normal file
24
mobile/lib/core/config.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
113
mobile/lib/features/archive/archive_screen.dart
Normal file
113
mobile/lib/features/archive/archive_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
mobile/lib/features/auth/login_screen.dart
Normal file
157
mobile/lib/features/auth/login_screen.dart
Normal 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 Wi‑Fi.',
|
||||
);
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/features/auth/splash_screen.dart
Normal file
55
mobile/lib/features/auth/splash_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
652
mobile/lib/features/camera_mode/camera_screen.dart
Normal file
652
mobile/lib/features/camera_mode/camera_screen.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
134
mobile/lib/features/camera_mode/camera_screen_landscape.dart
Normal file
134
mobile/lib/features/camera_mode/camera_screen_landscape.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
mobile/lib/features/camera_mode/camera_screen_portrait.dart
Normal file
158
mobile/lib/features/camera_mode/camera_screen_portrait.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
mobile/lib/features/controller_mode/controller_landscape.dart
Normal file
188
mobile/lib/features/controller_mode/controller_landscape.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
187
mobile/lib/features/controller_mode/controller_portrait.dart
Normal file
187
mobile/lib/features/controller_mode/controller_portrait.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal file
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
401
mobile/lib/features/matches/matches_screen.dart
Normal file
401
mobile/lib/features/matches/matches_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
mobile/lib/features/matches/no_team_onboarding.dart
Normal file
56
mobile/lib/features/matches/no_team_onboarding.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/features/matches/resume_session_sheet.dart
Normal file
97
mobile/lib/features/matches/resume_session_sheet.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
34
mobile/lib/features/matches/team_providers.dart
Normal file
34
mobile/lib/features/matches/team_providers.dart
Normal 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);
|
||||
});
|
||||
284
mobile/lib/features/setup_wizard/step_match.dart
Normal file
284
mobile/lib/features/setup_wizard/step_match.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
295
mobile/lib/features/setup_wizard/step_network_test.dart
Normal file
295
mobile/lib/features/setup_wizard/step_network_test.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
238
mobile/lib/features/setup_wizard/step_roles.dart
Normal file
238
mobile/lib/features/setup_wizard/step_roles.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
331
mobile/lib/features/setup_wizard/step_transmission.dart
Normal file
331
mobile/lib/features/setup_wizard/step_transmission.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
173
mobile/lib/features/setup_wizard/wizard_shell.dart
Normal file
173
mobile/lib/features/setup_wizard/wizard_shell.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
mobile/lib/features/shared/live_score_actions.dart
Normal file
94
mobile/lib/features/shared/live_score_actions.dart
Normal 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
38
mobile/lib/main.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
53
mobile/lib/platform/streaming_channel.dart
Normal file
53
mobile/lib/platform/streaming_channel.dart
Normal 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;
|
||||
}
|
||||
103
mobile/lib/platform/streaming_preview.dart
Normal file
103
mobile/lib/platform/streaming_preview.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
90
mobile/lib/providers/auth_provider.dart
Normal file
90
mobile/lib/providers/auth_provider.dart
Normal 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(),
|
||||
);
|
||||
12
mobile/lib/providers/match_rules_provider.dart
Normal file
12
mobile/lib/providers/match_rules_provider.dart
Normal 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);
|
||||
});
|
||||
77
mobile/lib/providers/score_provider.dart
Normal file
77
mobile/lib/providers/score_provider.dart
Normal 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(),
|
||||
);
|
||||
165
mobile/lib/providers/session_provider.dart
Normal file
165
mobile/lib/providers/session_provider.dart
Normal 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(),
|
||||
);
|
||||
249
mobile/lib/shared/api_client.dart
Normal file
249
mobile/lib/shared/api_client.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
32
mobile/lib/shared/api_error.dart
Normal file
32
mobile/lib/shared/api_error.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/shared/models/device_state.dart
Normal file
37
mobile/lib/shared/models/device_state.dart
Normal 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;
|
||||
}
|
||||
81
mobile/lib/shared/models/match.dart
Normal file
81
mobile/lib/shared/models/match.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
35
mobile/lib/shared/models/recording_item.dart
Normal file
35
mobile/lib/shared/models/recording_item.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
mobile/lib/shared/models/score_state.dart
Normal file
100
mobile/lib/shared/models/score_state.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
mobile/lib/shared/models/stream_session.dart
Normal file
129
mobile/lib/shared/models/stream_session.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/shared/models/team.dart
Normal file
96
mobile/lib/shared/models/team.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
mobile/lib/shared/models/user.dart
Normal file
46
mobile/lib/shared/models/user.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/shared/premium_dialog.dart
Normal file
37
mobile/lib/shared/premium_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
70
mobile/lib/shared/scoring/match_scoring_rules.dart
Normal file
70
mobile/lib/shared/scoring/match_scoring_rules.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
158
mobile/lib/shared/websocket_service.dart
Normal file
158
mobile/lib/shared/websocket_service.dart
Normal 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);
|
||||
}
|
||||
39
mobile/lib/shared/widgets/camera_compact_metrics.dart
Normal file
39
mobile/lib/shared/widgets/camera_compact_metrics.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
303
mobile/lib/shared/widgets/camera_score_controls.dart
Normal file
303
mobile/lib/shared/widgets/camera_score_controls.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
mobile/lib/shared/widgets/connected_badge.dart
Normal file
42
mobile/lib/shared/widgets/connected_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
65
mobile/lib/shared/widgets/destructive_cta.dart
Normal file
65
mobile/lib/shared/widgets/destructive_cta.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
mobile/lib/shared/widgets/end_stream_dialog.dart
Normal file
31
mobile/lib/shared/widgets/end_stream_dialog.dart
Normal 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;
|
||||
}
|
||||
59
mobile/lib/shared/widgets/live_badge.dart
Normal file
59
mobile/lib/shared/widgets/live_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
mobile/lib/shared/widgets/match_live_wordmark.dart
Normal file
74
mobile/lib/shared/widgets/match_live_wordmark.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
65
mobile/lib/shared/widgets/metric_card.dart
Normal file
65
mobile/lib/shared/widgets/metric_card.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
66
mobile/lib/shared/widgets/primary_cta.dart
Normal file
66
mobile/lib/shared/widgets/primary_cta.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/shared/widgets/score_outcome_dialogs.dart
Normal file
96
mobile/lib/shared/widgets/score_outcome_dialogs.dart
Normal 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;
|
||||
}
|
||||
133
mobile/lib/shared/widgets/score_overlay_bar.dart
Normal file
133
mobile/lib/shared/widgets/score_overlay_bar.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
84
mobile/lib/shared/widgets/screen_insets.dart
Normal file
84
mobile/lib/shared/widgets/screen_insets.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user