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,249 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import 'models/match.dart';
import 'models/recording_item.dart';
import 'models/stream_session.dart';
import 'models/team.dart';
import 'models/user.dart';
final apiClientProvider = Provider<ApiClient>((ref) {
final auth = ref.watch(authProvider);
return ApiClient(accessToken: auth.accessToken);
});
class ApiClient {
ApiClient({this.accessToken}) {
_dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiV1,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (accessToken != null && accessToken!.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
},
),
);
}
late final Dio _dio;
final String? accessToken;
// --- Auth ---
Future<({User user, AuthTokens tokens})> login({
required String email,
required String password,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/login',
data: {'email': email, 'password': password},
);
final data = response.data!;
return (
user: User.fromJson(data['user'] as Map<String, dynamic>),
tokens: AuthTokens.fromJson(data),
);
}
Future<AuthTokens> refresh(String refreshToken) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
return AuthTokens.fromJson(response.data!);
}
Future<User> me() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/me');
return User.fromJson(response.data!);
}
Future<void> logout() async {
await _dio.post('/auth/logout');
}
// --- Teams ---
Future<List<Team>> fetchTeams() async {
final response = await _dio.get<List<dynamic>>('/teams');
return response.data!
.map((e) => Team.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<RecordingItem>> fetchRecordings(String teamId) async {
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
return response.data!
.map((e) => RecordingItem.fromJson(e as Map<String, dynamic>))
.toList();
}
// --- Matches ---
Future<List<MatchModel>> fetchMatches(String teamId) async {
final response =
await _dio.get<List<dynamic>>('/teams/$teamId/matches');
return response.data!
.map((e) => MatchModel.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<MatchModel> fetchMatch(String matchId) async {
final response =
await _dio.get<Map<String, dynamic>>('/matches/$matchId');
return MatchModel.fromJson(response.data!);
}
Future<MatchModel> updateMatch(String matchId, Map<String, dynamic> body) async {
final response = await _dio.patch<Map<String, dynamic>>(
'/matches/$matchId',
data: {'match': body},
);
return MatchModel.fromJson(response.data!);
}
Future<void> deleteMatch(String matchId) async {
await _dio.delete<void>('/matches/$matchId');
}
Future<MatchModel> createMatch(String teamId, Map<String, dynamic> body) async {
final response = await _dio.post<Map<String, dynamic>>(
'/teams/$teamId/matches',
data: {'match': body},
);
return MatchModel.fromJson(response.data!);
}
// --- Sessions ---
Future<StreamSession> createSession(
String matchId, {
String platform = 'matchlivetv',
String privacyStatus = 'unlisted',
String qualityPreset = '720p_30_2.5mbps',
int? targetBitrate,
int? targetFps,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/matches/$matchId/sessions',
data: {
'platform': platform,
'privacy_status': privacyStatus,
'quality_preset': qualityPreset,
if (targetBitrate != null) 'target_bitrate': targetBitrate,
if (targetFps != null) 'target_fps': targetFps,
},
);
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> fetchSession(String sessionId) async {
final response =
await _dio.get<Map<String, dynamic>>('/sessions/$sessionId');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> startSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/start');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> stopSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/stop');
return StreamSession.fromJson(response.data!);
}
Future<StreamSession> pauseSession(String sessionId) async {
final response =
await _dio.patch<Map<String, dynamic>>('/sessions/$sessionId/pause');
return StreamSession.fromJson(response.data!);
}
Future<PairingTokenResponse> createPairingToken(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/pairing_token',
);
return PairingTokenResponse.fromJson(response.data!);
}
Future<StreamSession> claimPairing({
required String sessionId,
required String pairingToken,
required String deviceRole,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/claim_pairing',
data: {
'pairing_token': pairingToken,
'device_role': deviceRole,
},
);
return StreamSession.fromJson(response.data!);
}
Future<NetworkTestResult> networkTest({
required String sessionId,
required double downloadMbps,
required double uploadMbps,
required int latencyMs,
required String networkType,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/network_test',
data: {
'download_mbps': downloadMbps,
'upload_mbps': uploadMbps,
'latency_ms': latencyMs,
'network_type': networkType,
},
);
return NetworkTestResult.fromJson(response.data!);
}
Future<YoutubeStats> youtubeStats(String sessionId) async {
final response =
await _dio.get<Map<String, dynamic>>('/sessions/$sessionId/youtube_stats');
return YoutubeStats.fromJson(response.data!);
}
Future<void> postTelemetry({
required String sessionId,
required String deviceRole,
int? batteryLevel,
String? networkType,
int? signalStrength,
int? currentBitrate,
int? targetBitrate,
int? fps,
}) async {
await _dio.post(
'/sessions/$sessionId/telemetry',
data: {
'device_role': deviceRole,
if (batteryLevel != null) 'battery_level': batteryLevel,
if (networkType != null) 'network_type': networkType,
if (signalStrength != null) 'signal_strength': signalStrength,
if (currentBitrate != null) 'current_bitrate': currentBitrate,
if (targetBitrate != null) 'target_bitrate': targetBitrate,
if (fps != null) 'fps': fps,
},
);
}
}

View File

@@ -0,0 +1,32 @@
class ApiException implements Exception {
ApiException({
required this.message,
this.statusCode,
this.errorCode,
this.billingUrl,
});
final String message;
final int? statusCode;
final String? errorCode;
final String? billingUrl;
bool get isPremiumRequired => errorCode == 'premium_required';
@override
String toString() => message;
static ApiException? fromDio(dynamic error) {
// ignore: avoid_dynamic_calls
final response = error.response;
if (response == null) return null;
final data = response.data;
if (data is! Map<String, dynamic>) return null;
return ApiException(
message: data['error'] as String? ?? 'Errore di rete',
statusCode: response.statusCode as int?,
errorCode: data['error_code'] as String?,
billingUrl: data['billing_url'] as String?,
);
}
}

View File

@@ -0,0 +1,37 @@
class DeviceStateModel {
const DeviceStateModel({
this.deviceRole = 'camera',
this.batteryLevel,
this.networkType,
this.signalStrength,
this.currentBitrate,
this.targetBitrate,
this.fps,
this.status,
});
final String deviceRole;
final int? batteryLevel;
final String? networkType;
final int? signalStrength;
final int? currentBitrate;
final int? targetBitrate;
final int? fps;
final String? status;
factory DeviceStateModel.fromJson(Map<String, dynamic> json) {
return DeviceStateModel(
deviceRole: json['device_role'] as String? ?? 'camera',
batteryLevel: json['battery'] as int? ?? json['battery_level'] as int?,
networkType: json['network'] as String? ?? json['network_type'] as String?,
signalStrength: json['signal_strength'] as int?,
currentBitrate: json['bitrate'] as int? ?? json['current_bitrate'] as int?,
targetBitrate: json['target_bitrate'] as int?,
fps: json['fps'] as int?,
status: json['status'] as String?,
);
}
double get bitrateMbps =>
currentBitrate != null ? currentBitrate! / 1_000_000 : 0;
}

View File

@@ -0,0 +1,81 @@
class MatchModel {
const MatchModel({
required this.id,
required this.teamId,
required this.teamName,
required this.opponentName,
this.location,
this.scheduledAt,
this.sport = 'volleyball',
this.setsToWin = 3,
this.scoringRules,
this.rosterNumbers = const [],
this.category,
this.phase,
this.activeSessionId,
this.activeSessionStatus,
});
final String id;
final String teamId;
final String teamName;
final String opponentName;
final String? location;
final DateTime? scheduledAt;
final String sport;
final int setsToWin;
final Map<String, dynamic>? scoringRules;
final List<int> rosterNumbers;
final String? category;
final String? phase;
final String? activeSessionId;
final String? activeSessionStatus;
/// Sessione avviata (o in ripresa) — si può rientrare in camera senza rifare il wizard.
bool get canResumeCamera {
const resumable = {'connecting', 'live', 'reconnecting', 'paused'};
return activeSessionId != null &&
activeSessionStatus != null &&
resumable.contains(activeSessionStatus);
}
bool get hasActiveSession => activeSessionId != null;
factory MatchModel.fromJson(Map<String, dynamic> json) {
return MatchModel(
id: json['id'] as String,
teamId: json['team_id'] as String,
teamName: json['team_name'] as String? ?? '',
opponentName: json['opponent_name'] as String,
location: json['location'] as String?,
scheduledAt: json['scheduled_at'] != null
? DateTime.tryParse(json['scheduled_at'] as String)
: null,
sport: json['sport'] as String? ?? 'volleyball',
setsToWin: json['sets_to_win'] as int? ?? 3,
scoringRules: json['scoring_rules'] is Map
? Map<String, dynamic>.from(json['scoring_rules'] as Map)
: null,
rosterNumbers: (json['roster_numbers'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
category: json['category'] as String?,
phase: json['phase'] as String?,
activeSessionId: json['active_session_id'] as String?,
activeSessionStatus: json['active_session_status'] as String?,
);
}
Map<String, dynamic> toJson() => {
'opponent_name': opponentName,
'location': location,
'scheduled_at': scheduledAt?.toIso8601String(),
'sport': sport,
'sets_to_win': setsToWin,
if (scoringRules != null) 'scoring_rules': scoringRules,
'roster_numbers': rosterNumbers,
'category': category,
'phase': phase,
};
}

View File

@@ -0,0 +1,35 @@
class RecordingItem {
const RecordingItem({
required this.id,
required this.sessionId,
required this.opponentName,
required this.teamName,
this.endedAt,
this.replayUrl,
this.expiresAt,
});
final String id;
final String sessionId;
final String opponentName;
final String teamName;
final DateTime? endedAt;
final String? replayUrl;
final DateTime? expiresAt;
factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem(
id: json['id'] as String,
sessionId: json['session_id'] as String,
opponentName: json['opponent_name'] as String,
teamName: json['team_name'] as String,
endedAt: json['ended_at'] != null
? DateTime.parse(json['ended_at'] as String)
: null,
replayUrl: json['replay_url'] as String?,
expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String)
: null,
);
}
}

View File

@@ -0,0 +1,100 @@
class SetPartial {
const SetPartial({
required this.set,
required this.home,
required this.away,
});
final int set;
final int home;
final int away;
factory SetPartial.fromJson(Map<String, dynamic> json) {
return SetPartial(
set: json['set'] as int? ?? 1,
home: json['home'] as int? ?? 0,
away: json['away'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'set': set,
'home': home,
'away': away,
};
}
class ScoreState {
const ScoreState({
this.homeSets = 0,
this.awaySets = 0,
this.homePoints = 0,
this.awayPoints = 0,
this.currentSet = 1,
this.setPartials = const [],
this.timeoutHome = false,
this.timeoutAway = false,
});
final int homeSets;
final int awaySets;
final int homePoints;
final int awayPoints;
final int currentSet;
final List<SetPartial> setPartials;
final bool timeoutHome;
final bool timeoutAway;
factory ScoreState.fromJson(Map<String, dynamic> json) {
final rawPartials = json['set_partials'];
final partials = rawPartials is List
? rawPartials
.whereType<Map>()
.map((e) => SetPartial.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <SetPartial>[];
return ScoreState(
homeSets: json['home_sets'] as int? ?? 0,
awaySets: json['away_sets'] as int? ?? 0,
homePoints: json['home_points'] as int? ?? 0,
awayPoints: json['away_points'] as int? ?? 0,
currentSet: json['current_set'] as int? ?? 1,
setPartials: partials,
timeoutHome: json['timeout_home'] as bool? ?? false,
timeoutAway: json['timeout_away'] as bool? ?? false,
);
}
Map<String, dynamic> toCablePayload() => {
'type': 'score_update',
'home_sets': homeSets,
'away_sets': awaySets,
'home_points': homePoints,
'away_points': awayPoints,
'current_set': currentSet,
'set_partials': setPartials.map((p) => p.toJson()).toList(),
};
ScoreState copyWith({
int? homeSets,
int? awaySets,
int? homePoints,
int? awayPoints,
int? currentSet,
List<SetPartial>? setPartials,
bool? timeoutHome,
bool? timeoutAway,
}) {
return ScoreState(
homeSets: homeSets ?? this.homeSets,
awaySets: awaySets ?? this.awaySets,
homePoints: homePoints ?? this.homePoints,
awayPoints: awayPoints ?? this.awayPoints,
currentSet: currentSet ?? this.currentSet,
setPartials: setPartials ?? this.setPartials,
timeoutHome: timeoutHome ?? this.timeoutHome,
timeoutAway: timeoutAway ?? this.timeoutAway,
);
}
}

View File

@@ -0,0 +1,129 @@
import 'device_state.dart';
import 'score_state.dart';
class StreamSession {
const StreamSession({
required this.id,
required this.matchId,
required this.status,
this.platform = 'matchlivetv',
this.rtmpIngestUrl,
this.hlsPlaybackUrl,
this.watchPageUrl,
this.youtubeBroadcastId,
this.privacyStatus = 'unlisted',
this.qualityPreset = '720p_30_2.5mbps',
this.targetBitrate = 2500000,
this.startedAt,
this.disconnectionCount = 0,
this.score,
this.devices = const [],
});
final String id;
final String matchId;
final String status;
final String platform;
final String? rtmpIngestUrl;
final String? hlsPlaybackUrl;
final String? watchPageUrl;
final String? youtubeBroadcastId;
final String privacyStatus;
final String qualityPreset;
final int targetBitrate;
final DateTime? startedAt;
final int disconnectionCount;
final ScoreState? score;
final List<DeviceStateModel> devices;
bool get isLive => status == 'live' || status == 'reconnecting';
bool get isTerminal => status == 'ended' || status == 'error';
bool get canResumeBroadcast =>
status == 'connecting' ||
status == 'live' ||
status == 'reconnecting' ||
status == 'paused';
factory StreamSession.fromJson(Map<String, dynamic> json) {
return StreamSession(
id: json['id'] as String,
matchId: json['match_id'] as String,
status: json['status'] as String? ?? 'idle',
platform: json['platform'] as String? ?? 'matchlivetv',
rtmpIngestUrl: json['rtmp_ingest_url'] as String?,
hlsPlaybackUrl: json['hls_playback_url'] as String?,
watchPageUrl: json['watch_page_url'] as String?,
youtubeBroadcastId: json['youtube_broadcast_id'] as String?,
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps',
targetBitrate: json['target_bitrate'] as int? ?? 2500000,
startedAt: json['started_at'] != null
? DateTime.tryParse(json['started_at'] as String)
: null,
disconnectionCount: json['disconnection_count'] as int? ?? 0,
score: json['score'] != null
? ScoreState.fromJson(json['score'] as Map<String, dynamic>)
: null,
devices: (json['devices'] as List<dynamic>?)
?.map((e) => DeviceStateModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}
}
class PairingTokenResponse {
const PairingTokenResponse({
required this.pairingToken,
required this.expiresAt,
required this.qrPayload,
});
final String pairingToken;
final DateTime expiresAt;
final Map<String, dynamic> qrPayload;
factory PairingTokenResponse.fromJson(Map<String, dynamic> json) {
return PairingTokenResponse(
pairingToken: json['pairing_token'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String),
qrPayload: json['qr_payload'] as Map<String, dynamic>,
);
}
}
class NetworkTestResult {
const NetworkTestResult({
required this.ready,
required this.targetUploadMbps,
});
final bool ready;
final double targetUploadMbps;
factory NetworkTestResult.fromJson(Map<String, dynamic> json) {
return NetworkTestResult(
ready: json['ready'] as bool? ?? false,
targetUploadMbps: (json['target_upload_mbps'] as num?)?.toDouble() ?? 2.5,
);
}
}
class YoutubeStats {
const YoutubeStats({
required this.concurrentViewers,
required this.live,
});
final int concurrentViewers;
final bool live;
factory YoutubeStats.fromJson(Map<String, dynamic> json) {
return YoutubeStats(
concurrentViewers: json['concurrent_viewers'] as int? ?? 0,
live: json['live'] as bool? ?? false,
);
}
}

View File

@@ -0,0 +1,96 @@
class Team {
const Team({
required this.id,
required this.name,
required this.sport,
this.logoUrl,
this.youtubeConnected = false,
this.planSlug = 'free',
this.planName = 'Free',
this.premiumActive = false,
this.premiumFull = false,
this.billingUrl,
this.staffManageUrl,
this.maxStaff = 2,
this.staffUsed = 0,
this.maxStaffTransmission,
this.maxStaffRegia,
this.staffTransmissionUsed = 0,
this.staffRegiaUsed = 0,
this.concurrentStreamsUsed = 0,
this.concurrentStreamsLimit,
this.recordingsEnabled = false,
this.phoneDownloadEnabled = false,
this.youtubeEnabled = false,
this.youtubeMode,
});
final String id;
final String name;
final String sport;
final String? logoUrl;
final bool youtubeConnected;
final String planSlug;
final String planName;
final bool premiumActive;
final bool premiumFull;
final String? billingUrl;
final String? staffManageUrl;
final int maxStaff;
final int staffUsed;
final int? maxStaffTransmission;
final int? maxStaffRegia;
final int staffTransmissionUsed;
final int staffRegiaUsed;
final int concurrentStreamsUsed;
final int? concurrentStreamsLimit;
final bool recordingsEnabled;
final bool phoneDownloadEnabled;
final bool youtubeEnabled;
final String? youtubeMode;
bool get canUseYoutube => premiumFull && youtubeEnabled;
bool get canUseRecordings => recordingsEnabled;
bool get canDownloadOnPhone => phoneDownloadEnabled;
String get staffLimitLabel {
if (maxStaffTransmission == null && maxStaffRegia == null) {
return 'illimitato';
}
final tx = maxStaffTransmission == null
? 'tx ∞'
: 'tx $staffTransmissionUsed/$maxStaffTransmission';
final rg = maxStaffRegia == null
? 'rg ∞'
: 'rg $staffRegiaUsed/$maxStaffRegia';
return '$tx · $rg';
}
factory Team.fromJson(Map<String, dynamic> json) {
return Team(
id: json['id'] as String,
name: json['name'] as String,
sport: json['sport'] as String? ?? 'volleyball',
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',
planName: json['plan_name'] as String? ?? 'Free',
premiumActive: json['premium_active'] as bool? ?? false,
premiumFull: json['premium_full'] as bool? ?? false,
billingUrl: json['billing_url'] as String?,
staffManageUrl: json['staff_manage_url'] as String?,
maxStaff: json['max_staff'] as int? ?? 2,
staffUsed: json['staff_used'] as int? ?? 0,
maxStaffTransmission: json['max_staff_transmission'] as int?,
maxStaffRegia: json['max_staff_regia'] as int?,
staffTransmissionUsed: json['staff_transmission_used'] as int? ?? 0,
staffRegiaUsed: json['staff_regia_used'] as int? ?? 0,
concurrentStreamsUsed: json['concurrent_streams_used'] as int? ?? 0,
concurrentStreamsLimit: json['concurrent_streams_limit'] as int?,
recordingsEnabled: json['recordings_enabled'] as bool? ?? false,
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
youtubeMode: json['youtube_mode'] as String?,
);
}
}

View File

@@ -0,0 +1,46 @@
class User {
const User({
required this.id,
required this.email,
required this.name,
required this.role,
});
final String id;
final String email;
final String name;
final String role;
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
role: json['role'] as String? ?? 'coach',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'role': role,
};
}
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
});
final String accessToken;
final String refreshToken;
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app/theme.dart';
Future<void> showPremiumRequiredDialog(
BuildContext context, {
required String message,
String? billingUrl,
}) async {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Premium richiesto'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Chiudi'),
),
if (billingUrl != null && billingUrl.isNotEmpty)
FilledButton(
onPressed: () async {
final uri = Uri.parse(billingUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
if (ctx.mounted) Navigator.pop(ctx);
},
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Attiva sul sito'),
),
],
),
);
}

