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:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

45
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
mobile/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
channel: "[user-branch]"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: ios
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: linux
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: macos
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: web
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: windows
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

17
mobile/README.md Normal file
View File

@@ -0,0 +1,17 @@
# match_live_tv
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
mobile/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,59 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.matchlivetv.match_live_tv"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.matchlivetv.match_live_tv"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = maxOf(flutter.minSdkVersion, 21)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
dependencies {
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
}

2
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# RootEncoder / SLF4J (R8 release)
-dontwarn org.slf4j.impl.StaticLoggerBinder

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:label="match_live_tv"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".StreamingForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,21 @@
package com.matchlivetv.match_live_tv
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Termina eventuale diretta rimasta attiva da sessione precedente
StreamingEngineHolder.engine?.stopStream()
StreamingEngineHolder.release()
startService(StreamingForegroundService.stopIntent(this))
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(StreamingPlugin())
}
}

View File

@@ -0,0 +1,9 @@
package com.matchlivetv.match_live_tv
import com.pedro.library.view.OpenGlView
/** Riferimento condiviso alla surface di preview tra PlatformView e StreamingEngine. */
object StreamPreviewHolder {
@Volatile
var openGlView: OpenGlView? = null
}

View File

@@ -0,0 +1,476 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.annotation.RequiresApi
import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.BitrateAdapter
import com.pedro.library.view.OpenGlView
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
data class StreamingConfig(
val rtmpUrl: String,
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
val sampleRate: Int = 44_100,
val stereo: Boolean = true,
val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L,
)
data class StreamingMetrics(
val isStreaming: Boolean,
val isPreviewActive: Boolean,
val isConnected: Boolean,
val state: String,
val bitrateKbps: Long,
val fps: Int,
val durationMs: Long,
val reconnectAttempts: Int,
val lastError: String?,
) {
fun toMap(): Map<String, Any?> = mapOf(
"isStreaming" to isStreaming,
"isPreviewActive" to isPreviewActive,
"isConnected" to isConnected,
"state" to state,
"bitrateKbps" to bitrateKbps,
"fps" to fps,
"durationMs" to durationMs,
"reconnectAttempts" to reconnectAttempts,
"lastError" to lastError,
)
}
enum class StreamingState {
IDLE,
PREVIEWING,
CONNECTING,
STREAMING,
RECONNECTING,
STOPPING,
ERROR,
}
interface StreamingEngineListener {
fun onStateChanged(state: StreamingState, message: String? = null)
fun onMetricsUpdated(metrics: StreamingMetrics)
fun onError(message: String)
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class StreamingEngine(
private val appContext: Context,
) : ConnectChecker {
private val mainHandler = Handler(Looper.getMainLooper())
private val listeners = mutableSetOf<StreamingEngineListener>()
private var genericStream: GenericStream? = null
private var previewSurface: OpenGlView? = null
private var config: StreamingConfig? = null
private var state: StreamingState = StreamingState.IDLE
private var streamStartedAtMs: Long = 0L
private var currentBitrateKbps: Long = 0L
private var currentFps: Int = 0
private var lastError: String? = null
private var lastVideoFrameCount: Long = 0L
private var lastFpsSampleAtMs: Long = 0L
private val reconnectAttempts = AtomicInteger(0)
private val isReleased = AtomicBoolean(false)
private val bitrateAdapter = BitrateAdapter { adaptedBitrate ->
genericStream?.setVideoBitrateOnFly(adaptedBitrate)
}
private val metricsRunnable = object : Runnable {
override fun run() {
emitMetrics()
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.PREVIEWING
) {
mainHandler.postDelayed(this, METRICS_INTERVAL_MS)
}
}
}
fun addListener(listener: StreamingEngineListener) {
listeners.add(listener)
}
fun removeListener(listener: StreamingEngineListener) {
listeners.remove(listener)
}
fun bindStreamPreview(glView: OpenGlView) {
ensureMainThread()
val surfaceChanged = previewSurface != null && previewSurface !== glView
previewSurface = glView
if (surfaceChanged) {
genericStream?.let { stream ->
if (stream.isOnPreview) {
stream.stopPreview()
}
}
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
attachPreviewToStream(forceRebind = surfaceChanged)
return
}
val cfg = config ?: previewConfig()
val stream = genericStream ?: obtainGenericStream(cfg)
genericStream = stream
if (!stream.isOnPreview) {
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
cfg.width,
cfg.height,
cfg.videoBitrate,
cfg.fps,
rotation = cfg.rotation,
) && stream.prepareAudio(
cfg.sampleRate,
cfg.stereo,
cfg.audioBitrate,
)
} catch (_: Exception) {
false
}
if (!prepared) {
notifyError("Impossibile preparare la preview camera")
return
}
}
attachPreviewToStream()
updateState(StreamingState.PREVIEWING)
startMetricsLoop()
}
fun unbindStreamPreview() {
ensureMainThread()
previewSurface = null
genericStream?.let { stream ->
if (stream.isOnPreview) {
stream.stopPreview()
}
}
if (state == StreamingState.PREVIEWING) {
updateState(StreamingState.IDLE)
}
}
fun startStream(streamConfig: StreamingConfig) {
ensureMainThread()
if (isReleased.get()) {
notifyError("StreamingEngine rilasciato")
return
}
if (state == StreamingState.STREAMING ||
state == StreamingState.CONNECTING ||
state == StreamingState.RECONNECTING
) {
return
}
config = streamConfig
lastError = null
reconnectAttempts.set(0)
val stream = genericStream ?: obtainGenericStream(streamConfig)
genericStream = stream
stopPreviewAndStreamForPrepare(stream)
val prepared = try {
stream.prepareVideo(
streamConfig.width,
streamConfig.height,
streamConfig.videoBitrate,
streamConfig.fps,
rotation = streamConfig.rotation,
) && stream.prepareAudio(
streamConfig.sampleRate,
streamConfig.stereo,
streamConfig.audioBitrate,
)
} catch (exception: IllegalArgumentException) {
false
} catch (exception: IllegalStateException) {
false
}
if (!prepared) {
notifyError("Configurazione audio/video non valida")
updateState(StreamingState.ERROR, "prepare failed")
return
}
attachPreviewToStream()
stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate)
streamStartedAtMs = SystemClock.elapsedRealtime()
updateState(StreamingState.CONNECTING)
stream.startStream(streamConfig.rtmpUrl)
startMetricsLoop()
}
fun stopStream() {
ensureMainThread()
if (state == StreamingState.IDLE || state == StreamingState.STOPPING) {
return
}
updateState(StreamingState.STOPPING)
stopMetricsLoop()
genericStream?.let { stream ->
if (stream.isStreaming) {
stream.stopStream()
}
if (stream.isOnPreview) {
stream.stopPreview()
}
stream.release()
}
genericStream = null
streamStartedAtMs = 0L
currentBitrateKbps = 0L
currentFps = 0
reconnectAttempts.set(0)
updateState(StreamingState.IDLE)
emitMetrics()
}
fun release() {
ensureMainThread()
if (!isReleased.compareAndSet(false, true)) {
return
}
stopStream()
unbindStreamPreview()
listeners.clear()
}
fun getMetrics(): StreamingMetrics = buildMetrics()
override fun onConnectionStarted(url: String) {
postMain {
updateState(StreamingState.CONNECTING, url)
}
}
override fun onConnectionSuccess() {
postMain {
reconnectAttempts.set(0)
lastError = null
updateState(StreamingState.STREAMING)
}
}
override fun onConnectionFailed(reason: String) {
postMain {
lastError = reason
val stream = genericStream ?: return@postMain
val currentConfig = config ?: return@postMain
updateState(StreamingState.RECONNECTING, reason)
val attempt = reconnectAttempts.incrementAndGet()
val retryScheduled = stream.getStreamClient().reTry(
currentConfig.reconnectDelayMs,
reason,
null,
)
if (retryScheduled) {
emitMetrics()
} else {
updateState(StreamingState.ERROR, reason)
notifyError("Connessione RTMP fallita: $reason")
stream.stopStream()
}
}
}
override fun onNewBitrate(bitrate: Long) {
postMain {
currentBitrateKbps = bitrate / 1000
genericStream?.let { stream ->
bitrateAdapter.adaptBitrate(
bitrate,
stream.getStreamClient().hasCongestion(),
)
}
emitMetrics()
}
}
override fun onDisconnect() {
postMain {
if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) {
updateState(StreamingState.RECONNECTING, "disconnected")
}
}
}
override fun onAuthError() {
postMain {
lastError = "auth_error"
updateState(StreamingState.ERROR, "auth_error")
genericStream?.stopStream()
notifyError("Autenticazione RTMP fallita")
}
}
override fun onAuthSuccess() {
postMain {
updateState(StreamingState.STREAMING)
}
}
private fun stopPreviewAndStreamForPrepare(stream: GenericStream) {
if (stream.isStreaming) {
stream.stopStream()
}
if (stream.isOnPreview) {
stream.stopPreview()
}
}
private fun attachPreviewToStream(forceRebind: Boolean = false) {
val glView = previewSurface ?: StreamPreviewHolder.openGlView ?: return
previewSurface = glView
val stream = genericStream ?: return
if (forceRebind && stream.isOnPreview) {
stream.stopPreview()
}
if (!stream.isOnPreview) {
stream.startPreview(glView, autoHandle = true)
}
}
private fun previewConfig(): StreamingConfig {
return config ?: StreamingConfig(
rtmpUrl = "rtmp://127.0.0.1/preview",
width = 1280,
height = 720,
)
}
private fun obtainGenericStream(streamConfig: StreamingConfig): GenericStream {
return GenericStream(
appContext,
this,
).apply {
getGlInterface().autoHandleOrientation = true
getStreamClient().setBitrateExponentialFactor(0.5f)
getStreamClient().setReTries(streamConfig.maxReconnectAttempts)
}
}
private fun buildMetrics(): StreamingMetrics {
val durationMs = if (streamStartedAtMs > 0L) {
SystemClock.elapsedRealtime() - streamStartedAtMs
} else {
0L
}
return StreamingMetrics(
isStreaming = state == StreamingState.STREAMING ||
state == StreamingState.RECONNECTING ||
state == StreamingState.CONNECTING,
isPreviewActive = state == StreamingState.PREVIEWING ||
(genericStream?.isOnPreview == true),
isConnected = state == StreamingState.STREAMING,
state = state.name.lowercase(),
bitrateKbps = currentBitrateKbps,
fps = currentFps,
durationMs = durationMs,
reconnectAttempts = reconnectAttempts.get(),
lastError = lastError,
)
}
private fun updateState(newState: StreamingState, message: String? = null) {
state = newState
listeners.forEach { it.onStateChanged(newState, message) }
emitMetrics()
}
private fun emitMetrics() {
updateFpsEstimate()
val metrics = buildMetrics()
listeners.forEach { it.onMetricsUpdated(metrics) }
}
private fun updateFpsEstimate() {
val stream = genericStream ?: return
val now = SystemClock.elapsedRealtime()
val sentFrames = stream.getStreamClient().getSentVideoFrames()
if (lastFpsSampleAtMs == 0L) {
lastFpsSampleAtMs = now
lastVideoFrameCount = sentFrames
return
}
val elapsedMs = now - lastFpsSampleAtMs
if (elapsedMs >= 1_000L) {
val frameDelta = sentFrames - lastVideoFrameCount
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt()
lastVideoFrameCount = sentFrames
lastFpsSampleAtMs = now
}
}
private fun notifyError(message: String) {
listeners.forEach { it.onError(message) }
}
private fun startMetricsLoop() {
mainHandler.removeCallbacks(metricsRunnable)
mainHandler.post(metricsRunnable)
}
private fun stopMetricsLoop() {
mainHandler.removeCallbacks(metricsRunnable)
}
private fun postMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private fun ensureMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"StreamingEngine must be used on the main thread"
}
}
companion object {
private const val METRICS_INTERVAL_MS = 1_000L
}
}

