Billing upgrade/downgrade, inviti in app e diretta YouTube da mobile.

Upgrade Stripe con addebito immediato; downgrade programmato a fine periodo.
UX billing più sobria con box info; icone piani. API inviti e OAuth YouTube
per staff trasmissione; deep link join; scelta partita programmata o nuova.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 22:15:27 +02:00
parent 2cb267f991
commit 566104aff4
49 changed files with 1615 additions and 173 deletions

View File

@@ -5,16 +5,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../features/matches/matches_screen.dart';
import '../../core/invite_storage.dart';
import '../../features/matches/team_providers.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/invite_preview.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
const LoginScreen({super.key, this.inviteToken});
final String? inviteToken;
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
@@ -22,9 +25,44 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'coach@matchlivetv.test');
final _emailController = TextEditingController();
final _passwordController = TextEditingController(text: 'password123');
bool _obscure = true;
InvitePreview? _invite;
String? _inviteToken;
bool _loadingInvite = false;
@override
void initState() {
super.initState();
_bootstrapInvite();
}
Future<void> _bootstrapInvite() async {
_inviteToken = widget.inviteToken ?? await InviteStorage.readToken();
if (_inviteToken == null) return;
setState(() => _loadingInvite = true);
try {
final preview = await ApiClient().fetchInvitePreview(_inviteToken!);
if (!mounted) return;
setState(() {
_invite = preview;
if (preview.email.isNotEmpty) {
_emailController.text = preview.email;
}
});
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invito non valido o scaduto')),
);
}
await InviteStorage.clear();
} finally {
if (mounted) setState(() => _loadingInvite = false);
}
}
@override
void dispose() {
@@ -33,6 +71,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
super.dispose();
}
Future<void> _acceptInviteIfNeeded(ApiClient client) async {
final token = _inviteToken;
if (token == null) return;
try {
final message = await client.acceptInvitation(token);
await InviteStorage.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
} on DioException catch (e) {
final err = e.response?.data;
final msg = err is Map ? (err['error'] as String?) : null;
if (mounted && msg != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
rethrow;
}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
@@ -56,12 +116,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
final authed = ApiClient(accessToken: result.tokens.accessToken);
if (_inviteToken != null) {
await _acceptInviteIfNeeded(authed);
}
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
ref.read(authProvider.notifier).setLoading(false);
if (mounted) context.go('/matches');
return;
} on DioException catch (e) {
if (e.response?.statusCode == 422) {
ref.read(authProvider.notifier).setLoading(false);
return;
}
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
@@ -91,63 +161,103 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
const Center(child: MatchLiveWordmark(showSlogan: true)),
const SizedBox(height: 48),
Text(
'ACCEDI',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Gestisci le dirette della tua squadra',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'coach@squadra.it',
const SizedBox(height: 32),
const Center(child: MatchLiveWordmark(showSlogan: true)),
const SizedBox(height: 48),
if (_loadingInvite)
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
if (_invite != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.35)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Invito staff',
style: theme.textTheme.titleMedium?.copyWith(color: AppTheme.primaryRed),
),
onPressed: () => setState(() => _obscure = !_obscure),
),
const SizedBox(height: 6),
Text(
'Entra in ${_invite!.teamName} come responsabile trasmissione.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
'Accedi con ${_invite!.email}',
style: theme.textTheme.bodySmall,
),
],
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci la password' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 12),
Text(
auth.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryRed,
const SizedBox(height: 20),
],
Text(
'ACCEDI',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_invite != null
? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta'
: 'Gestisci le dirette della tua squadra',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
readOnly: _invite != null,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'coach@squadra.it',
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
),
textAlign: TextAlign.center,
onPressed: () => setState(() => _obscure = !_obscure),
),
],
const SizedBox(height: 32),
PrimaryCta(
label: 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci la password' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 12),
Text(
auth.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryRed,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
PrimaryCta(
label: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
],
),
),

View File

