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,
),
],
),
),