View File

@@ -0,0 +1,22 @@
package com.matchlivetv.match_live_tv
import android.content.Context
/** Motore streaming condiviso tra preview UI e RTMP (deve restare legato all'Activity). */
object StreamingEngineHolder {
@Volatile
var engine: StreamingEngine? = null
fun getOrCreate(context: Context): StreamingEngine {
val existing = engine
if (existing != null) {
return existing
}
return StreamingEngine(context.applicationContext).also { engine = it }
}
fun release() {
engine?.release()
engine = null
}
}

View File

@@ -0,0 +1,243 @@
package com.matchlivetv.match_live_tv
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
/** Mantiene in foreground la diretta (notifica + wake lock). Il motore RTMP vive nel plugin. */
class StreamingForegroundService : Service() {
private val binder = LocalBinder()
private val mainHandler = Handler(Looper.getMainLooper())
private var wakeLock: PowerManager.WakeLock? = null
private var sessionStartedAtMs: Long = 0L
private var autoStopTriggered = false
private val sessionTimeoutRunnable = Runnable {
if (!autoStopTriggered) {
autoStopTriggered = true
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to "session_timeout",
"durationMs" to elapsedMs(),
),
)
stopForegroundInternal("session_timeout")
}
}
inner class LocalBinder : Binder() {
fun getService(): StreamingForegroundService = this@StreamingForegroundService
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startForegroundSession()
ACTION_STOP -> stopForegroundInternal("user_stop")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
stopForegroundInternal("service_destroy")
super.onDestroy()
}
fun getMetrics(): Map<String, Any?> =
StreamingEngineHolder.engine?.getMetrics()?.toMap() ?: emptyMap()
private fun startForegroundSession() {
autoStopTriggered = false
sessionStartedAtMs = SystemClock.elapsedRealtime()
acquireWakeLock()
promoteToForeground(getString(R.string.streaming_notification_active))
mainHandler.removeCallbacks(sessionTimeoutRunnable)
mainHandler.postDelayed(sessionTimeoutRunnable, SESSION_MAX_MS)
}
private fun stopForegroundInternal(reason: String) {
mainHandler.removeCallbacks(sessionTimeoutRunnable)
releaseWakeLock()
if (sessionStartedAtMs > 0L) {
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to reason,
"durationMs" to elapsedMs(),
),
)
}
sessionStartedAtMs = 0L
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun elapsedMs(): Long {
return if (sessionStartedAtMs > 0L) {
SystemClock.elapsedRealtime() - sessionStartedAtMs
} else {
0L
}
}
private fun promoteToForeground(content: String) {
val notification = buildNotification(content)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
fun updateNotificationForState(state: StreamingState) {
val content = when (state) {
StreamingState.CONNECTING -> getString(R.string.streaming_notification_connecting)
StreamingState.STREAMING -> getString(R.string.streaming_notification_streaming)
StreamingState.RECONNECTING -> getString(R.string.streaming_notification_reconnecting)
StreamingState.ERROR -> getString(R.string.streaming_notification_error)
else -> getString(R.string.streaming_notification_active)
}
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun buildNotification(content: String): Notification {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
this,
1,
Companion.stopIntent(this),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.streaming_notification_title))
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(
android.R.drawable.ic_media_pause,
getString(R.string.streaming_notification_stop),
stopIntent,
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.streaming_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.streaming_notification_channel_desc)
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"$packageName:StreamingWakeLock",
).apply {
setReferenceCounted(false)
acquire(SESSION_MAX_MS + 60_000L)
}
}
private fun releaseWakeLock() {
wakeLock?.let { lock ->
if (lock.isHeld) {
lock.release()
}
}
wakeLock = null
}
companion object {
const val ACTION_START = "com.matchlivetv.match_live_tv.action.START_STREAM"
const val ACTION_STOP = "com.matchlivetv.match_live_tv.action.STOP_STREAM"
private const val CHANNEL_ID = "match_live_tv_streaming"
private const val NOTIFICATION_ID = 1001
private val SESSION_MAX_MS = 90L * 60L * 1000L
fun startIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_START
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_STOP
}
}
}
}
object ServiceEventBus {
private val listeners = mutableSetOf<(Map<String, Any?>) -> Unit>()
fun addListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.add(listener)
}
}
fun removeListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.remove(listener)
}
}
fun emit(event: Map<String, Any?>) {
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { it(event) }
}
}

View File

@@ -0,0 +1,286 @@
package com.matchlivetv.match_live_tv
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import com.pedro.library.view.OpenGlView
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler {
private lateinit var appContext: Context
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var activityBinding: ActivityPluginBinding? = null
private var eventSink: EventChannel.EventSink? = null
private var boundService: StreamingForegroundService? = null
private var serviceBound = false
private val engineListener = object : StreamingEngineListener {
override fun onStateChanged(state: StreamingState, message: String?) {
ServiceEventBus.emit(
mapOf(
"type" to "state",
"state" to state.name.lowercase(),
"message" to message,
),
)
boundService?.updateNotificationForState(state)
}
override fun onMetricsUpdated(metrics: StreamingMetrics) {
ServiceEventBus.emit(
mapOf(
"type" to "metrics",
"metrics" to metrics.toMap(),
),
)
}
override fun onError(message: String) {
ServiceEventBus.emit(
mapOf(
"type" to "error",
"message" to message,
),
)
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as? StreamingForegroundService.LocalBinder ?: return
boundService = binder.getService()
serviceBound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
serviceBound = false
}
}
private val busListener: (Map<String, Any?>) -> Unit = { event ->
eventSink?.success(event)
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
binding.platformViewRegistry.registerViewFactory(
PREVIEW_VIEW_TYPE,
StreamingPreviewFactory { mainHandlerRebindPreview() },
)
ServiceEventBus.addListener(busListener)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
ServiceEventBus.removeListener(busListener)
releaseEngine()
unbindServiceIfNeeded()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
mainHandlerRebindPreview()
}
private fun mainHandlerRebindPreview() {
android.os.Handler(android.os.Looper.getMainLooper()).post {
attachPreviewIfPossible()
}
}
override fun onDetachedFromActivity() {
activityBinding = null
unbindServiceIfNeeded()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startStream" -> startStream(call, result)
"stopStream" -> stopStream(result)
"getMetrics" -> getMetrics(result)
"startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result)
"unbindPreview" -> unbindPreview(result)
else -> result.notImplemented()
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
private fun startStream(call: MethodCall, result: Result) {
if (activityBinding?.activity == null) {
result.error("no_activity", "Activity non disponibile per lo streaming", null)
return
}
val args = call.arguments as? Map<*, *>
val rtmpUrl = args?.get("rtmpUrl") as? String
if (rtmpUrl.isNullOrBlank()) {
result.error("invalid_args", "rtmpUrl is required", null)
return
}
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: 1280,
height = (args["height"] as? Number)?.toInt() ?: 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
val engine = getOrCreateEngine()
engine.removeListener(engineListener)
engine.addListener(engineListener)
attachPreviewIfPossible()
val intent = StreamingForegroundService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
bindServiceIfNeeded()
try {
engine.startStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("start_failed", exception.message, null)
}
}
private fun stopStream(result: Result) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
unbindServiceIfNeeded()
result.success(true)
}
private fun getMetrics(result: Result) {
val metrics = boundService?.getMetrics()
?: getEngine()?.getMetrics()?.toMap()
?: emptyMap()
result.success(metrics)
}
private fun bindPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
}
private fun unbindPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun startPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
}
private fun stopPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun getOrCreateEngine(): StreamingEngine {
val context = activityBinding?.activity ?: appContext
return StreamingEngineHolder.getOrCreate(context).also { engine ->
engine.removeListener(engineListener)
engine.addListener(engineListener)
}
}
private fun getEngine(): StreamingEngine? = StreamingEngineHolder.engine
private fun releaseEngine() {
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
StreamingEngineHolder.release()
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView ?: return
val engine = getOrCreateEngine()
engine.bindStreamPreview(glView)
}
private fun bindServiceIfNeeded() {
if (serviceBound) {
return
}
val intent = Intent(appContext, StreamingForegroundService::class.java)
appContext.bindService(
intent,
serviceConnection,
Context.BIND_AUTO_CREATE,
)
}
private fun unbindServiceIfNeeded() {
if (!serviceBound) {
return
}
try {
appContext.unbindService(serviceConnection)
} catch (_: IllegalArgumentException) {
}
serviceBound = false
boundService = null
}
companion object {
private const val METHOD_CHANNEL = "com.matchlivetv.match_live_tv/streaming"
private const val EVENT_CHANNEL = "com.matchlivetv.match_live_tv/streaming_events"
const val PREVIEW_VIEW_TYPE = "match_live_tv/streaming_preview"
}
}

