Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
285 lines
9.6 KiB
Dart
285 lines
9.6 KiB
Dart
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|