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