View File

@@ -0,0 +1,42 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import com.pedro.library.view.OpenGlView
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView(
context: Context,
private val onPreviewReady: (OpenGlView) -> Unit,
) : PlatformView {
private val openGlView = OpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
init {
StreamPreviewHolder.openGlView = openGlView
onPreviewReady(openGlView)
}
override fun getView(): View = openGlView
override fun dispose() {
// Non azzerare il holder: la rotazione ricrea la PlatformView e il plugin riaggancia.
}
}
class StreamingPreviewFactory(
private val onPreviewReady: (OpenGlView) -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onPreviewReady)
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="streaming_notification_channel">Streaming live</string>
<string name="streaming_notification_channel_desc">Notifiche durante lo streaming Match Live TV</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Streaming attivo</string>
<string name="streaming_notification_connecting">Connessione al server RTMP…</string>
<string name="streaming_notification_streaming">Trasmissione in corso</string>
<string name="streaming_notification_reconnecting">Riconnessione in corso…</string>
<string name="streaming_notification_error">Errore di streaming</string>
<string name="streaming_notification_stop">Stop</string>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Dev: API Rails su host (emulatore → 10.0.2.2) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@@ -0,0 +1,35 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
include(":app")

34
mobile/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Match Live Tv</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>match_live_tv</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/auth/login_screen.dart';
import '../features/auth/splash_screen.dart';
import '../features/camera_mode/camera_screen.dart';
import '../features/controller_mode/controller_screen.dart';
import '../features/archive/archive_screen.dart';
import '../features/matches/matches_screen.dart';
import '../features/setup_wizard/wizard_shell.dart';
import '../providers/auth_provider.dart';
/// Notifica GoRouter solo su cambi auth rilevanti (non su ogni loading tick).
class _AuthRefreshNotifier extends ChangeNotifier {
_AuthRefreshNotifier(this._ref) {
_ref.listen<AuthState>(authProvider, (prev, next) {
final authChanged = prev?.isAuthenticated != next.isAuthenticated;
if (authChanged) notifyListeners();
});
}
final Ref _ref;
}
final routerProvider = Provider<GoRouter>((ref) {
final refresh = _AuthRefreshNotifier(ref);
return GoRouter(
initialLocation: '/',
refreshListenable: refresh,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggingIn = state.matchedLocation == '/login';
final onSplash = state.matchedLocation == '/';
if (onSplash) return null;
if (!auth.isAuthenticated && !loggingIn) {
return '/login';
}
if (auth.isAuthenticated && loggingIn) {
return '/matches';
}
return null;
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/matches',
builder: (context, state) => const MatchesScreen(),
),
GoRoute(
path: '/archive',
builder: (context, state) => const ArchiveScreen(),
),
GoRoute(
path: '/setup/:matchId/:step',
builder: (context, state) {
final matchId = state.pathParameters['matchId']!;
final step = int.tryParse(state.pathParameters['step'] ?? '1') ?? 1;
return WizardShell(matchId: matchId, step: step);
},
),
GoRoute(
path: '/session/:id/camera',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return CameraScreen(sessionId: sessionId);
},
),
GoRoute(
path: '/session/:id/controller',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return ControllerScreen(sessionId: sessionId);
},
),
],
);
});

93
mobile/lib/app/theme.dart Normal file
View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Brand Match Live TV — dark theme, rosso #FF2D2D, Barlow Condensed.
class AppTheme {
AppTheme._();
static const Color primaryRed = Color(0xFFFF2D2D);
static const Color background = Color(0xFF0A0A0A);
static const Color surface = Color(0xFF1E1E1E);
static const Color surfaceElevated = Color(0xFF2A2A2A);
static const Color accentYellow = Color(0xFFF5C518);
static const Color successGreen = Color(0xFF22C55E);
static const Color textSecondary = Color(0xFF9CA3AF);
static TextTheme get _textTheme {
final base = GoogleFonts.barlowCondensedTextTheme(
ThemeData.dark().textTheme,
);
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
),
displayMedium: base.displayMedium?.copyWith(fontWeight: FontWeight.w900),
displaySmall: base.displaySmall?.copyWith(fontWeight: FontWeight.w900),
headlineLarge: base.headlineLarge?.copyWith(fontWeight: FontWeight.w900),
headlineMedium: base.headlineMedium?.copyWith(fontWeight: FontWeight.w800),
headlineSmall: base.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700),
titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700),
labelLarge: base.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
bodyLarge: base.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
bodyMedium: base.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: textSecondary,
),
);
}
static ThemeData get dark {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: background,
colorScheme: const ColorScheme.dark(
primary: primaryRed,
secondary: accentYellow,
surface: surface,
error: primaryRed,
onPrimary: Colors.white,
onSecondary: Colors.black,
onSurface: Colors.white,
),
textTheme: _textTheme,
appBarTheme: AppBarTheme(
backgroundColor: background,
elevation: 0,
centerTitle: true,
titleTextStyle: _textTheme.titleLarge?.copyWith(color: Colors.white),
iconTheme: const IconThemeData(color: Colors.white),
),
cardTheme: CardThemeData(
color: surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceElevated,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
labelStyle: _textTheme.bodyMedium,
hintStyle: _textTheme.bodyMedium,
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return primaryRed;
return surfaceElevated;
}),
foregroundColor: WidgetStateProperty.all(Colors.white),
),
),
dividerColor: surfaceElevated,
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../shared/models/user.dart';
class AuthStorage {
static const _keyAccess = 'auth_access_token';
static const _keyRefresh = 'auth_refresh_token';
static const _keyUserId = 'auth_user_id';
static const _keyUserEmail = 'auth_user_email';
static const _keyUserName = 'auth_user_name';
static const _keyUserRole = 'auth_user_role';
static Future<({User user, String accessToken, String refreshToken})?> load() async {
final prefs = await SharedPreferences.getInstance();
final access = prefs.getString(_keyAccess);
final refresh = prefs.getString(_keyRefresh);
final userId = prefs.getString(_keyUserId);
final email = prefs.getString(_keyUserEmail);
final name = prefs.getString(_keyUserName);
if (access == null ||
access.isEmpty ||
refresh == null ||
refresh.isEmpty ||
userId == null ||
email == null ||
name == null) {
return null;
}
return (
user: User(
id: userId,
email: email,
name: name,
role: prefs.getString(_keyUserRole) ?? 'coach',
),
accessToken: access,
refreshToken: refresh,
);
}
static Future<void> save({
required User user,
required String accessToken,
required String refreshToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAccess, accessToken);
await prefs.setString(_keyRefresh, refreshToken);
await prefs.setString(_keyUserId, user.id);
await prefs.setString(_keyUserEmail, user.email);
await prefs.setString(_keyUserName, user.name);
await prefs.setString(_keyUserRole, user.role);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAccess);
await prefs.remove(_keyRefresh);
await prefs.remove(_keyUserId);
await prefs.remove(_keyUserEmail);
await prefs.remove(_keyUserName);
await prefs.remove(_keyUserRole);
}
}

View File

