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:
@@ -34,6 +34,18 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="www.matchlivetv.it" android:pathPrefix="/join"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="matchlivetv"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
||||
@@ -52,7 +52,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
builder: (context, state) => LoginScreen(
|
||||
inviteToken: state.uri.queryParameters['invite'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/matches',
|
||||
|
||||
77
mobile/lib/core/deep_link_listener.dart
Normal file
77
mobile/lib/core/deep_link_listener.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/matches/team_providers.dart';
|
||||
import 'invite_storage.dart';
|
||||
|
||||
/// Gestisce link invito (join) e ritorno OAuth YouTube dall'app.
|
||||
class DeepLinkListener extends ConsumerStatefulWidget {
|
||||
const DeepLinkListener({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<DeepLinkListener> createState() => _DeepLinkListenerState();
|
||||
}
|
||||
|
||||
class _DeepLinkListenerState extends ConsumerState<DeepLinkListener> {
|
||||
final _appLinks = AppLinks();
|
||||
StreamSubscription<Uri>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_handleInitialLink();
|
||||
_sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {});
|
||||
}
|
||||
|
||||
Future<void> _handleInitialLink() async {
|
||||
final uri = await _appLinks.getInitialLink();
|
||||
if (uri != null) await _onUri(uri);
|
||||
}
|
||||
|
||||
Future<void> _onUri(Uri uri) async {
|
||||
final inviteToken = _extractInviteToken(uri);
|
||||
if (inviteToken != null) {
|
||||
await InviteStorage.saveToken(inviteToken);
|
||||
if (!mounted) return;
|
||||
context.go('/login?invite=$inviteToken');
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri.scheme == 'matchlivetv' && uri.host == 'youtube-connected') {
|
||||
ref.invalidate(teamsProvider);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Canale YouTube collegato. Puoi selezionare YouTube Live.'),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractInviteToken(Uri uri) {
|
||||
if (uri.scheme == 'matchlivetv' && uri.host == 'join') {
|
||||
final segment = uri.pathSegments.isNotEmpty ? uri.pathSegments.first : uri.path.replaceFirst('/', '');
|
||||
return segment.isEmpty ? null : segment;
|
||||
}
|
||||
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'join') {
|
||||
return uri.pathSegments[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
20
mobile/lib/core/invite_storage.dart
Normal file
20
mobile/lib/core/invite_storage.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class InviteStorage {
|
||||
static const _keyToken = 'pending_invite_token';
|
||||
|
||||
static Future<void> saveToken(String token) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyToken, token);
|
||||
}
|
||||
|
||||
static Future<String?> readToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyToken);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyToken);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
105
mobile/lib/features/matches/new_match_sheet.dart
Normal file
105
mobile/lib/features/matches/new_match_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
mobile/lib/features/matches/select_match_sheet.dart
Normal file
185
mobile/lib/features/matches/select_match_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
import 'app/router.dart';
|
||||
import 'app/theme.dart';
|
||||
import 'core/deep_link_listener.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -28,11 +29,13 @@ class MatchLiveTvApp extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Match Live TV',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
return DeepLinkListener(
|
||||
child: MaterialApp.router(
|
||||
title: 'Match Live TV',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/config.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'models/invite_preview.dart';
|
||||
import 'models/match.dart';
|
||||
import 'models/recording_item.dart';
|
||||
import 'models/stream_session.dart';
|
||||
@@ -86,6 +87,24 @@ class ApiClient {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<InvitePreview> fetchInvitePreview(String token) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/invitations/$token');
|
||||
return InvitePreview.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<String> acceptInvitation(String token) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>('/invitations/$token/accept');
|
||||
return response.data!['message'] as String? ?? 'Invito accettato';
|
||||
}
|
||||
|
||||
Future<String> youtubeAuthorizeUrl(String teamId, {bool returnApp = true}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/teams/$teamId/youtube/authorize',
|
||||
queryParameters: returnApp ? {'return_app': '1'} : null,
|
||||
);
|
||||
return response.data!['authorization_url'] as String;
|
||||
}
|
||||
|
||||
Future<List<RecordingItem>> fetchRecordings(String teamId) async {
|
||||
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
|
||||
return response.data!
|
||||
|
||||
28
mobile/lib/shared/models/invite_preview.dart
Normal file
28
mobile/lib/shared/models/invite_preview.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
class InvitePreview {
|
||||
const InvitePreview({
|
||||
required this.valid,
|
||||
required this.email,
|
||||
required this.teamName,
|
||||
this.clubName,
|
||||
this.teamId,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final bool valid;
|
||||
final String email;
|
||||
final String teamName;
|
||||
final String? clubName;
|
||||
final String? teamId;
|
||||
final String? error;
|
||||
|
||||
factory InvitePreview.fromJson(Map<String, dynamic> json) {
|
||||
return InvitePreview(
|
||||
valid: json['valid'] as bool? ?? false,
|
||||
email: json['email'] as String? ?? '',
|
||||
teamName: json['team_name'] as String? ?? '',
|
||||
clubName: json['club_name'] as String?,
|
||||
teamId: json['team_id'] as String?,
|
||||
error: json['error'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class Team {
|
||||
this.staffRole,
|
||||
this.canStream = true,
|
||||
this.youtubeMode,
|
||||
this.canConnectYoutube = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -60,6 +61,7 @@ class Team {
|
||||
final bool phoneDownloadEnabled;
|
||||
final bool youtubeEnabled;
|
||||
final String? youtubeMode;
|
||||
final bool canConnectYoutube;
|
||||
|
||||
bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light');
|
||||
|
||||
@@ -105,6 +107,7 @@ class Team {
|
||||
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
|
||||
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
|
||||
youtubeMode: json['youtube_mode'] as String?,
|
||||
canConnectYoutube: json['can_connect_youtube'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: match_live_tv
|
||||
description: Match Live TV - Lo streaming che non muore
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+2
|
||||
version: 1.2.0+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
@@ -25,6 +25,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.3
|
||||
url_launcher: ^6.3.1
|
||||
share_plus: ^10.1.4
|
||||
app_links: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user