Billing Stripe, link regia mobile e staff solo trasmissione.

Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 07:23:13 +02:00
parent 4083bc5dee
commit f4b7be0f80
156 changed files with 5033 additions and 2033 deletions

View File

@@ -176,6 +176,13 @@ class ApiClient {
return StreamSession.fromJson(response.data!);
}
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/regia_link',
);
return RegiaLinkResponse.fromJson(response.data!);
}
Future<PairingTokenResponse> createPairingToken(String sessionId) async {
final response = await _dio.post<Map<String, dynamic>>(
'/sessions/$sessionId/pairing_token',

View File

@@ -74,6 +74,23 @@ class StreamSession {
}
}
class RegiaLinkResponse {
const RegiaLinkResponse({
required this.regiaUrl,
required this.expiresAt,
});
final String regiaUrl;
final DateTime expiresAt;
factory RegiaLinkResponse.fromJson(Map<String, dynamic> json) {
return RegiaLinkResponse(
regiaUrl: json['regia_url'] as String,
expiresAt: DateTime.parse(json['expires_at'] as String),
);
}
}
class PairingTokenResponse {
const PairingTokenResponse({
required this.pairingToken,

View File

@@ -22,12 +22,18 @@ class Team {
this.recordingsEnabled = false,
this.phoneDownloadEnabled = false,
this.youtubeEnabled = false,
this.clubName,
this.staffRole,
this.canStream = true,
this.youtubeMode,
});
final String id;
final String name;
final String sport;
final String? clubName;
final String? staffRole;
final bool canStream;
final String? logoUrl;
final bool youtubeConnected;
final String planSlug;
@@ -54,16 +60,10 @@ class Team {
bool get canDownloadOnPhone => phoneDownloadEnabled;
String get staffLimitLabel {
if (maxStaffTransmission == null && maxStaffRegia == null) {
return 'illimitato';
if (maxStaffTransmission == null) {
return 'trasmissione illimitata';
}
final tx = maxStaffTransmission == null
? 'tx ∞'
: 'tx $staffTransmissionUsed/$maxStaffTransmission';
final rg = maxStaffRegia == null
? 'rg ∞'
: 'rg $staffRegiaUsed/$maxStaffRegia';
return '$tx · $rg';
return 'trasmissione $staffTransmissionUsed/$maxStaffTransmission';
}
factory Team.fromJson(Map<String, dynamic> json) {
@@ -71,6 +71,9 @@ class Team {
id: json['id'] as String,
name: json['name'] as String,
sport: json['sport'] as String? ?? 'volleyball',
clubName: json['club_name'] as String?,
staffRole: json['staff_role'] as String?,
canStream: json['can_stream'] as bool? ?? true,
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import 'api_client.dart';
Future<void> shareRegiaLink(
BuildContext context,
WidgetRef ref, {
required String sessionId,
required String shareSubject,
}) async {
try {
final link = await ref.read(apiClientProvider).createRegiaLink(sessionId);
await Share.share(
'Apri questo link per gestire il punteggio della diretta:\n${link.regiaUrl}',
subject: shareSubject,
);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore generazione link regia')),
);
}
}
}

View File

@@ -1,10 +1,8 @@
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).
/// Punteggio in sola lettura sulla camera (la regia avviene via link web).
class CameraScoreControls extends StatelessWidget {
const CameraScoreControls({
super.key,
@@ -15,11 +13,6 @@ class CameraScoreControls extends StatelessWidget {
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,
@@ -32,85 +25,10 @@ class CameraScoreControls extends StatelessWidget {
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(
@@ -125,179 +43,19 @@ class _PortraitControls extends StatelessWidget {
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,
compact: compact,
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),
if (!compact) ...[
const SizedBox(height: 8),
Text(
'Punteggio: condividi il link regia con chi gestisce il tabellone.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white54),
textAlign: TextAlign.center,
),
],
],
);
}
}
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,
),
),
],
),
),
),
);
}
}