@@ -0,0 +1,24 @@
/// Configurazione globale dell'app Match Live TV.
class AppConfig {
AppConfig._();
/// URL base API Rails. Default emulatore Android → host localhost.
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:3000',
);
static String get apiV1 => '$apiBaseUrl/api/v1';
static String get cableUrl {
final uri = Uri.parse(apiBaseUrl);
final wsScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final port = uri.hasPort ? uri.port : defaultPort;
// Evita :443/:80 espliciti — alcuni client WebSocket/NPM li gestiscono male
if (port == defaultPort) {
return '$wsScheme://${uri.host}/cable';
}
return '$wsScheme://${uri.host}:$port/cable';
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart';
class ArchiveScreen extends ConsumerWidget {
const ArchiveScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamAsync = ref.watch(primaryTeamProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Archivio gare'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'),
),
),
body: teamAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore: $e')),
data: (team) {
if (team == null) {
return const Center(child: Text('Nessuna squadra'));
}
if (!team.canUseRecordings) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Archivio replay con Premium Light o Full',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
context,
message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.',
billingUrl: team.billingUrl,
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
child: const Text('Scopri Premium'),
),
],
),
),
);
}
final recsAsync = ref.watch(recordingsProvider(team.id));
return recsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('$e')),
data: (recs) {
if (recs.isEmpty) {
return const Center(child: Text('Nessuna gara in archivio'));
}
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: recs.length,
itemBuilder: (context, i) {
final r = recs[i];
return Card(
color: AppTheme.surface,
child: ListTile(
title: Text('${r.teamName} vs ${r.opponentName}'),
subtitle: Text(
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '',
),
trailing: const Icon(Icons.play_circle_outline),
onTap: () async {
final url = r.replayUrl;
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: team.canDownloadOnPhone
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
}
},
),
);
},
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
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 '../../features/matches/team_providers.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.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});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'coach@matchlivetv.test');
final _passwordController = TextEditingController(text: 'password123');
bool _obscure = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final connectivity = await Connectivity().checkConnectivity();
if (connectivity.contains(ConnectivityResult.none)) {
ref.read(authProvider.notifier).setError(
'Nessuna connessione. Disattiva la modalità aereo e verifica WiFi.',
);
return;
}
ref.read(authProvider.notifier).setLoading(true);
try {
final client = ApiClient();
final result = await client.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
ref.read(authProvider.notifier).setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
ref.read(authProvider.notifier).setLoading(false);
if (mounted) context.go('/matches');
return;
} on DioException catch (e) {
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError =>
'Server non raggiungibile. Verifica che il backend sia avviato (docker compose up).',
DioExceptionType.badResponse when e.response?.statusCode == 401 =>
'Email o password non corretti',
_ => 'Errore di rete: ${e.message}',
};
ref.read(authProvider.notifier).setError(message);
} catch (e) {
ref.read(authProvider.notifier).setError('Errore imprevisto: $e');
}
ref.read(authProvider.notifier).setLoading(false);
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final theme = Theme.of(context);
return Scaffold(
body: SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 24, vertical: 16),
child: Form(
key: _formKey,
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',
),
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,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
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: 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/auth_provider.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/screen_insets.dart';
/// Splash con wordmark e slogan.
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
await ref.read(authProvider.notifier).restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 800));
if (!mounted) return;
final auth = ref.read(authProvider);
if (auth.isAuthenticated) {
context.go('/matches');
} else {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
body: AppSafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const MatchLiveWordmark(showSlogan: true),
const SizedBox(height: 48),
const CircularProgressIndicator(color: AppTheme.primaryRed),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,652 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:battery_plus/battery_plus.dart';
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 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../app/theme.dart';
import '../../platform/streaming_channel.dart';
import '../../platform/streaming_preview.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../shared/live_score_actions.dart';
import '../../shared/api_client.dart';
import '../../shared/models/score_state.dart';
import '../../providers/session_provider.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/camera_compact_metrics.dart';
import '../../shared/widgets/camera_score_controls.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/end_stream_dialog.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/screen_insets.dart';
class CameraScreen extends ConsumerStatefulWidget {
const CameraScreen({super.key, required this.sessionId});
final String sessionId;
@override
ConsumerState<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> {
static const _previewKey = ValueKey<String>('camera_streaming_preview');
Timer? _elapsedTimer;
Timer? _telemetryTimer;
Timer? _statsTimer;
int _batteryLevel = 100;
int? _lastDelta;
int _prevHomePoints = 0;
int _prevAwayPoints = 0;
double _bitrateMbps = 0;
int _fps = 0;
String _networkType = '4G';
int _viewerCount = 0;
StreamSubscription<Map<String, dynamic>>? _metricsSub;
Orientation? _lastOrientation;
@override
void initState() {
super.initState();
WakelockPlus.enable();
_lockOrientations();
_bootstrap();
_readBattery();
_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!),
);
}
});
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final orientation = MediaQuery.orientationOf(context);
if (_lastOrientation != null && _lastOrientation != orientation) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await StreamingChannel.bindPreview();
});
}
_lastOrientation = orientation;
}
Future<bool> _ensurePermissions() async {
final camera = await Permission.camera.request();
final mic = await Permission.microphone.request();
if (camera.isGranted && mic.isGranted) return true;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servono permessi fotocamera e microfono per la diretta'),
),
);
}
return false;
}
Future<void> _bootstrap() async {
try {
if (!await _ensurePermissions()) return;
final client = ref.read(apiClientProvider);
var session = await client.fetchSession(widget.sessionId);
if (session.isTerminal) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Questa diretta è già terminata'),
),
);
context.go('/matches');
}
return;
}
final match = await client.fetchMatch(session.matchId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// Ripresa dopo crash: la sessione resta connecting/live, riallineiamo lo stato.
if (session.status == 'idle' || session.status == 'paused') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
}
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300));
await StreamingChannel.bindPreview();
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.rtmpIngestUrl != null) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
);
if (mounted && fresh.canResumeBroadcast) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — stai di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
}
_metricsSub = StreamingChannel.metricsStream
.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e as Map))
.listen((event) {
if (event['type'] != 'metrics') return;
final metrics = Map<String, dynamic>.from(
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
);
setState(() {
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
});
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore streaming: ${e.message ?? e.code}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore avvio camera: $e')),
);
}
}
}
Future<void> _readBattery() async {
final level = await Battery().batteryLevel;
if (mounted) setState(() => _batteryLevel = level);
}
Future<void> _sendTelemetry() async {
try {
await ref.read(apiClientProvider).postTelemetry(
sessionId: widget.sessionId,
deviceRole: 'camera',
batteryLevel: _batteryLevel,
networkType: _networkType,
currentBitrate: (_bitrateMbps * 1_000_000).round(),
fps: _fps,
);
} catch (_) {}
}
Future<void> _fetchStats() async {
try {
final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId);
if (mounted) setState(() => _viewerCount = stats.concurrentViewers);
} catch (_) {}
}
Future<void> _lockOrientations() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
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,
);
}
Future<void> _leaveSession() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(websocketServiceProvider).disconnect();
} catch (_) {}
if (mounted) context.go('/matches');
}
/// Pausa temporanea: si può riprendere la stessa sessione.
Future<void> _interruptStream() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa sessione: $e')),
);
}
}
await _leaveSession();
}
/// Chiusura definitiva: sessione ended, pagina web senza player.
Future<void> _closeStreamPermanently() async {
if (!mounted) return;
final ok = await confirmEndStream(context);
if (!ok || !mounted) return;
try {
await StreamingChannel.stopStream();
} catch (_) {}
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 sessione: $e')),
);
}
}
await _leaveSession();
}
void _trackScoreDelta(ScoreState score) {
if (score.homePoints != _prevHomePoints) {
_lastDelta = score.homePoints - _prevHomePoints;
} else if (score.awayPoints != _prevAwayPoints) {
_lastDelta = score.awayPoints - _prevAwayPoints;
}
_prevHomePoints = score.homePoints;
_prevAwayPoints = score.awayPoints;
}
@override
void dispose() {
_elapsedTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_metricsSub?.cancel();
WakelockPlus.disable();
_unlockOrientation();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
_trackScoreDelta(score);
final match = sessionState.match;
final homeName = match?.teamName ?? 'HOME';
final awayName = match?.opponentName ?? 'AWAY';
final isLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet);
// Preview unica: non viene mai smontata al cambio orientamento.
return Scaffold(
backgroundColor: isLandscape ? Colors.black : AppTheme.background,
body: Stack(
fit: StackFit.expand,
children: [
const Positioned.fill(
child: NativeStreamingPreview(
key: _previewKey,
showGrid: true,
platformLabel: 'LIVE',
),
),
if (isLandscape)
_LandscapeOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
lastDelta: _lastDelta,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
)
else
_PortraitOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onHomePoint: () => _homePoint(),
onAwayPoint: () => _awayPoint(),
onHomeMinus: _homeMinus,
onAwayMinus: _awayMinus,
onCloseSet: () => _closeSet(),
onInterrupt: _interruptStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
),
],
),
);
}
}
class _PortraitOverlay extends StatelessWidget {
const _PortraitOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
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 Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
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 inset = ScreenInsets.cameraOverlay(context);
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
),
],
),
),
const Expanded(child: SizedBox.expand()),
Container(
color: AppTheme.background,
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CameraScoreControls(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi (riprendibile)'),
),
const SizedBox(height: 8),
DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
],
),
),
],
);
}
}
class _LandscapeOverlay extends StatelessWidget {
const _LandscapeOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
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 Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
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 inset = ScreenInsets.cameraOverlay(context);
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, compact: true),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
light: true,
),
],
),
),
const Expanded(child: SizedBox.expand()),
Container(
color: Colors.black.withValues(alpha: 0.82),
padding: EdgeInsets.fromLTRB(
inset.left,
10,
inset.right,
math.max(inset.bottom, 12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CameraScoreControls(
compact: true,
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onHomePoint: onHomePoint,
onAwayPoint: onAwayPoint,
onHomeMinus: onHomeMinus,
onAwayMinus: onAwayMinus,
onCloseSet: onCloseSet,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onInterrupt,
child: const Text('Interrompi', style: TextStyle(fontSize: 11)),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi',
compact: true,
onPressed: () => onCloseStream(),
),
),
],
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenLandscape extends StatelessWidget {
const CameraScreenLandscape({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
NativeStreamingPreview(showGrid: false, platformLabel: platformLabel),
Positioned(
top: 12,
left: 12,
child: LiveBadge(elapsed: elapsed, compact: true),
),
Positioned(
top: 12,
right: 12,
child: Row(
children: [
Icon(Icons.battery_std, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Text(
'$batteryLevel%',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
Positioned(
bottom: 16,
left: 16,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
compact: true,
lastDelta: lastDelta,
),
),
Positioned(
bottom: 16,
left: 0,
right: 100,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'fps',
value: '$fps',
icon: Icons.movie,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Spett.',
value: viewerCount > 0 ? '$viewerCount' : 'LIVE',
icon: Icons.visibility,
expand: false,
),
],
),
),
),
Positioned(
bottom: 16,
right: 16,
child: DestructiveCta(
label: 'INTERROMPI',
compact: true,
onPressed: onStop,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenPortrait extends StatelessWidget {
const CameraScreenPortrait({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
required this.sessionId,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String sessionId;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: AppTheme.background,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
const Spacer(),
const Icon(Icons.battery_std, size: 18, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text('$batteryLevel%', style: theme.textTheme.labelLarge),
],
),
),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: NativeStreamingPreview(
showGrid: true,
platformLabel: platformLabel,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
),
const SizedBox(width: 8),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
),
const SizedBox(width: 8),
MetricCard(label: 'fps', value: '$fps', icon: Icons.movie),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.play_circle, color: AppTheme.primaryRed),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(platformLabel, style: theme.textTheme.titleMedium),
Text(
viewerCount > 0
? '$viewerCount spettatori'
: 'IN DIRETTA',
style: theme.textTheme.bodyMedium,
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Registrazione locale attiva: /sd/match_$sessionId',
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
textAlign: TextAlign.center,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: DestructiveCta(label: 'INTERROMPI', onPressed: onStop),
),
],
),
),
);
}
}

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

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

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