@@ -9,11 +9,12 @@ import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
import 'resume_session_sheet.dart';
import 'no_team_onboarding.dart';
import 'new_match_sheet.dart';
import 'schedule_match_sheet.dart';
import 'select_match_sheet.dart';
import 'team_picker.dart';
import 'team_providers.dart';
@@ -127,9 +128,38 @@ class MatchesScreen extends ConsumerWidget {
),
const SizedBox(height: 4),
Text(
'Le tue partite',
'Scegli una partita programmata o creane una nuova.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _pickScheduledMatch(context, ref, matches),
icon: const Icon(Icons.playlist_play),
label: const Text('Partita\nprogrammata'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () => _newMatch(context, ref),
icon: const Icon(Icons.add),
label: const Text('Nuova\npartita'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
],
),
const TeamPickerBar(),
if (activeMatch != null) ...[
const SizedBox(height: 12),
@@ -186,17 +216,25 @@ class MatchesScreen extends ConsumerWidget {
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Text(
matches.isEmpty ? 'Calendario vuoto' : 'Calendario',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
),
),
if (matches.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Nessuna partita in calendario.\nTocca «Programma partita» per aggiungere data e ora.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
'Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
)
@@ -208,13 +246,7 @@ class MatchesScreen extends ConsumerWidget {
return _MatchCard(
match: match,
dateFormat: dateFormat,
onTap: () {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
},
onTap: () => _openMatch(context, ref, match),
onDelete: match.canResumeCamera
? null
: () => _deleteMatch(context, ref, match),
@@ -224,24 +256,7 @@ class MatchesScreen extends ConsumerWidget {
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: PrimaryCta(
label: 'PROGRAMMA PARTITA',
icon: Icons.event_available,
onPressed: () => _scheduleMatch(context, ref),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 20 + bottomInset),
child: TextButton.icon(
onPressed: () => _createMatchQuick(context, ref),
icon: const Icon(Icons.settings_outlined, size: 18),
label: const Text('Crea e configura subito (senza orario)'),
),
),
child: SizedBox(height: 20 + bottomInset),
),
],
),
@@ -299,6 +314,48 @@ class MatchesScreen extends ConsumerWidget {
}
}
void _openMatch(BuildContext context, WidgetRef ref, MatchModel match) {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
}
Future<void> _pickScheduledMatch(
BuildContext context,
WidgetRef ref,
List<MatchModel> matches,
) async {
final selectable = matches.where((m) => !m.hasActiveSession).toList();
if (selectable.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nessuna partita disponibile. Crea una nuova partita.'),
),
);
}
return;
}
final picked = await showSelectMatchSheet(context, matches: matches);
if (picked != null && context.mounted) {
context.push('/setup/${picked.id}/1');
}
}
Future<void> _newMatch(BuildContext context, WidgetRef ref) async {
final choice = await showNewMatchSheet(context);
if (choice == null || !context.mounted) return;
switch (choice) {
case NewMatchChoice.schedule:
await _scheduleMatch(context, ref);
case NewMatchChoice.quickStart:
await _createMatchQuick(context, ref);
}
}
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
enum NewMatchChoice { schedule, quickStart }
Future<NewMatchChoice?> showNewMatchSheet(BuildContext context) {
return showModalBottomSheet<NewMatchChoice>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final theme = Theme.of(ctx);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Nuova partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 6),
Text(
'Programma in anticipo o avvia la configurazione diretta subito.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 20),
_OptionTile(
icon: Icons.event_available,
title: 'Programma partita',
subtitle: 'Data, ora e avversario — visibile anche sul sito',
onTap: () => Navigator.pop(ctx, NewMatchChoice.schedule),
),
const SizedBox(height: 10),
_OptionTile(
icon: Icons.play_circle_outline,
title: 'Avvia subito',
subtitle: 'Crea la partita e passa al wizard senza orario',
onTap: () => Navigator.pop(ctx, NewMatchChoice.quickStart),
),
],
),
);
},
);
}
class _OptionTile extends StatelessWidget {
const _OptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: AppTheme.primaryRed, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodySmall),
],
),
),
const Icon(Icons.chevron_right, color: AppTheme.textSecondary),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/theme.dart';
import '../../shared/models/match.dart';
/// Scegli una partita già in calendario per avviare la diretta.
Future<MatchModel?> showSelectMatchSheet(
BuildContext context, {
required List<MatchModel> matches,
}) {
final selectable = matches.where((m) => !m.hasActiveSession).toList();
if (selectable.isEmpty) return Future.value(null);
return showModalBottomSheet<MatchModel>(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => _SelectMatchSheet(matches: selectable),
);
}
class _SelectMatchSheet extends StatelessWidget {
const _SelectMatchSheet({required this.matches});
final List<MatchModel> matches;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT');
final now = DateTime.now();
final scheduled = matches
.where((m) => m.scheduledAt != null)
.toList()
..sort((a, b) => a.scheduledAt!.compareTo(b.scheduledAt!));
final unscheduled = matches.where((m) => m.scheduledAt == null).toList();
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.55,
minChildSize: 0.35,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Scegli partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 6),
Text(
'Partite già programmate sul sito o in app.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Expanded(
child: ListView(
controller: scrollController,
children: [
if (scheduled.isNotEmpty) ...[
Text('Programmate', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
...scheduled.map(
(m) => _MatchTile(
match: m,
dateFormat: dateFormat,
now: now,
onTap: () => Navigator.pop(context, m),
),
),
const SizedBox(height: 16),
],
if (unscheduled.isNotEmpty) ...[
Text('Senza orario', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
...unscheduled.map(
(m) => _MatchTile(
match: m,
dateFormat: dateFormat,
now: now,
onTap: () => Navigator.pop(context, m),
),
),
],
],
),
),
],
),
);
},
);
}
}
class _MatchTile extends StatelessWidget {
const _MatchTile({
required this.match,
required this.dateFormat,
required this.now,
required this.onTap,
});
final MatchModel match;
final DateFormat dateFormat;
final DateTime now;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final at = match.scheduledAt;
final isFuture = at != null && at.isAfter(now);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${match.teamName} vs ${match.opponentName}',
style: theme.textTheme.titleSmall,
),
if (at != null)
Text(
dateFormat.format(at.toLocal()),
style: theme.textTheme.bodySmall,
),
if (match.location != null && match.location!.isNotEmpty)
Text(match.location!, style: theme.textTheme.bodySmall),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFuture
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.textSecondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
at != null ? (isFuture ? 'PROGRAMMATA' : 'IN CALENDARIO') : 'BOZZA',
style: theme.textTheme.labelSmall?.copyWith(
color: isFuture ? AppTheme.primaryRed : AppTheme.textSecondary,
fontSize: 10,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
@@ -74,16 +75,53 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
}
}
Future<void> _connectYoutube(Team team) async {
try {
final url = await ref.read(apiClientProvider).youtubeAuthorizeUrl(team.id);
final uri = Uri.parse(url);
if (!await canLaunchUrl(uri)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossibile aprire il browser per Google')),
);
}
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Completa il consenso Google, poi torna qui e seleziona YouTube Live.',
),
duration: Duration(seconds: 5),
),
);
}
} on DioException catch (e) {
final apiErr = ApiException.fromDio(e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(apiErr?.message ?? 'Errore collegamento YouTube')),
);
}
}
}
void _onYoutubeTap(Team? team) {
if (team == null) return;
if (team.isYoutubeReady) {
setState(() => _platform = 'youtube');
return;
}
if (team.canUseYoutube && team.youtubeMode == 'team' && team.canConnectYoutube) {
_connectYoutube(team);
return;
}
if (team.canUseYoutube && team.youtubeMode == 'team') {
showPremiumRequiredDialog(
context,
message: 'Collega il canale YouTube dalla pagina Dettagli squadra sul sito, poi seleziona YouTube qui.',
message: 'Collega il canale YouTube (Premium Full) per trasmettere sul tuo canale.',
billingUrl: team.staffManageUrl ?? team.billingUrl,
);
return;
@@ -119,7 +157,10 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
if (team.youtubeMode == 'matchlivetv_light') {
return 'Canale Match Live TV (in attivazione)';
}
return 'Collega canale sul sito';
if (team.canConnectYoutube) {
return 'Tocca per collegare il tuo canale YouTube';
}
return 'Collega canale (Premium Full)';
}
void _onExternalPremiumTap(String platform, Team? team) {