Initial commit: monorepo Match Live TV.

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

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

View File

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

View File

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

View File

@@ -0,0 +1,238 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepRoles extends ConsumerStatefulWidget {
const StepRoles({
super.key,
required this.match,
required this.onBack,
required this.onNext,
});
final MatchModel match;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<StepRoles> createState() => _StepRolesState();
}
class _StepRolesState extends ConsumerState<StepRoles> {
DeviceRole _role = DeviceRole.controller;
String? _qrData;
bool _loadingQr = false;
Future<void> _generateQr() async {
final session = ref.read(sessionProvider).session;
if (session == null) return;
setState(() => _loadingQr = true);
try {
final client = ref.read(apiClientProvider);
final response = await client.createPairingToken(session.id);
setState(() {
_qrData = jsonEncode(response.qrPayload);
});
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore generazione QR')),
);
}
} finally {
if (mounted) setState(() => _loadingQr = false);
}
}
void _confirm() {
ref.read(sessionProvider.notifier).setDeviceRole(_role);
widget.onNext();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Scegli il tuo ruolo', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _RoleCard(
title: 'CAMERA',
subtitle: 'Sul cavalletto',
icon: Icons.videocam,
selected: _role == DeviceRole.camera,
onTap: () => setState(() => _role = DeviceRole.camera),
),
),
const SizedBox(width: 12),
Expanded(
child: _RoleCard(
title: 'REGIA',
subtitle: 'In mano',
icon: Icons.sports_esports,
selected: _role == DeviceRole.controller,
onTap: () => setState(() => _role = DeviceRole.controller),
),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.accentYellow.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.accentYellow.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.battery_charging_full, color: AppTheme.accentYellow),
const SizedBox(width: 12),
Expanded(
child: Text(
'Mantieni in carica. Saranno 90 minuti a pieno carico.',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white),
),
),
],
),
),
const SizedBox(height: 24),
Text('Collega il secondo telefono', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: _qrData != null
? QrImageView(
data: _qrData!,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.white,
)
: SizedBox(
width: 180,
height: 180,
child: _loadingQr
? const Center(
child: CircularProgressIndicator(
color: AppTheme.primaryRed,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code, size: 48, color: Colors.black54),
const SizedBox(height: 8),
TextButton(
onPressed: _generateQr,
child: const Text(
'GENERA QR',
style: TextStyle(color: Colors.black),
),
),
],
),
),
),
),
const SizedBox(height: 8),
Text(
'Scansiona con l\'altro dispositivo per collegare camera e regia.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'AVANTI >',
onPressed: _confirm,
icon: Icons.arrow_forward,
),
),
],
),
],
),
);
}
}
class _RoleCard extends StatelessWidget {
const _RoleCard({
required this.title,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
final String title;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selected
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppTheme.primaryRed : Colors.transparent,
),
),
child: Column(
children: [
Icon(icon, size: 32, color: selected ? AppTheme.primaryRed : Colors.white),
const SizedBox(height: 8),
Text(title, style: theme.textTheme.titleMedium),
Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
],
),
),
),
);
}
}

View File

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

View File

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