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