View File

@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
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 'team_providers.dart';
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) return [];
final client = ref.read(apiClientProvider);
return client.fetchMatches(teams.first.id);
});
class MatchesScreen extends ConsumerWidget {
const MatchesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final teamsAsync = ref.watch(teamsProvider);
final matchesAsync = ref.watch(matchesProvider);
final theme = Theme.of(context);
final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT');
final bottomInset = ScreenInsets.of(context).bottom;
return Scaffold(
appBar: AppBar(
title: const MatchLiveWordmark(compact: true),
actions: [
IconButton(
icon: const Icon(Icons.group_outlined),
tooltip: 'Gestisci staff',
onPressed: () async {
final team = await ref.read(primaryTeamProvider.future);
final url = team?.staffManageUrl ?? team?.billingUrl;
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
},
),
IconButton(
icon: const Icon(Icons.video_library_outlined),
tooltip: 'Archivio',
onPressed: () => context.push('/archive'),
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Esci',
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
],
),
body: teamsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore squadre: $e')),
data: (teams) {
if (teams.isEmpty) {
return NoTeamOnboarding(theme: theme);
}
return matchesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Errore nel caricamento', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
e.toString(),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
},
child: const Text('RIPROVA'),
),
],
),
),
),
data: (matches) {
MatchModel? activeMatch;
for (final m in matches) {
if (m.hasActiveSession) {
activeMatch = m;
break;
}
}
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async {
ref.invalidate(matchesProvider);
await ref.read(matchesProvider.future);
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ciao, ${auth.user?.name ?? ''}',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
'Le tue partite',
style: theme.textTheme.bodyMedium,
),
if (activeMatch != null) ...[
const SizedBox(height: 12),
Material(
color: AppTheme.primaryRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => showResumeSessionSheet(
context,
ref,
match: activeMatch!,
),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
Icons.videocam,
color: AppTheme.primaryRed,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Riprendi diretta in corso',
style: theme.textTheme.titleSmall
?.copyWith(
color: AppTheme.primaryRed,
fontWeight: FontWeight.w700,
),
),
Text(
'${activeMatch.teamName} vs ${activeMatch.opponentName}',
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppTheme.primaryRed,
),
],
),
),
),
),
],
],
),
),
),
if (matches.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Nessuna partita programmata',
style: theme.textTheme.bodyMedium,
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final match = matches[index];
return _MatchCard(
match: match,
dateFormat: dateFormat,
onTap: () {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
},
onDelete: match.canResumeCamera
? null
: () => _deleteMatch(context, ref, match),
);
},
childCount: matches.length,
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset),
child: PrimaryCta(
label: 'NUOVA PARTITA',
icon: Icons.add,
onPressed: () => _createMatch(context, ref),
),
),
),
],
),
);
},
);
},
),
);
}
Future<void> _deleteMatch(
BuildContext context,
WidgetRef ref,
MatchModel match,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Elimina partita'),
content: Text(
'Eliminare «${match.teamName} vs ${match.opponentName}»?\n\n'
'L\'operazione non si può annullare.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Elimina'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(apiClientProvider).deleteMatch(match.id);
ref.invalidate(matchesProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partita eliminata')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossibile eliminare: $e')),
);
}
}
}
Future<void> _createMatch(BuildContext context, WidgetRef ref) async {
final teams = await ref.read(teamsProvider.future);
if (teams.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
);
}
return;
}
final client = ref.read(apiClientProvider);
final match = await client.createMatch(teams.first.id, {
'opponent_name': 'Avversario',
'sport': 'volleyball',
'sets_to_win': 3,
});
if (context.mounted) context.push('/setup/${match.id}/1');
}
}
class _MatchCard extends StatelessWidget {
const _MatchCard({
required this.match,
required this.dateFormat,
required this.onTap,
this.onDelete,
});
final MatchModel match;
final DateFormat dateFormat;
final VoidCallback onTap;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasSession = match.hasActiveSession;
final canResume = match.canResumeCamera;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${match.teamName} vs ${match.opponentName}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
if (match.scheduledAt != null)
Text(
dateFormat.format(match.scheduledAt!.toLocal()),
style: theme.textTheme.bodyMedium,
),
if (match.location != null && match.location!.isNotEmpty)
Text(
match.location!,
style: theme.textTheme.bodyMedium,
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: hasSession
? AppTheme.primaryRed.withValues(alpha: 0.2)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(8),
),
child: Text(
canResume ? 'RIPRENDI' : (hasSession ? 'IN CORSO' : 'CONFIGURA'),
style: theme.textTheme.labelLarge?.copyWith(
color: hasSession ? AppTheme.primaryRed : AppTheme.textSecondary,
fontSize: 11,
),
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline, color: AppTheme.textSecondary),
tooltip: 'Elimina partita',
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../core/config.dart';
import '../../shared/widgets/primary_cta.dart';
class NoTeamOnboarding extends StatelessWidget {
const NoTeamOnboarding({required this.theme});
final ThemeData theme;
String get _signupUrl {
final base = AppConfig.apiBaseUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
return '$base/signup';
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.groups_outlined, size: 64, color: AppTheme.textSecondary),
const SizedBox(height: 16),
Text(
'Completa l\'iscrizione sul sito',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Crea la tua squadra e scegli il piano Free o Premium da browser. '
'Poi torna qui con le stesse credenziali.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
PrimaryCta(
label: 'VAI AL SITO',
icon: Icons.open_in_new,
onPressed: () async {
final uri = Uri.parse(_signupUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/models/match.dart';
/// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.).
void showResumeSessionSheet(
BuildContext context,
WidgetRef ref, {
required MatchModel match,
}) {
final sessionId = match.activeSessionId;
if (sessionId == null) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Diretta in corso',
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${match.teamName} vs ${match.opponentName}',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (match.activeSessionStatus != null) ...[
const SizedBox(height: 8),
Text(
'Stato: ${match.activeSessionStatus}',
style: Theme.of(ctx).textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
),
),
],
const SizedBox(height: 20),
if (match.canResumeCamera)
FilledButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera);
Navigator.pop(ctx);
context.push('/session/$sessionId/camera');
},
icon: const Icon(Icons.videocam),
label: const Text('Riprendi camera e trasmetti'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
if (match.activeSessionStatus == 'idle') ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
context.push('/setup/${match.id}/1');
},
icon: const Icon(Icons.settings),
label: const Text('Continua configurazione'),
),
],
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller);
Navigator.pop(ctx);
context.push('/session/$sessionId/controller');
},
icon: const Icon(Icons.sports_esports),
label: const Text('Apri regia (punteggio)'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annulla'),
),
],
),
),
);
},
);
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/models/team.dart';
final teamsProvider = FutureProvider<List<Team>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final client = ref.read(apiClientProvider);
return client.fetchTeams();
});
final primaryTeamProvider = FutureProvider<Team?>((ref) async {
final teams = await ref.watch(teamsProvider.future);
if (teams.isEmpty) return null;
return teams.first;
});
final teamByIdProvider = FutureProvider.family<Team?, String>((ref, teamId) async {
final teams = await ref.watch(teamsProvider.future);
for (final t in teams) {
if (t.id == teamId) return t;
}
return null;
});
final recordingsProvider = FutureProvider.family<List<RecordingItem>, String>((ref, teamId) async {
final team = await ref.watch(teamByIdProvider(teamId).future);
if (team == null || !team.recordingsEnabled) return [];
final client = ref.read(apiClientProvider);
return client.fetchRecordings(teamId);
});

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepMatch extends ConsumerStatefulWidget {
const StepMatch({
super.key,
required this.match,
required this.onNext,
});
final MatchModel match;
final VoidCallback onNext;
@override
ConsumerState<StepMatch> createState() => _StepMatchState();
}
class _StepMatchState extends ConsumerState<StepMatch> {
late final TextEditingController _opponentController;
late final TextEditingController _locationController;
late final TextEditingController _categoryController;
late final TextEditingController _phaseController;
late final TextEditingController _rosterController;
int _setsToWin = 3;
DateTime? _scheduledAt;
bool _saving = false;
bool _customRules = false;
int _pointsPerSet = 25;
int _pointsDecidingSet = 15;
int _minPointLead = 2;
@override
void initState() {
super.initState();
_opponentController = TextEditingController(text: widget.match.opponentName);
_locationController = TextEditingController(text: widget.match.location ?? '');
_categoryController = TextEditingController(text: widget.match.category ?? '');
_phaseController = TextEditingController(text: widget.match.phase ?? '');
_rosterController = TextEditingController(
text: widget.match.rosterNumbers.join(', '),
);
_setsToWin = widget.match.setsToWin;
_scheduledAt = widget.match.scheduledAt;
final rules = widget.match.scoringRules;
if (rules != null && rules.isNotEmpty) {
_customRules = true;
_pointsPerSet = rules['points_per_set'] as int? ?? 25;
_pointsDecidingSet = rules['points_deciding_set'] as int? ?? 15;
_minPointLead = rules['min_point_lead'] as int? ?? 2;
}
}
@override
void dispose() {
_opponentController.dispose();
_locationController.dispose();
_categoryController.dispose();
_phaseController.dispose();
_rosterController.dispose();
super.dispose();
}
List<int> _parseRoster(String raw) {
return raw
.split(RegExp(r'[,\s]+'))
.where((s) => s.isNotEmpty)
.map(int.tryParse)
.whereType<int>()
.toList();
}
Future<void> _pickDateTime() async {
final date = await showDatePicker(
context: context,
initialDate: _scheduledAt ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date == null || !mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_scheduledAt ?? DateTime.now()),
);
if (time == null) return;
setState(() {
_scheduledAt = DateTime(date.year, date.month, date.day, time.hour, time.minute);
});
}
Future<void> _save() async {
if (_opponentController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inserisci il nome avversario')),
);
return;
}
setState(() => _saving = true);
try {
final client = ref.read(apiClientProvider);
await client.updateMatch(widget.match.id, {
'opponent_name': _opponentController.text.trim(),
'location': _locationController.text.trim(),
'scheduled_at': _scheduledAt?.toIso8601String(),
'sets_to_win': _setsToWin,
if (_customRules)
'scoring_rules': {
'points_per_set': _pointsPerSet,
'points_deciding_set': _pointsDecidingSet,
'min_point_lead': _minPointLead,
},
'category': _categoryController.text.trim(),
'phase': _phaseController.text.trim(),
'roster_numbers': _parseRoster(_rosterController.text),
});
widget.onNext();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore nel salvataggio')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateLabel = _scheduledAt != null
? DateFormat('EEE d MMM yyyy · HH:mm', 'it_IT').format(_scheduledAt!.toLocal())
: 'Seleziona data e ora';
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Dettagli partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 20),
_ReadOnlyField(label: 'Squadra casa', value: widget.match.teamName),
const SizedBox(height: 12),
TextField(
controller: _opponentController,
decoration: const InputDecoration(labelText: 'Avversario'),
),
const SizedBox(height: 12),
TextField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Luogo'),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Orario', style: theme.textTheme.bodyMedium),
subtitle: Text(dateLabel, style: theme.textTheme.titleMedium),
trailing: const Icon(Icons.calendar_today),
onTap: _pickDateTime,
),
const SizedBox(height: 12),
Text('Set da vincere', style: theme.textTheme.bodyMedium),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
],
selected: {_setsToWin},
onSelectionChanged: (s) => setState(() => _setsToWin = s.first),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Regole punteggio personalizzate'),
subtitle: const Text(
'Per tornei non standard (punti set, tie-break, vantaggio minimo)',
),
value: _customRules,
onChanged: (v) => setState(() => _customRules = v),
),
if (_customRules) ...[
const SizedBox(height: 8),
Text('Punti per vincere un set', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 21, label: Text('21')),
ButtonSegment(value: 25, label: Text('25')),
ButtonSegment(value: 30, label: Text('30')),
],
selected: {_pointsPerSet},
onSelectionChanged: (s) => setState(() => _pointsPerSet = s.first),
),
const SizedBox(height: 10),
Text('Punti set decisivo', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 15, label: Text('15')),
ButtonSegment(value: 21, label: Text('21')),
],
selected: {_pointsDecidingSet},
onSelectionChanged: (s) => setState(() => _pointsDecidingSet = s.first),
),
const SizedBox(height: 10),
Text('Vantaggio minimo', style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
],
selected: {_minPointLead},
onSelectionChanged: (s) => setState(() => _minPointLead = s.first),
),
],
const SizedBox(height: 12),
TextField(
controller: _categoryController,
decoration: const InputDecoration(labelText: 'Categoria'),
),
const SizedBox(height: 12),
TextField(
controller: _phaseController,
decoration: const InputDecoration(labelText: 'Fase (es. semifinale)'),
),
const SizedBox(height: 12),
TextField(
controller: _rosterController,
decoration: const InputDecoration(
labelText: 'Convocate (numeri maglia)',
hintText: '1, 5, 7, 12',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 32),
PrimaryCta(
label: 'AVANTI >',
loading: _saving,
onPressed: _save,
icon: Icons.arrow_forward,
),
],
),
);
}
}
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(height: 4),
Text(value, style: theme.textTheme.titleMedium),
],
),
);
}
}

