Initial commit: monorepo Match Live TV.
Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
188
mobile/lib/features/controller_mode/controller_landscape.dart
Normal file
188
mobile/lib/features/controller_mode/controller_landscape.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/connected_badge.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import 'controller_screen.dart';
|
||||
|
||||
/// Layout gamepad a 3 colonne per uso landscape.
|
||||
class ControllerLandscape extends StatelessWidget {
|
||||
const ControllerLandscape({
|
||||
super.key,
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.sessionState,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final SessionState sessionState;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamColumn(
|
||||
teamLabel: 'CASA',
|
||||
teamName: homeName,
|
||||
sets: score.homeSets,
|
||||
points: score.homePoints,
|
||||
onPoint: onHomePoint,
|
||||
onMinus: onHomeMinus,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('REGIA', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(width: 12),
|
||||
ConnectedBadge(connected: sessionState.connected),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'SET ${score.currentSet} → $pointsTarget pt',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.accentYellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet),
|
||||
const SizedBox(height: 16),
|
||||
Text('ULTIME AZIONI', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
StreamMetricsFooter(
|
||||
elapsed: sessionState.elapsed,
|
||||
connected: sessionState.connected,
|
||||
cameraDevice: sessionState.cameraDevice,
|
||||
viewerCount: sessionState.viewerCount,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
child: const Text('Interrompi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DestructiveCta(
|
||||
label: 'Chiudi',
|
||||
compact: true,
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamColumn(
|
||||
teamLabel: 'OSPITI',
|
||||
teamName: awayName,
|
||||
sets: score.awaySets,
|
||||
points: score.awayPoints,
|
||||
onPoint: onAwayPoint,
|
||||
onMinus: onAwayMinus,
|
||||
alignRight: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamColumn extends StatelessWidget {
|
||||
const _TeamColumn({
|
||||
required this.teamLabel,
|
||||
required this.teamName,
|
||||
required this.sets,
|
||||
required this.points,
|
||||
required this.onPoint,
|
||||
required this.onMinus,
|
||||
this.alignRight = false,
|
||||
});
|
||||
|
||||
final String teamLabel;
|
||||
final String teamName;
|
||||
final int sets;
|
||||
final int points;
|
||||
final VoidCallback onPoint;
|
||||
final VoidCallback onMinus;
|
||||
final bool alignRight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final crossAlign =
|
||||
alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: crossAlign,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(teamLabel, style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
teamName.toUpperCase(),
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: alignRight ? TextAlign.right : TextAlign.left,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('SETS $sets', style: theme.textTheme.bodyMedium),
|
||||
Text(
|
||||
'$points',
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
fontSize: 56,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ScoreTapButton(label: '+1', onTap: onPoint, large: true),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: points > 0 ? onMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
187
mobile/lib/features/controller_mode/controller_portrait.dart
Normal file
187
mobile/lib/features/controller_mode/controller_portrait.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../shared/models/score_state.dart';
|
||||
import '../../shared/widgets/destructive_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'controller_screen.dart';
|
||||
|
||||
class ControllerPortrait extends StatelessWidget {
|
||||
const ControllerPortrait({
|
||||
super.key,
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.sessionState,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
required this.onCloseSet,
|
||||
required this.onInterrupt,
|
||||
required this.onCloseStream,
|
||||
required this.pointsTarget,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final SessionState sessionState;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
final VoidCallback onCloseSet;
|
||||
final VoidCallback onInterrupt;
|
||||
final Future<void> Function() onCloseStream;
|
||||
final int pointsTarget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final match = sessionState.match;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: ScreenInsets.contentPadding(context, horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (match != null)
|
||||
Text(
|
||||
'${match.sport.toUpperCase()} · ${match.category ?? ''} · ${match.phase ?? ''} · SET ${score.currentSet}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Scoreboard(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
score: score,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: onHomePoint,
|
||||
onAwayPoint: onAwayPoint,
|
||||
onHomeMinus: onHomeMinus,
|
||||
onAwayMinus: onAwayMinus,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet),
|
||||
const SizedBox(height: 16),
|
||||
StreamMetricsFooter(
|
||||
elapsed: sessionState.elapsed,
|
||||
connected: sessionState.connected,
|
||||
cameraDevice: sessionState.cameraDevice,
|
||||
viewerCount: sessionState.viewerCount,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: onInterrupt,
|
||||
child: const Text('Interrompi (riprendibile)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DestructiveCta(
|
||||
label: 'Chiudi diretta',
|
||||
onPressed: () => onCloseStream(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scoreboard extends StatelessWidget {
|
||||
const _Scoreboard({
|
||||
required this.homeName,
|
||||
required this.awayName,
|
||||
required this.score,
|
||||
required this.pointsTarget,
|
||||
required this.onHomePoint,
|
||||
required this.onAwayPoint,
|
||||
required this.onHomeMinus,
|
||||
required this.onAwayMinus,
|
||||
});
|
||||
|
||||
final String homeName;
|
||||
final String awayName;
|
||||
final ScoreState score;
|
||||
final int pointsTarget;
|
||||
final VoidCallback onHomePoint;
|
||||
final VoidCallback onAwayPoint;
|
||||
final VoidCallback onHomeMinus;
|
||||
final VoidCallback onAwayMinus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(homeName.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
'SETS ${score.homeSets}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'${score.homePoints}',
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
),
|
||||
Text('→ $pointsTarget pt', style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 8),
|
||||
ScoreTapButton(label: '+1', onTap: onHomePoint),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedButton(
|
||||
onPressed: score.homePoints > 0 ? onHomeMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'-',
|
||||
style: theme.textTheme.headlineLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(awayName.toUpperCase(), style: theme.textTheme.labelLarge),
|
||||
Text(
|
||||
'SETS ${score.awaySets}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'${score.awayPoints}',
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ScoreTapButton(label: '+1', onTap: onAwayPoint),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedButton(
|
||||
onPressed: score.awayPoints > 0 ? onAwayMinus : null,
|
||||
child: const Text('-1'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal file
424
mobile/lib/features/controller_mode/controller_screen.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/match_rules_provider.dart';
|
||||
import '../../providers/score_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../shared/live_score_actions.dart';
|
||||
import '../../shared/widgets/end_stream_dialog.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/websocket_service.dart';
|
||||
import '../../shared/widgets/connected_badge.dart';
|
||||
import '../../shared/widgets/match_live_wordmark.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'controller_landscape.dart';
|
||||
import 'controller_portrait.dart';
|
||||
|
||||
class ControllerScreen extends ConsumerStatefulWidget {
|
||||
const ControllerScreen({super.key, required this.sessionId});
|
||||
|
||||
final String sessionId;
|
||||
|
||||
@override
|
||||
ConsumerState<ControllerScreen> createState() => _ControllerScreenState();
|
||||
}
|
||||
|
||||
class _ControllerScreenState extends ConsumerState<ControllerScreen> {
|
||||
Timer? _elapsedTimer;
|
||||
Timer? _statsTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_lockLandscape();
|
||||
_bootstrap();
|
||||
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final session = ref.read(sessionProvider).session;
|
||||
if (session?.startedAt != null) {
|
||||
ref.read(sessionProvider.notifier).setElapsed(
|
||||
DateTime.now().difference(session!.startedAt!),
|
||||
);
|
||||
}
|
||||
});
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final session = await client.fetchSession(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setSession(session);
|
||||
if (session.score != null) {
|
||||
ref.read(scoreProvider.notifier).reset(session.score!);
|
||||
}
|
||||
final ws = ref.read(websocketServiceProvider);
|
||||
await ws.connect(sessionId: widget.sessionId, deviceRole: 'controller');
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Errore connessione sessione')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchStats() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final stats = await client.youtubeStats(widget.sessionId);
|
||||
ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _lockLandscape() async {
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _unlockOrientation() async {
|
||||
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
|
||||
Future<void> _leaveSession() async {
|
||||
try {
|
||||
await ref.read(websocketServiceProvider).disconnect();
|
||||
} catch (_) {}
|
||||
if (mounted) context.go('/matches');
|
||||
}
|
||||
|
||||
Future<void> _interruptStream() async {
|
||||
try {
|
||||
ref.read(websocketServiceProvider).sendCommand('pause_stream');
|
||||
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Pausa: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _leaveSession();
|
||||
}
|
||||
|
||||
Future<void> _closeStreamPermanently() async {
|
||||
if (!mounted) return;
|
||||
final ok = await confirmEndStream(context);
|
||||
if (!ok || !mounted) return;
|
||||
|
||||
try {
|
||||
ref.read(websocketServiceProvider).sendCommand('stop_stream');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await ref.read(apiClientProvider).stopSession(widget.sessionId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Chiusura: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _leaveSession();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_elapsedTimer?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
_unlockOrientation();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncScore() {
|
||||
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
|
||||
}
|
||||
|
||||
Future<void> _homePoint() async {
|
||||
ref.read(scoreProvider.notifier).incrementHome();
|
||||
_syncScore();
|
||||
final match = ref.read(sessionProvider).match;
|
||||
await LiveScoreActions(ref).afterPointChange(
|
||||
context,
|
||||
homeName: match?.teamName ?? 'Casa',
|
||||
awayName: match?.opponentName ?? 'Ospiti',
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _awayPoint() async {
|
||||
ref.read(scoreProvider.notifier).incrementAway();
|
||||
_syncScore();
|
||||
final match = ref.read(sessionProvider).match;
|
||||
await LiveScoreActions(ref).afterPointChange(
|
||||
context,
|
||||
homeName: match?.teamName ?? 'Casa',
|
||||
awayName: match?.opponentName ?? 'Ospiti',
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
void _homeMinus() {
|
||||
ref.read(scoreProvider.notifier).decrementHome();
|
||||
_syncScore();
|
||||
}
|
||||
|
||||
void _awayMinus() {
|
||||
ref.read(scoreProvider.notifier).decrementAway();
|
||||
_syncScore();
|
||||
}
|
||||
|
||||
Future<void> _closeSet() async {
|
||||
await LiveScoreActions(ref).requestCloseSet(
|
||||
context,
|
||||
onCloseSetConfirmed: () => _closeSetConfirmed(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _closeSetConfirmed() async {
|
||||
ref.read(scoreProvider.notifier).closeSet();
|
||||
_syncScore();
|
||||
final match = ref.read(sessionProvider).match;
|
||||
await LiveScoreActions(ref).afterCloseSet(
|
||||
context,
|
||||
homeName: match?.teamName ?? 'Casa',
|
||||
awayName: match?.opponentName ?? 'Ospiti',
|
||||
onStopStream: _closeStreamPermanently,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sessionState = ref.watch(sessionProvider);
|
||||
final score = ref.watch(scoreProvider);
|
||||
final match = sessionState.match;
|
||||
final isLandscape =
|
||||
MediaQuery.orientationOf(context) == Orientation.landscape;
|
||||
|
||||
final homeName = match?.teamName ?? 'CASA';
|
||||
final awayName = match?.opponentName ?? 'OSPITI';
|
||||
final rules = ref.watch(matchScoringRulesProvider);
|
||||
final pointsTarget = rules.pointsTarget(score.currentSet);
|
||||
|
||||
final body = isLandscape
|
||||
? ControllerLandscape(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
score: score,
|
||||
sessionState: sessionState,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: () => _homePoint(),
|
||||
onAwayPoint: () => _awayPoint(),
|
||||
onHomeMinus: _homeMinus,
|
||||
onAwayMinus: _awayMinus,
|
||||
onCloseSet: () => _closeSet(),
|
||||
onInterrupt: _interruptStream,
|
||||
onCloseStream: _closeStreamPermanently,
|
||||
)
|
||||
: ControllerPortrait(
|
||||
homeName: homeName,
|
||||
awayName: awayName,
|
||||
score: score,
|
||||
sessionState: sessionState,
|
||||
pointsTarget: pointsTarget,
|
||||
onHomePoint: () => _homePoint(),
|
||||
onAwayPoint: () => _awayPoint(),
|
||||
onHomeMinus: _homeMinus,
|
||||
onAwayMinus: _awayMinus,
|
||||
onCloseSet: () => _closeSet(),
|
||||
onInterrupt: _interruptStream,
|
||||
onCloseStream: _closeStreamPermanently,
|
||||
);
|
||||
|
||||
final inset = ScreenInsets.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
if (!isLandscape)
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
inset.left,
|
||||
inset.top,
|
||||
inset.right,
|
||||
8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const MatchLiveWordmark(compact: true),
|
||||
const Spacer(),
|
||||
ConnectedBadge(connected: sessionState.connected),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: inset.left,
|
||||
right: inset.right,
|
||||
top: isLandscape ? inset.top : 0,
|
||||
bottom: inset.bottom,
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulsante score +1 con stile gamepad.
|
||||
class ScoreTapButton extends StatelessWidget {
|
||||
const ScoreTapButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.large = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool large;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(large ? 16 : 12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(large ? 16 : 12),
|
||||
child: Container(
|
||||
width: large ? double.infinity : null,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: large ? 20 : 12,
|
||||
horizontal: large ? 24 : 16,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: large ? 28 : 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CTA gialla per azioni secondarie (PAUSA, CHIUDI SET).
|
||||
class YellowActionButton extends StatelessWidget {
|
||||
const YellowActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentYellow,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: Colors.black),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer metriche stream (timer, segnale, Mbps, batteria).
|
||||
class StreamMetricsFooter extends StatelessWidget {
|
||||
const StreamMetricsFooter({
|
||||
super.key,
|
||||
required this.elapsed,
|
||||
required this.connected,
|
||||
this.cameraDevice,
|
||||
this.viewerCount,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final Duration elapsed;
|
||||
final bool connected;
|
||||
final dynamic cameraDevice;
|
||||
final int? viewerCount;
|
||||
final bool compact;
|
||||
|
||||
String _format(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final network = cameraDevice?.networkType ?? '—';
|
||||
final mbps = cameraDevice?.bitrateMbps != null
|
||||
? cameraDevice.bitrateMbps.toStringAsFixed(1)
|
||||
: '—';
|
||||
final battery = cameraDevice?.batteryLevel?.toString() ?? '—';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(compact ? 8 : 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(compact ? 8 : 10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_MetricChip(icon: Icons.timer, label: _format(elapsed)),
|
||||
_MetricChip(icon: Icons.signal_cellular_alt, label: network),
|
||||
_MetricChip(icon: Icons.speed, label: '$mbps Mbps'),
|
||||
_MetricChip(icon: Icons.battery_std, label: '$battery%'),
|
||||
if (viewerCount != null)
|
||||
_MetricChip(icon: Icons.visibility, label: '$viewerCount'),
|
||||
ConnectedBadge(connected: connected, label: compact ? null : 'COLLEGATO'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricChip extends StatelessWidget {
|
||||
const _MetricChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 11)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user