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

@@ -14,26 +14,9 @@ import '../../shared/widgets/screen_insets.dart';
import 'resume_session_sheet.dart';
import 'no_team_onboarding.dart';
import 'schedule_match_sheet.dart';
import 'team_picker.dart';
import 'team_providers.dart';
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) return [];
final client = ref.read(apiClientProvider);
final list = await client.fetchMatches(teams.first.id);
list.sort((a, b) {
final sa = a.scheduledAt;
final sb = b.scheduledAt;
if (sa == null && sb == null) return 0;
if (sa == null) return 1;
if (sb == null) return -1;
return sa.compareTo(sb);
});
return list;
});
class MatchesScreen extends ConsumerWidget {
const MatchesScreen({super.key});
@@ -54,7 +37,7 @@ class MatchesScreen extends ConsumerWidget {
icon: const Icon(Icons.group_outlined),
tooltip: 'Gestisci staff',
onPressed: () async {
final team = await ref.read(primaryTeamProvider.future);
final team = await ref.read(activeTeamProvider.future);
final url = team?.staffManageUrl ?? team?.billingUrl;
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
@@ -126,6 +109,7 @@ class MatchesScreen extends ConsumerWidget {
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async {
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
await ref.read(matchesProvider.future);
},
@@ -146,6 +130,7 @@ class MatchesScreen extends ConsumerWidget {
'Le tue partite',
style: theme.textTheme.bodyMedium,
),
const TeamPickerBar(),
if (activeMatch != null) ...[
const SizedBox(height: 12),
Material(
@@ -315,8 +300,8 @@ class MatchesScreen extends ConsumerWidget {
}
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
@@ -325,12 +310,12 @@ class MatchesScreen extends ConsumerWidget {
return;
}
final data = await showScheduleMatchSheet(context);
final data = await showScheduleMatchSheet(context, teamName: team.name);
if (data == null || !context.mounted) return;
try {
final client = ref.read(apiClientProvider);
final match = await client.createMatch(teams.first.id, {
final match = await client.createMatch(team.id, {
'opponent_name': data.opponentName,
'scheduled_at': data.scheduledAt.toUtc().toIso8601String(),
if (data.location != null) 'location': data.location,
@@ -350,7 +335,7 @@ class MatchesScreen extends ConsumerWidget {
backgroundColor: AppTheme.surface,
title: const Text('Configurare ora?'),
content: const Text(
'Puoi preparare telefono e regia subito, oppure tornare quando sei in palestra.',
'Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.',
),
actions: [
TextButton(
@@ -378,8 +363,8 @@ class MatchesScreen extends ConsumerWidget {
}
Future<void> _createMatchQuick(BuildContext context, WidgetRef ref) async {
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
@@ -389,7 +374,7 @@ class MatchesScreen extends ConsumerWidget {
}
final client = ref.read(apiClientProvider);
final match = await client.createMatch(teams.first.id, {
final match = await client.createMatch(team.id, {
'opponent_name': 'Avversario',
'sport': 'volleyball',
'sets_to_win': 3,

View File

@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/models/match.dart';
import '../../shared/regia_share.dart';
/// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.).
void showResumeSessionSheet(
@@ -51,7 +51,6 @@ void showResumeSessionSheet(
if (match.canResumeCamera)
FilledButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
Navigator.pop(ctx);
context.push('/session/$sessionId/camera');
},
@@ -76,12 +75,16 @@ void showResumeSessionSheet(
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller);
Navigator.pop(ctx);
context.push('/session/$sessionId/controller');
shareRegiaLink(
ctx,
ref,
sessionId: sessionId,
shareSubject:
'Regia — ${match.teamName} vs ${match.opponentName}',
);
},
icon: const Icon(Icons.sports_esports),
label: const Text('Apri regia (punteggio)'),
icon: const Icon(Icons.share),
label: const Text('Condividi link regia'),
),
const SizedBox(height: 8),
TextButton(

View File

@@ -16,7 +16,10 @@ class ScheduleMatchResult {
final String? location;
}
Future<ScheduleMatchResult?> showScheduleMatchSheet(BuildContext context) {
Future<ScheduleMatchResult?> showScheduleMatchSheet(
BuildContext context, {
String? teamName,
}) {
return showModalBottomSheet<ScheduleMatchResult>(
context: context,
isScrollControlled: true,
@@ -24,12 +27,14 @@ Future<ScheduleMatchResult?> showScheduleMatchSheet(BuildContext context) {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => const _ScheduleMatchSheet(),
builder: (ctx) => _ScheduleMatchSheet(teamName: teamName),
);
}
class _ScheduleMatchSheet extends StatefulWidget {
const _ScheduleMatchSheet();
const _ScheduleMatchSheet({this.teamName});
final String? teamName;
@override
State<_ScheduleMatchSheet> createState() => _ScheduleMatchSheetState();
@@ -123,6 +128,16 @@ class _ScheduleMatchSheetState extends State<_ScheduleMatchSheet> {
),
const SizedBox(height: 16),
Text('Programma partita', style: theme.textTheme.titleLarge),
if (widget.teamName != null && widget.teamName!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.teamName!,
style: theme.textTheme.titleSmall?.copyWith(
color: AppTheme.primaryRed,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 6),
Text(
'La diretta non parte ora: comparirà sul sito con data e ora. '

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme.dart';
import '../../shared/models/team.dart';
import 'team_providers.dart';
/// Selettore squadra quando l'utente ne gestisce più di una (staff trasmissione / società).
class TeamPickerBar extends ConsumerWidget {
const TeamPickerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamsAsync = ref.watch(teamsProvider);
final activeAsync = ref.watch(activeTeamProvider);
return teamsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (teams) {
if (teams.length <= 1) return const SizedBox.shrink();
final active = activeAsync.valueOrNull;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => _showTeamSheet(context, ref, teams, active?.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
const Icon(Icons.groups_outlined, color: AppTheme.primaryRed, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Squadra per la diretta',
style: theme.textTheme.labelMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
Text(
active?.name ?? 'Seleziona squadra',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (active?.clubName != null && active!.clubName!.isNotEmpty)
Text(
active.clubName!,
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(Icons.expand_more, color: AppTheme.textSecondary),
],
),
),
),
),
);
},
);
}
Future<void> _showTeamSheet(
BuildContext context,
WidgetRef ref,
List<Team> teams,
String? selectedId,
) async {
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
'Scegli squadra',
style: Theme.of(ctx).textTheme.titleLarge,
),
),
...teams.map((team) {
final selected = team.id == selectedId;
return ListTile(
leading: Icon(
selected ? Icons.check_circle : Icons.circle_outlined,
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
),
title: Text(team.name),
subtitle: team.clubName != null && team.clubName!.isNotEmpty
? Text(team.clubName!)
: null,
onTap: () => Navigator.pop(ctx, team.id),
);
}),
const SizedBox(height: 8),
],
),
);
},
);
if (picked == null) return;
await ref.read(selectedTeamIdProvider.notifier).select(picked);
ref.invalidate(matchesProvider);
}
}

View File

@@ -1,10 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/team_selection_storage.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/models/team.dart';
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final team = await ref.watch(activeTeamProvider.future);
if (team == null) return [];
final client = ref.read(apiClientProvider);
final list = await client.fetchMatches(team.id);
list.sort((a, b) {
final sa = a.scheduledAt;
final sb = b.scheduledAt;
if (sa == null && sb == null) return 0;
if (sa == null) return 1;
if (sb == null) return -1;
return sa.compareTo(sb);
});
return list;
});
final teamsProvider = FutureProvider<List<Team>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
@@ -12,12 +32,38 @@ final teamsProvider = FutureProvider<List<Team>>((ref) async {
return client.fetchTeams();
});
final primaryTeamProvider = FutureProvider<Team?>((ref) async {
final selectedTeamIdProvider =
AsyncNotifierProvider<SelectedTeamIdNotifier, String?>(SelectedTeamIdNotifier.new);
class SelectedTeamIdNotifier extends AsyncNotifier<String?> {
@override
Future<String?> build() => TeamSelectionStorage.load();
Future<void> select(String teamId) async {
await TeamSelectionStorage.save(teamId);
state = AsyncData(teamId);
}
}
/// Squadra attiva: preferisce la selezione salvata, altrimenti la prima disponibile.
final activeTeamProvider = FutureProvider<Team?>((ref) async {
final teams = await ref.watch(teamsProvider.future);
if (teams.isEmpty) return null;
return teams.first;
var selectedId = ref.watch(selectedTeamIdProvider).valueOrNull;
final valid = selectedId != null && teams.any((t) => t.id == selectedId);
if (!valid) {
selectedId = teams.first.id;
await ref.read(selectedTeamIdProvider.notifier).select(selectedId);
}
return teams.firstWhere((t) => t.id == selectedId);
});
/// Compatibilità con codice esistente.
final primaryTeamProvider = activeTeamProvider;
final teamByIdProvider = FutureProvider.family<Team?, String>((ref, teamId) async {
final teams = await ref.watch(teamsProvider.future);
for (final t in teams) {