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:
249
mobile/lib/shared/api_client.dart
Normal file
249
mobile/lib/shared/api_client.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
32
mobile/lib/shared/api_error.dart
Normal file
32
mobile/lib/shared/api_error.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/shared/models/device_state.dart
Normal file
37
mobile/lib/shared/models/device_state.dart
Normal 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;
|
||||
}
|
||||
81
mobile/lib/shared/models/match.dart
Normal file
81
mobile/lib/shared/models/match.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
35
mobile/lib/shared/models/recording_item.dart
Normal file
35
mobile/lib/shared/models/recording_item.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
mobile/lib/shared/models/score_state.dart
Normal file
100
mobile/lib/shared/models/score_state.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
129
mobile/lib/shared/models/stream_session.dart
Normal file
129
mobile/lib/shared/models/stream_session.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/shared/models/team.dart
Normal file
96
mobile/lib/shared/models/team.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
46
mobile/lib/shared/models/user.dart
Normal file
46
mobile/lib/shared/models/user.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/lib/shared/premium_dialog.dart
Normal file
37
mobile/lib/shared/premium_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
70
mobile/lib/shared/scoring/match_scoring_rules.dart
Normal file
70
mobile/lib/shared/scoring/match_scoring_rules.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
158
mobile/lib/shared/websocket_service.dart
Normal file
158
mobile/lib/shared/websocket_service.dart
Normal 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);
|
||||
}
|
||||
39
mobile/lib/shared/widgets/camera_compact_metrics.dart
Normal file
39
mobile/lib/shared/widgets/camera_compact_metrics.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
303
mobile/lib/shared/widgets/camera_score_controls.dart
Normal file
303
mobile/lib/shared/widgets/camera_score_controls.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
mobile/lib/shared/widgets/connected_badge.dart
Normal file
42
mobile/lib/shared/widgets/connected_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
65
mobile/lib/shared/widgets/destructive_cta.dart
Normal file
65
mobile/lib/shared/widgets/destructive_cta.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
mobile/lib/shared/widgets/end_stream_dialog.dart
Normal file
31
mobile/lib/shared/widgets/end_stream_dialog.dart
Normal 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;
|
||||
}
|
||||
59
mobile/lib/shared/widgets/live_badge.dart
Normal file
59
mobile/lib/shared/widgets/live_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
mobile/lib/shared/widgets/match_live_wordmark.dart
Normal file
74
mobile/lib/shared/widgets/match_live_wordmark.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
65
mobile/lib/shared/widgets/metric_card.dart
Normal file
65
mobile/lib/shared/widgets/metric_card.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
66
mobile/lib/shared/widgets/primary_cta.dart
Normal file
66
mobile/lib/shared/widgets/primary_cta.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/shared/widgets/score_outcome_dialogs.dart
Normal file
96
mobile/lib/shared/widgets/score_outcome_dialogs.dart
Normal 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;
|
||||
}
|
||||
133
mobile/lib/shared/widgets/score_overlay_bar.dart
Normal file
133
mobile/lib/shared/widgets/score_overlay_bar.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
84
mobile/lib/shared/widgets/screen_insets.dart
Normal file
84
mobile/lib/shared/widgets/screen_insets.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user