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

@@ -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,
),
),
),
],
),
),
),
),
);
}
}