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:
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user