View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart';
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/score_provider.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/models/score_state.dart';
import '../../shared/models/stream_session.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepNetworkTest extends ConsumerStatefulWidget {
const StepNetworkTest({
super.key,
required this.match,
required this.onBack,
});
final MatchModel match;
final VoidCallback onBack;
@override
ConsumerState<StepNetworkTest> createState() => _StepNetworkTestState();
}
class _StepNetworkTestState extends ConsumerState<StepNetworkTest> {
bool _testing = false;
bool _ready = false;
double _downloadMbps = 0;
double _uploadMbps = 0;
int _latencyMs = 0;
String _networkType = '';
bool _starting = false;
Future<void> _runTest() async {
setState(() {
_testing = true;
_ready = false;
});
final connectivity = await Connectivity().checkConnectivity();
final networkLabel = _connectivityLabel(connectivity);
// Simulazione test rete locale (sostituibile con speed test reale).
await Future<void>.delayed(const Duration(seconds: 2));
final rng = Random();
final download = 8 + rng.nextDouble() * 12;
final upload = 2 + rng.nextDouble() * 4;
final latency = 20 + rng.nextInt(80);
final session = ref.read(sessionProvider).session;
NetworkTestResult? testResult;
if (session != null) {
try {
final client = ref.read(apiClientProvider);
testResult = await client.networkTest(
sessionId: session.id,
downloadMbps: download,
uploadMbps: upload,
latencyMs: latency,
networkType: networkLabel,
);
} catch (_) {
testResult = null;
}
}
if (mounted) {
setState(() {
_testing = false;
_downloadMbps = download;
_uploadMbps = upload;
_latencyMs = latency;
_networkType = networkLabel;
_ready = testResult?.ready ?? upload >= 2.0;
});
}
}
String _connectivityLabel(List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'Ethernet';
return 'Sconosciuto';
}
Future<void> _startLive() async {
final sessionState = ref.read(sessionProvider);
final session = sessionState.session;
if (session == null) return;
setState(() => _starting = true);
try {
final client = ref.read(apiClientProvider);
final started = await client.startSession(session.id);
ref.read(sessionProvider.notifier).setSession(started, match: widget.match);
if (started.score != null) {
ref.read(scoreProvider.notifier).reset(started.score!);
} else {
ref.read(scoreProvider.notifier).reset(const ScoreState());
}
final role = sessionState.deviceRole;
final path = role == DeviceRole.camera
? '/session/${started.id}/camera'
: '/session/${started.id}/controller';
if (!mounted) return;
context.go(path);
// WebSocket in background: non bloccare apertura camera/regia
try {
final ws = ref.read(websocketServiceProvider);
await ws.connect(
sessionId: started.id,
deviceRole: role == DeviceRole.camera ? 'camera' : 'controller',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Diretta avviata ma sync regia limitata: $e',
),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore avvio diretta: $e')),
);
}
} finally {
if (mounted) setState(() => _starting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Test rete', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'Verifica che la connessione regga l\'upload della diretta.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
Row(
children: [
MetricCard(
label: 'Download',
value: _testing ? '...' : '${_downloadMbps.toStringAsFixed(1)} Mbps',
icon: Icons.download,
),
const SizedBox(width: 8),
MetricCard(
label: 'Upload',
value: _testing ? '...' : '${_uploadMbps.toStringAsFixed(1)} Mbps',
icon: Icons.upload,
highlight: _ready,
),
const SizedBox(width: 8),
MetricCard(
label: 'Latenza',
value: _testing ? '...' : '${_latencyMs} ms',
icon: Icons.speed,
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: MetricCard(
label: 'Tipo rete',
value: _networkType,
icon: Icons.signal_cellular_alt,
expand: false,
),
),
const SizedBox(height: 20),
if (_ready) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.successGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.successGreen.withValues(alpha: 0.4)),
),
child: Text(
'PRONTO PER ANDARE IN DIRETTA',
style: theme.textTheme.titleMedium?.copyWith(
color: AppTheme.successGreen,
),
textAlign: TextAlign.center,
),
),
if (ref.read(sessionProvider).session?.watchPageUrl != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Link per gli spettatori',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SelectableText(
ref.read(sessionProvider).session!.watchPageUrl!,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
final url = ref.read(sessionProvider).session!.watchPageUrl!;
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link copiato')),
);
},
icon: const Icon(Icons.link, size: 18),
label: const Text('COPIA LINK DIRETTA'),
),
],
),
),
],
],
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'In caso di disconnessione, lo stream resta attivo con slate per massimo 5 minuti mentre il telefono riconnette.',
style: theme.textTheme.bodyMedium,
),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _testing ? null : _runTest,
child: Text(_testing ? 'TEST IN CORSO...' : 'AVVIA TEST RETE'),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _starting ? null : widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'INIZIA >',
loading: _starting,
onPressed: _ready ? _startLive : null,
icon: Icons.play_arrow,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,238 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class StepRoles extends ConsumerStatefulWidget {
const StepRoles({
super.key,
required this.match,
required this.onBack,
required this.onNext,
});
final MatchModel match;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<StepRoles> createState() => _StepRolesState();
}
class _StepRolesState extends ConsumerState<StepRoles> {
DeviceRole _role = DeviceRole.controller;
String? _qrData;
bool _loadingQr = false;
Future<void> _generateQr() async {
final session = ref.read(sessionProvider).session;
if (session == null) return;
setState(() => _loadingQr = true);
try {
final client = ref.read(apiClientProvider);
final response = await client.createPairingToken(session.id);
setState(() {
_qrData = jsonEncode(response.qrPayload);
});
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore generazione QR')),
);
}
} finally {
if (mounted) setState(() => _loadingQr = false);
}
}
void _confirm() {
ref.read(sessionProvider.notifier).setDeviceRole(_role);
widget.onNext();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Scegli il tuo ruolo', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _RoleCard(
title: 'CAMERA',
subtitle: 'Sul cavalletto',
icon: Icons.videocam,
selected: _role == DeviceRole.camera,
onTap: () => setState(() => _role = DeviceRole.camera),
),
),
const SizedBox(width: 12),
Expanded(
child: _RoleCard(
title: 'REGIA',
subtitle: 'In mano',
icon: Icons.sports_esports,
selected: _role == DeviceRole.controller,
onTap: () => setState(() => _role = DeviceRole.controller),
),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.accentYellow.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.accentYellow.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.battery_charging_full, color: AppTheme.accentYellow),
const SizedBox(width: 12),
Expanded(
child: Text(
'Mantieni in carica. Saranno 90 minuti a pieno carico.',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white),
),
),
],
),
),
const SizedBox(height: 24),
Text('Collega il secondo telefono', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: _qrData != null
? QrImageView(
data: _qrData!,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.white,
)
: SizedBox(
width: 180,
height: 180,
child: _loadingQr
? const Center(
child: CircularProgressIndicator(
color: AppTheme.primaryRed,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code, size: 48, color: Colors.black54),
const SizedBox(height: 8),
TextButton(
onPressed: _generateQr,
child: const Text(
'GENERA QR',
style: TextStyle(color: Colors.black),
),
),
],
),
),
),
),
const SizedBox(height: 8),
Text(
'Scansiona con l\'altro dispositivo per collegare camera e regia.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'AVANTI >',
onPressed: _confirm,
icon: Icons.arrow_forward,
),
),
],
),
],
),
);
}
}
class _RoleCard extends StatelessWidget {
const _RoleCard({
required this.title,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
final String title;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selected
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppTheme.primaryRed : Colors.transparent,
),
),
child: Column(
children: [
Icon(icon, size: 32, color: selected ? AppTheme.primaryRed : Colors.white),
const SizedBox(height: 8),
Text(title, style: theme.textTheme.titleMedium),
Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,331 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/api_error.dart';
import '../../shared/models/match.dart';
import '../../shared/premium_dialog.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
import '../../shared/models/team.dart';
import '../matches/team_providers.dart';
class StepTransmission extends ConsumerStatefulWidget {
const StepTransmission({
super.key,
required this.match,
required this.onBack,
required this.onNext,
});
final MatchModel match;
final VoidCallback onBack;
final VoidCallback onNext;
@override
ConsumerState<StepTransmission> createState() => _StepTransmissionState();
}
class _StepTransmissionState extends ConsumerState<StepTransmission> {
String _platform = 'matchlivetv';
String _privacy = 'unlisted';
bool _creating = false;
Future<void> _createSession() async {
setState(() => _creating = true);
try {
final client = ref.read(apiClientProvider);
final session = await client.createSession(
widget.match.id,
platform: _platform,
privacyStatus: _privacy,
qualityPreset: '720p_30_2.5mbps',
targetBitrate: 2500000,
targetFps: 30,
);
ref.read(sessionProvider.notifier).setSession(session, match: widget.match);
widget.onNext();
} on DioException catch (e) {
final apiErr = ApiException.fromDio(e);
if (mounted) {
if (apiErr?.isPremiumRequired == true) {
await showPremiumRequiredDialog(
context,
message: apiErr!.message,
billingUrl: apiErr.billingUrl,
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(apiErr?.message ?? 'Errore nella creazione sessione')),
);
}
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore nella creazione sessione')),
);
}
} finally {
if (mounted) setState(() => _creating = false);
}
}
void _onYoutubeTap(Team? team) {
if (team == null) return;
if (team.canUseYoutube && team.youtubeConnected) {
setState(() => _platform = 'youtube');
return;
}
if (team.canUseYoutube && !team.youtubeConnected) {
showPremiumRequiredDialog(
context,
message: 'Collega il canale YouTube dalla dashboard sul sito, poi seleziona YouTube qui.',
billingUrl: team.billingUrl,
);
return;
}
showPremiumRequiredDialog(
context,
message: 'YouTube Live è incluso nel piano Premium Full.',
billingUrl: team.billingUrl,
);
}
void _onExternalPremiumTap(String platform, Team? team) {
showPremiumRequiredDialog(
context,
message: '$platform è disponibile con Premium (in arrivo).',
billingUrl: team?.billingUrl,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final teamAsync = ref.watch(teamByIdProvider(widget.match.teamId));
return teamAsync.when(
loading: () => const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)),
error: (_, __) => const Center(child: Text('Errore caricamento squadra')),
data: (team) {
final youtubeSelectable = team != null && team.canUseYoutube && team.youtubeConnected;
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (team != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.3)),
),
child: Text(
'Piano ${team.planName} · Staff ${team.staffLimitLabel}'
'${team.concurrentStreamsLimit != null ? ' · Dirette ${team.concurrentStreamsUsed}/${team.concurrentStreamsLimit}' : ''}',
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 12),
],
Text('Piattaforma', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
_PlatformCard(
title: 'Match Live TV',
subtitle: 'Diretta sul nostro sito (incluso)',
selected: _platform == 'matchlivetv',
enabled: true,
onTap: () => setState(() => _platform = 'matchlivetv'),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'YouTube Live',
subtitle: team?.canUseYoutube == true
? (team!.youtubeConnected
? 'Canale collegato'
: 'Collega canale sul sito')
: 'Premium Full — collegamento canale',
selected: _platform == 'youtube',
enabled: youtubeSelectable,
badge: team?.canUseYoutube == true ? null : 'Full',
onTap: () => _onYoutubeTap(team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Facebook Live',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Facebook Live', team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Twitch',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Twitch', team),
),
const SizedBox(height: 24),
Text('Privacy', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'public', label: Text('Pubblico')),
ButtonSegment(value: 'unlisted', label: Text('Non in elenco')),
],
selected: {_privacy},
onSelectionChanged: (s) => setState(() => _privacy = s.first),
),
const SizedBox(height: 24),
Text('Qualità', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.4)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Bitrate fisso consigliato per reti instabili',
style: theme.textTheme.bodyMedium,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryRed.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'AUTO',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.primaryRed,
fontSize: 11,
),
),
),
],
),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _creating ? null : widget.onBack,
child: const Text('Indietro'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: PrimaryCta(
label: 'AVANTI >',
loading: _creating,
onPressed: _createSession,
icon: Icons.arrow_forward,
),
),
],
),
],
),
);
},
);
}
}
class _PlatformCard extends StatelessWidget {
const _PlatformCard({
required this.title,
this.subtitle,
required this.selected,
required this.enabled,
this.badge,
this.onTap,
});
final String title;
final String? subtitle;
final bool selected;
final bool enabled;
final String? badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Opacity(
opacity: enabled ? 1 : 0.55,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: selected
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? AppTheme.primaryRed : Colors.transparent,
),
),
child: Row(
children: [
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.bodyMedium),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(badge!, style: theme.textTheme.labelSmall),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/models/match.dart';
import '../../shared/api_client.dart';
import '../../shared/widgets/screen_insets.dart';
import 'step_match.dart';
import 'step_network_test.dart';
import 'step_roles.dart';
import 'step_transmission.dart';
class WizardShell extends ConsumerStatefulWidget {
const WizardShell({
super.key,
required this.matchId,
required this.step,
});
final String matchId;
final int step;
@override
ConsumerState<WizardShell> createState() => _WizardShellState();
}
class _WizardShellState extends ConsumerState<WizardShell> {
MatchModel? _match;
bool _loading = true;
static const _stepTitles = [
'01 · Partita',
'02 · Trasmissione',
'03 · Ruoli',
'04 · Test',
];
@override
void initState() {
super.initState();
_loadMatch();
}
@override
void didUpdateWidget(WizardShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.matchId != widget.matchId) {
_loadMatch();
}
}
Future<void> _loadMatch() async {
setState(() => _loading = true);
try {
final client = ref.read(apiClientProvider);
final match = await client.fetchMatch(widget.matchId);
ref.read(sessionProvider.notifier).setMatch(match);
if (mounted) {
setState(() {
_match = match;
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _goToStep(int step) {
context.go('/setup/${widget.matchId}/$step');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stepIndex = widget.step.clamp(1, 4) - 1;
return Scaffold(
appBar: AppBar(
title: Text(_stepTitles[stepIndex]),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.go('/matches'),
),
),
body: _loading
? const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
)
: Column(
children: [
_StepIndicator(current: widget.step),
Expanded(
child: AppSafeArea(
top: false,
child: _buildStep(stepIndex),
),
),
],
),
);
}
Widget _buildStep(int index) {
final match = _match;
if (match == null) {
return Center(
child: Text(
'Partita non trovata',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
switch (index) {
case 0:
return StepMatch(
match: match,
onNext: () => _goToStep(2),
);
case 1:
return StepTransmission(
match: match,
onBack: () => _goToStep(1),
onNext: () => _goToStep(3),
);
case 2:
return StepRoles(
match: match,
onBack: () => _goToStep(2),
onNext: () => _goToStep(4),
);
case 3:
return StepNetworkTest(
match: match,
onBack: () => _goToStep(3),
);
default:
return const SizedBox.shrink();
}
}
}
class _StepIndicator extends StatelessWidget {
const _StepIndicator({required this.current});
final int current;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: List.generate(4, (i) {
final step = i + 1;
final active = step <= current;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(right: i < 3 ? 6 : 0),
decoration: BoxDecoration(
color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../../shared/scoring/match_scoring_rules.dart';
import '../../shared/widgets/score_outcome_dialogs.dart';
/// Gestione regole punteggio, dialoghi set/partita e sync.
class LiveScoreActions {
LiveScoreActions(this.ref);
final WidgetRef ref;
bool _dialogOpen = false;
MatchScoringRules get _rules => ref.read(matchScoringRulesProvider);
Future<void> afterPointChange(
BuildContext context, {
required String homeName,
required String awayName,
required Future<void> Function() onCloseSetConfirmed,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
);
if (winner == null) return;
_dialogOpen = true;
final close = await showSetWonDialog(
context,
winner: winner,
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
);
_dialogOpen = false;
if (close && context.mounted) {
await onCloseSetConfirmed();
}
}
Future<void> requestCloseSet(
BuildContext context, {
required Future<void> Function() onCloseSetConfirmed,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
);
if (winner == null) {
final ok = await showCloseSetAnywayDialog(context);
if (!ok) return;
}
await onCloseSetConfirmed();
}
Future<void> afterCloseSet(
BuildContext context, {
required String homeName,
required String awayName,
required Future<void> Function() onStopStream,
}) async {
if (_dialogOpen || !context.mounted) return;
final score = ref.read(scoreProvider);
final winner = _rules.matchWinner(
homeSets: score.homeSets,
awaySets: score.awaySets,
);
if (winner == null) return;
_dialogOpen = true;
final stop = await showMatchWonDialog(
context,
winner: winner,
homeName: homeName,
awayName: awayName,
homeSets: score.homeSets,
awaySets: score.awaySets,
);
_dialogOpen = false;
if (stop && context.mounted) {
await onStopStream();
}
}
}

38
mobile/lib/main.dart Normal file
View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'app/router.dart';
import 'app/theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting('it_IT', null);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.light,
),
);
runApp(const ProviderScope(child: MatchLiveTvApp()));
}
class MatchLiveTvApp extends ConsumerWidget {
const MatchLiveTvApp({super.key});
@override
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,
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/services.dart';
/// Bridge verso engine nativo Android (CameraX + RootEncoder).
class StreamingChannel {
StreamingChannel._();
static const MethodChannel _channel =
MethodChannel('com.matchlivetv.match_live_tv/streaming');
static const EventChannel _metricsChannel =
EventChannel('com.matchlivetv.match_live_tv/streaming_events');
static Future<void> startPreview() async {
await _channel.invokeMethod('startPreview');
}
static Future<bool> bindPreview() async {
final ok = await _channel.invokeMethod<bool>('bindPreview');
return ok ?? false;
}
static Future<void> stopPreview() async {
await _channel.invokeMethod('stopPreview');
}
static Future<void> startStream({
required String rtmpUrl,
int targetBitrate = 2500000,
int targetFps = 30,
}) async {
await _channel.invokeMethod('startStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
});
}
static Future<void> stopStream() async {
await _channel.invokeMethod('stopStream');
}
static Future<void> pauseStream() async {
await _channel.invokeMethod('pauseStream');
}
static Future<Map<String, dynamic>?> getMetrics() async {
final result =
await _channel.invokeMethod<Map<dynamic, dynamic>>('getMetrics');
return result?.map((key, value) => MapEntry(key.toString(), value));
}
static EventChannel get metricsStream => _metricsChannel;
}

View File

@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme.
class NativeStreamingPreview extends StatelessWidget {
const NativeStreamingPreview({
super.key,
this.showGrid = true,
this.platformLabel = 'LIVE',
});
final bool showGrid;
final String platformLabel;
@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.android) {
return Stack(
fit: StackFit.expand,
children: [
AndroidView(
key: key,
viewType: 'match_live_tv/streaming_preview',
layoutDirection: TextDirection.ltr,
creationParamsCodec: StandardMessageCodec(),
),
if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel),
],
);
}
return CameraPreviewPlaceholder(showGrid: showGrid);
}
}
class CameraPreviewPlaceholder extends StatelessWidget {
const CameraPreviewPlaceholder({super.key, this.showGrid = true});
final bool showGrid;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: const Center(
child: Icon(Icons.videocam, size: 64, color: Colors.white24),
),
);
}
}
/// Overlay REC / badge sopra la preview nativa.
class CameraPreviewOverlay extends StatelessWidget {
const CameraPreviewOverlay({super.key, this.platformLabel = 'LIVE'});
final String platformLabel;
@override
Widget build(BuildContext context) {
final inset = MediaQuery.viewPaddingOf(context);
const gap = 8.0;
return Stack(
fit: StackFit.expand,
children: [
Positioned(
top: inset.top + gap,
left: inset.left + gap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'REC',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
),
Positioned(
top: inset.top + gap,
right: inset.right + gap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
platformLabel,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../shared/models/user.dart';
class AuthState {
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.loading = false,
this.error,
});
final User? user;
final String? accessToken;
final String? refreshToken;
final bool loading;
final String? error;
bool get isAuthenticated =>
accessToken != null && accessToken!.isNotEmpty && user != null;
AuthState copyWith({
User? user,
String? accessToken,
String? refreshToken,
bool? loading,
String? error,
bool clearError = false,
}) {
return AuthState(
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
loading: loading ?? this.loading,
error: clearError ? null : (error ?? this.error),
);
}
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
void setSession({
required User user,
required String accessToken,
required String refreshToken,
}) {
state = AuthState(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
loading: false,
);
AuthStorage.save(
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
);
}
Future<void> restoreSession() async {
final stored = await AuthStorage.load();
if (stored == null) return;
state = AuthState(
user: stored.user,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
loading: false,
);
}
void setLoading(bool loading) {
state = state.copyWith(loading: loading, clearError: true);
}
void setError(String message) {
state = state.copyWith(loading: false, error: message);
}
void logout() {
state = const AuthState();
AuthStorage.clear();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/scoring/match_scoring_rules.dart';
import 'session_provider.dart';
final matchScoringRulesProvider = Provider<MatchScoringRules>((ref) {
final match = ref.watch(sessionProvider).match;
if (match == null) {
return const MatchScoringRules(setsToWin: 3);
}
return MatchScoringRules.fromMatch(match);
});

Some files were not shown because too many files have changed in this diff Show More