View File

@@ -0,0 +1,70 @@
import '../models/match.dart';
/// Lato che ha vinto set o partita.
enum ScoringSide { home, away }
/// Regole punteggio (pallavolo di default, campi sovrascrivibili per tornei).
class MatchScoringRules {
const MatchScoringRules({
required this.setsToWin,
this.pointsPerSet = 25,
this.pointsDecidingSet = 15,
this.minPointLead = 2,
});
final int setsToWin;
final int pointsPerSet;
final int pointsDecidingSet;
final int minPointLead;
int get maxSets => (setsToWin * 2) - 1;
factory MatchScoringRules.fromMatch(MatchModel match) {
final overrides = match.scoringRules;
return MatchScoringRules(
setsToWin: match.setsToWin,
pointsPerSet: overrides?['points_per_set'] as int? ?? 25,
pointsDecidingSet: overrides?['points_deciding_set'] as int? ?? 15,
minPointLead: overrides?['min_point_lead'] as int? ?? 2,
);
}
Map<String, dynamic> toJson() => {
'points_per_set': pointsPerSet,
'points_deciding_set': pointsDecidingSet,
'min_point_lead': minPointLead,
};
/// Punti necessari per chiudere il set corrente.
int pointsTarget(int currentSet) {
if (currentSet >= maxSets) return pointsDecidingSet;
return pointsPerSet;
}
/// Chi ha vinto il set in corso in base ai punti (null se ancora aperto).
ScoringSide? setWinnerFromPoints({
required int homePoints,
required int awayPoints,
required int currentSet,
}) {
final target = pointsTarget(currentSet);
final lead = minPointLead;
if (homePoints >= target && homePoints - awayPoints >= lead) {
return ScoringSide.home;
}
if (awayPoints >= target && awayPoints - homePoints >= lead) {
return ScoringSide.away;
}
return null;
}
bool isMatchOver({required int homeSets, required int awaySets}) {
return homeSets >= setsToWin || awaySets >= setsToWin;
}
ScoringSide? matchWinner({required int homeSets, required int awaySets}) {
if (homeSets >= setsToWin) return ScoringSide.home;
if (awaySets >= setsToWin) return ScoringSide.away;
return null;
}
}

