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),
],
),
);
}
}