View File

@@ -0,0 +1,158 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:x_action_cable_v2/x_action_cable.dart';
import '../core/config.dart';
import '../providers/auth_provider.dart';
import '../providers/score_provider.dart';
import '../providers/session_provider.dart';
import 'models/device_state.dart';
import 'models/score_state.dart';
final websocketServiceProvider = Provider<WebsocketService>((ref) {
final service = WebsocketService(ref);
ref.onDispose(service.dispose);
return service;
});
typedef CableMessageHandler = void Function(Map<String, dynamic> data);
class WebsocketService {
WebsocketService(this._ref);
final Ref _ref;
ActionCable? _cable;
ActionChannel? _channel;
String? _sessionId;
bool get isConnected => _cable != null && _channel != null;
Future<void> connect({
required String sessionId,
required String deviceRole,
}) async {
if (_sessionId == sessionId && isConnected) return;
await disconnect();
final token = _ref.read(authProvider).accessToken;
if (token == null || token.isEmpty) {
throw StateError('Token di accesso mancante');
}
_sessionId = sessionId;
_cable = ActionCable.connect(
AppConfig.cableUrl,
headers: {'Authorization': 'Bearer $token'},
onConnected: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onConnectionLost: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
onCannotConnect: (_) {
_ref.read(sessionProvider.notifier).setConnected(false);
},
);
_channel = _cable!.subscribe(
'SessionChannel',
channelParams: {
'session_id': sessionId,
'device_role': deviceRole,
},
onSubscribed: () {
_ref.read(sessionProvider.notifier).setConnected(true);
},
onDisconnected: () {
_ref.read(sessionProvider.notifier).setConnected(false);
},
callbacks: [
ActionCallback(
name: 'receive_message',
callback: _handleMessage,
),
],
);
}
void _handleMessage(ActionResponse response) {
if (response.hasError || response.data == null) return;
_dispatch(Map<String, dynamic>.from(response.data!));
}
void _dispatch(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case 'score_update':
_ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data));
break;
case 'device_state':
_ref
.read(sessionProvider.notifier)
.updateDeviceState(DeviceStateModel.fromJson(data));
break;
case 'timeout':
final team = data['team'] as String?;
if (team == 'home') {
_ref.read(scoreProvider.notifier).setTimeoutHome(true);
} else if (team == 'away') {
_ref.read(scoreProvider.notifier).setTimeoutAway(true);
}
break;
case 'command':
final action = data['action'] as String?;
if (action != null) {
_ref.read(sessionProvider.notifier).applyCommand(action);
}
break;
default:
break;
}
}
void sendScoreUpdate(ScoreState score) {
_perform({'type': 'score_update', ...score.toCablePayload()});
}
void sendTimeout(String team) {
_perform({'type': 'timeout', 'team': team});
}
void sendCommand(String action) {
_perform({'type': 'command', 'action': action});
}
void sendDeviceState(DeviceStateModel state) {
_perform({
'type': 'device_state',
'device_role': state.deviceRole,
'battery': state.batteryLevel,
'network': state.networkType,
'bitrate': state.currentBitrate,
'fps': state.fps,
});
}
void _perform(Map<String, dynamic> payload) {
_channel?.performAction('receive', params: payload);
}
Future<void> disconnect() async {
_channel?.unsubscribe();
_channel = null;
_cable?.disconnect();
_cable = null;
_sessionId = null;
_ref.read(sessionProvider.notifier).setConnected(false);
}
void dispose() {
disconnect();
}
String encodeQrPayload(Map<String, dynamic> payload) => jsonEncode(payload);
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
/// Riga compatta metriche stream (sostituisce le MetricCard ingombranti in camera).
class CameraCompactMetrics extends StatelessWidget {
const CameraCompactMetrics({
super.key,
required this.networkType,
required this.bitrateMbps,
required this.fps,
this.batteryLevel,
this.viewerCount,
this.light = false,
});
final String networkType;
final double bitrateMbps;
final int fps;
final int? batteryLevel;
final int? viewerCount;
final bool light;
@override
Widget build(BuildContext context) {
final color = light ? Colors.white70 : Theme.of(context).textTheme.bodySmall?.color;
final parts = <String>[
networkType,
'${bitrateMbps.toStringAsFixed(1)} Mbps',
'$fps fps',
if (batteryLevel != null) '$batteryLevel%',
if (viewerCount != null && viewerCount! > 0) '$viewerCount spett.',
];
return Text(
parts.join(' · '),
style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../../features/controller_mode/controller_screen.dart';
import 'score_overlay_bar.dart';
/// Controlli punteggio per overlay camera (portrait e landscape).
class CameraScoreControls extends StatelessWidget {
const CameraScoreControls({
super.key,
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.lastDelta,
this.compact = false,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? lastDelta;
final bool compact;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
if (compact) {
return _LandscapeControls(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
);
}
return _PortraitControls(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
);
}
}
class _PortraitControls extends StatelessWidget {
const _PortraitControls({
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
pointsTarget: pointsTarget,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ScoreTapButton(label: '+1 $homeName', onTap: onHomePoint, large: true),
),
const SizedBox(width: 8),
Expanded(
child: ScoreTapButton(label: '+1 $awayName', onTap: onAwayPoint, large: true),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: homePoints > 0 ? onHomeMinus : null,
child: Text('-1 $homeName', style: const TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: awayPoints > 0 ? onAwayMinus : null,
child: Text('-1 $awayName', style: const TextStyle(fontSize: 12)),
),
),
],
),
const SizedBox(height: 8),
YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet),
],
);
}
}
class _LandscapeControls extends StatelessWidget {
const _LandscapeControls({
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
required this.homeSets,
required this.awaySets,
required this.onHomePoint,
required this.onAwayPoint,
required this.onHomeMinus,
required this.onAwayMinus,
required this.onCloseSet,
this.lastDelta,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int homeSets;
final int awaySets;
final VoidCallback onHomePoint;
final VoidCallback onAwayPoint;
final VoidCallback onHomeMinus;
final VoidCallback onAwayMinus;
final VoidCallback onCloseSet;
final int? lastDelta;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: homePoints,
awayPoints: awayPoints,
currentSet: currentSet,
homeSets: homeSets,
awaySets: awaySets,
compact: true,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _CompactScoreBtn(label: '+1', sub: homeName, onTap: onHomePoint)),
const SizedBox(width: 6),
Expanded(child: _CompactScoreBtn(label: '+1', sub: awayName, onTap: onAwayPoint)),
const SizedBox(width: 6),
Expanded(
child: _CompactScoreBtn(
label: '-1',
sub: homeName,
onTap: homePoints > 0 ? onHomeMinus : null,
muted: true,
),
),
const SizedBox(width: 6),
Expanded(
child: _CompactScoreBtn(
label: '-1',
sub: awayName,
onTap: awayPoints > 0 ? onAwayMinus : null,
muted: true,
),
),
],
),
const SizedBox(height: 6),
YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet),
],
);
}
}
class _CompactScoreBtn extends StatelessWidget {
const _CompactScoreBtn({
required this.label,
required this.sub,
required this.onTap,
this.muted = false,
});
final String label;
final String sub;
final VoidCallback? onTap;
final bool muted;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Material(
color: muted
? Colors.black.withValues(alpha: enabled ? 0.65 : 0.35)
: AppTheme.primaryRed.withValues(alpha: enabled ? 0.92 : 0.45),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: enabled ? Colors.white : Colors.white38,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
Text(
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: enabled ? Colors.white70 : Colors.white30,
fontSize: 9,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Dot verde + etichetta COLLEGATO.
class ConnectedBadge extends StatelessWidget {
const ConnectedBadge({
super.key,
this.connected = true,
this.label,
});
final bool connected;
final String? label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = connected ? AppTheme.successGreen : AppTheme.textSecondary;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
label ?? (connected ? 'COLLEGATO' : 'DISCONNESSO'),
style: theme.textTheme.labelLarge?.copyWith(
color: color,
fontSize: 12,
),
),
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// CTA distruttiva nera con icona rossa (INTERROMPI, FERMA TRASMISSIONE).
class DestructiveCta extends StatelessWidget {
const DestructiveCta({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.compact = false,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: compact ? null : double.infinity,
height: compact ? 44 : 52,
child: OutlinedButton(
onPressed: loading ? null : onPressed,
style: OutlinedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: AppTheme.primaryRed,
side: BorderSide(
color: AppTheme.primaryRed.withValues(alpha: 0.6),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(compact ? 10 : 12),
),
),
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primaryRed,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max,
children: [
const Icon(Icons.stop_circle_outlined, size: 20),
const SizedBox(width: 8),
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
fontSize: compact ? 12 : 14,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Conferma chiusura definitiva della diretta (non riprendibile).
Future<bool> confirmEndStream(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Chiudi diretta'),
content: const Text(
'La diretta verrà chiusa definitivamente. '
'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n'
'Per una pausa temporanea usa «Interrompi».',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Chiudi definitivamente'),
),
],
),
);
return result == true;
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Pill rossa con dot pulsante e timer diretta.
class LiveBadge extends StatelessWidget {
const LiveBadge({
super.key,
required this.elapsed,
this.compact = false,
});
final Duration elapsed;
final bool compact;
String _format(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (h > 0) return '$h:$m:$s';
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 4 : 6,
),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
SizedBox(width: compact ? 6 : 8),
Text(
compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}',
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.white,
fontSize: compact ? 11 : 13,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Wordmark orizzontale: MATCH + pill LIVE + TV.
class MatchLiveWordmark extends StatelessWidget {
const MatchLiveWordmark({
super.key,
this.compact = false,
this.showSlogan = false,
});
final bool compact;
final bool showSlogan;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final matchStyle = theme.textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w900,
letterSpacing: 1,
);
final tvStyle = theme.textTheme.displaySmall?.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w900,
letterSpacing: 1,
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('MATCH', style: matchStyle),
const SizedBox(width: 8),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 12,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: AppTheme.primaryRed,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'LIVE',
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.white,
fontSize: compact ? 14 : 18,
),
),
),
const SizedBox(width: 8),
Text('TV', style: tvStyle),
],
),
if (showSlogan) ...[
const SizedBox(height: 12),
Text(
'LO STREAMING CHE NON MUORE',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
fontSize: 12,
letterSpacing: 2,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Card metrica singola (segnale, Mbps, fps).
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
this.icon,
this.highlight = false,
this.expand = true,
});
final String label;
final String value;
final IconData? icon;
final bool highlight;
final bool expand;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final card = Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: highlight
? Border.all(color: AppTheme.successGreen.withValues(alpha: 0.5))
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: AppTheme.textSecondary),
const SizedBox(height: 4),
],
Text(
value,
style: theme.textTheme.titleLarge?.copyWith(
color: highlight ? AppTheme.successGreen : Colors.white,
fontWeight: FontWeight.w900,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
textAlign: TextAlign.center,
),
],
),
);
if (!expand) {
return SizedBox(width: 72, child: card);
}
return Expanded(child: card);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// CTA primaria rossa full-width (AVANTI, INIZIA).
class PrimaryCta extends StatelessWidget {
const PrimaryCta({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: loading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
foregroundColor: Colors.white,
disabledBackgroundColor: AppTheme.primaryRed.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: 1,
),
),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, size: 20),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../scoring/match_scoring_rules.dart';
Future<bool> showSetWonDialog(
BuildContext context, {
required ScoringSide winner,
required String homeName,
required String awayName,
required int homePoints,
required int awayPoints,
}) async {
final winnerName = winner == ScoringSide.home ? homeName : awayName;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Set concluso'),
content: Text(
'$winnerName vince il set $homePoints-$awayPoints.\n\nChiudere il set e passare al successivo?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continua a segnare'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.accentYellow),
child: const Text('Chiudi set', style: TextStyle(color: Colors.black)),
),
],
),
);
return result == true;
}
Future<bool> showCloseSetAnywayDialog(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Chiudi set'),
content: const Text(
'Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Chiudi comunque'),
),
],
),
);
return result == true;
}
Future<bool> showMatchWonDialog(
BuildContext context, {
required ScoringSide winner,
required String homeName,
required String awayName,
required int homeSets,
required int awaySets,
}) async {
final winnerName = winner == ScoringSide.home ? homeName : awayName;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Partita terminata'),
content: Text(
'$winnerName vince la partita ($homeSets-$awaySets set).\n\nChiudere definitivamente la diretta?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continua in onda'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Chiudi diretta'),
),
],
),
);
return result == true;
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
/// Barra overlay punteggio: SET n | HOME x - AWAY y.
class ScoreOverlayBar extends StatelessWidget {
const ScoreOverlayBar({
super.key,
required this.homeName,
required this.awayName,
required this.homePoints,
required this.awayPoints,
required this.currentSet,
this.homeSets,
this.awaySets,
this.compact = false,
this.lastDelta,
this.pointsTarget,
});
final String homeName;
final String awayName;
final int homePoints;
final int awayPoints;
final int currentSet;
final int? homeSets;
final int? awaySets;
final bool compact;
final int? lastDelta;
final int? pointsTarget;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = Colors.black.withValues(alpha: 0.72);
final bar = Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 6 : 8,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(compact ? 8 : 10),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
pointsTarget != null ? 'SET $currentSet$pointsTarget' : 'SET $currentSet',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.accentYellow,
fontSize: compact ? 11 : 13,
),
),
Container(
width: 1,
height: compact ? 14 : 18,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.white24,
),
Text(
homeName.toUpperCase(),
style: theme.textTheme.labelLarge?.copyWith(
fontSize: compact ? 11 : 13,
),
),
const SizedBox(width: 6),
Text(
'$homePoints',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
fontSize: compact ? 16 : 20,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'-',
style: theme.textTheme.titleMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
),
Text(
'$awayPoints',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
fontSize: compact ? 16 : 20,
),
),
const SizedBox(width: 6),
Text(
awayName.toUpperCase(),
style: theme.textTheme.labelLarge?.copyWith(
fontSize: compact ? 11 : 13,
),
),
if (homeSets != null && awaySets != null) ...[
const SizedBox(width: 10),
Text(
'($homeSets-$awaySets)',
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
),
],
if (lastDelta != null && lastDelta != 0) ...[
const SizedBox(width: 8),
Text(
lastDelta! > 0 ? '+$lastDelta' : '$lastDelta',
style: theme.textTheme.labelLarge?.copyWith(
color: lastDelta! > 0 ? AppTheme.successGreen : AppTheme.primaryRed,
fontSize: compact ? 11 : 13,
),
),
],
],
),
);
return LayoutBuilder(
builder: (context, constraints) {
if (!constraints.hasBoundedWidth) {
return bar;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: bar,
);
},
);
}
}

View File

@@ -0,0 +1,84 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Padding di sistema (status bar, notch, barra navigazione) con minimo configurabile.
class ScreenInsets {
ScreenInsets._();
static EdgeInsets of(BuildContext context, {double minimum = 8}) {
final mq = MediaQuery.of(context);
final padding = mq.padding;
final view = mq.viewPadding;
return EdgeInsets.only(
left: math.max(math.max(padding.left, view.left), minimum),
top: math.max(math.max(padding.top, view.top), minimum),
right: math.max(math.max(padding.right, view.right), minimum),
bottom: math.max(math.max(padding.bottom, view.bottom), minimum),
);
}
/// Padding per contenuti scrollabili (login, wizard, elenco partite).
/// Overlay camera: in landscape su Android la barra nav può stare a sinistra.
static EdgeInsets cameraOverlay(BuildContext context, {double minimum = 12}) {
final base = of(context, minimum: minimum);
final landscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
if (landscape && base.left < 48) {
return base.copyWith(left: 48);
}
return base;
}
static EdgeInsets contentPadding(
BuildContext context, {
double horizontal = 20,
double vertical = 16,
double minimum = 8,
}) {
final inset = of(context, minimum: minimum);
return EdgeInsets.fromLTRB(
math.max(inset.left, horizontal),
math.max(inset.top, vertical),
math.max(inset.right, horizontal),
math.max(inset.bottom, vertical),
);
}
}
/// SafeArea con padding minimo su tutti i lati (gestisce edge-to-edge Android).
class AppSafeArea extends StatelessWidget {
const AppSafeArea({
super.key,
required this.child,
this.minimum = 8,
this.top = true,
this.bottom = true,
this.left = true,
this.right = true,
});
final Widget child;
final double minimum;
final bool top;
final bool bottom;
final bool left;
final bool right;
@override
Widget build(BuildContext context) {
return SafeArea(
minimum: EdgeInsets.only(
left: left ? minimum : 0,
top: top ? minimum : 0,
right: right ? minimum : 0,
bottom: bottom ? minimum : 0,
),
top: top,
bottom: bottom,
left: left,
right: right,
child: child,
);
}
}