Completa app Android nativa e rimuove il client Flutter.

Il monorepo punta a native/android (login, wizard, diretta RTMP, tabellone WebSocket, pausa/telemetria) con encode 16:9 e orientamento via sensore; eliminati mobile/ e gli script Flutter obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 17:38:56 +02:00
parent 499aee8988
commit 68b4390282
262 changed files with 4860 additions and 15162 deletions

View File

@@ -2,13 +2,13 @@
Piattaforma mobile-first per streaming di partite sportive giovanili — MVP su **Match Live TV** (HLS sul nostro sito); YouTube/Facebook/Twitch come integrazioni premium.
**Slogan:** *Lo streaming che non muore*
**Slogan:** *Ogni partita, ogni evento, per i tuoi tifosi.*
## Struttura monorepo
```
├── backend/ # Rails 7.2 API + Action Cable + Sidekiq
├── mobile/ # Flutter app (Camera + Regia)
├── native/android/ # App Android nativa (Kotlin + Compose)
├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal
└── docs/ # Documentazione
```
@@ -45,15 +45,16 @@ email: coach@matchlivetv.test
password: password123
```
## Mobile
## App Android nativa
```bash
cd mobile
flutter pub get
flutter run
./scripts/build_native_android_apk_prod.sh
adb install -r native/android/app/build/outputs/apk/release/app-release.apk
```
Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:3000` per emulatore Android).
API produzione: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...` in Gradle).
Vedi [native/android/README.md](native/android/README.md).
## Architettura
@@ -62,9 +63,9 @@ Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:300
- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)
### Mobile produzione
### App Android produzione
```bash
./scripts/run_mobile_android_prod.sh
./scripts/build_native_android_apk_prod.sh
# API: https://www.matchlivetv.it
```

View File

@@ -156,7 +156,7 @@ Dalla macchina di sviluppo:
```bash
# Tar + scp (rsync non installato sul server)
tar -C /path/to/MatchLiveTV/src -czf /tmp/matchlivetv-deploy.tar.gz \
--exclude='./mobile/build' --exclude='./backend/log' --exclude='./.git' .
--exclude='./native/android/build' --exclude='./backend/log' --exclude='./.git' .
scp /tmp/matchlivetv-deploy.tar.gz eminux@192.168.1.146:~/
ssh eminux@192.168.1.146 'tar -xzf ~/matchlivetv-deploy.tar.gz -C ~/matchlivetv'

45
mobile/.gitignore vendored
View File

@@ -1,45 +0,0 @@
# 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

View File

@@ -1,45 +0,0 @@
# 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'

View File

@@ -1,17 +0,0 @@
# 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

@@ -1,28 +0,0 @@
# 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

View File

@@ -1,14 +0,0 @@
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

@@ -1,59 +0,0 @@
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")
}

View File

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

View File

@@ -1,7 +0,0 @@
<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

@@ -1,66 +0,0 @@
<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="@string/app_name"
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>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="www.matchlivetv.it" android:pathPrefix="/join"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="matchlivetv"/>
</intent-filter>
</activity>
<service
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

@@ -1,27 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.res.Configuration
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
StreamingEngineHolder.engine?.pauseGlDuringRotation()
super.onConfigurationChanged(newConfig)
}
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

@@ -1,9 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,243 +0,0 @@
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

@@ -1,30 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceHolder
import com.pedro.library.view.OpenGlView
/**
* OpenGlView che non distrugge encoder/GL quando la surface viene persa in rotazione.
* La SurfaceView standard di Pedro chiama stop() in surfaceDestroyed e uccide tutto il pipeline.
*/
class StreamingOpenGlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : OpenGlView(context, attrs) {
@Volatile
var preservePipelineOnSurfaceLoss: Boolean = false
var onPreviewSurfaceLost: (() -> Unit)? = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (preservePipelineOnSurfaceLoss && isRunning) {
setForceRender(false)
onPreviewSurfaceLost?.invoke()
return
}
super.surfaceDestroyed(holder)
}
}

View File

@@ -1,365 +0,0 @@
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 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(
onSurfaceReady = { glView ->
android.os.Handler(android.os.Looper.getMainLooper()).post {
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.onPlatformSurfaceReady(glView)
}
},
onSurfaceDestroyed = {
android.os.Handler(android.os.Looper.getMainLooper()).post {
getEngine()?.onPlatformSurfaceDestroyed()
}
},
),
)
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
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView as? StreamingOpenGlView ?: return
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.bindStreamPreview(glView)
}
private fun previewIsReady(): Boolean {
val engine = getEngine() ?: return false
val metrics = engine.getMetrics()
return metrics.isPreviewActive
}
override fun onDetachedFromActivity() {
activityBinding = null
unbindServiceIfNeeded()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startStream" -> startStream(call, result)
"resumeStream" -> resumeStream(call, result)
"stopStream" -> stopStream(result)
"pauseStream" -> pauseStream(result)
"getMetrics" -> getMetrics(result)
"startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result)
"syncOrientation" -> syncOrientation(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 argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 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,
portrait = portrait,
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 resumeStream(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 argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 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,
portrait = portrait,
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.resumeStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("resume_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 pauseStream(result: Result) {
getEngine()?.pauseStream()
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(previewIsReady())
}
private fun syncOrientation(result: Result) {
getEngine()?.applyOrientationFromSensor()
result.success(true)
}
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 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

@@ -1,95 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.view.SurfaceHolder
import android.view.View
import android.widget.FrameLayout
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView(
context: Context,
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformView {
private val openGlView = StreamingOpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
private var lastNotifiedWidth = 0
private var lastNotifiedHeight = 0
private val notifyReadyRunnable = Runnable {
if (openGlView.width <= 0 || openGlView.height <= 0) {
return@Runnable
}
val sizeChanged = openGlView.width != lastNotifiedWidth ||
openGlView.height != lastNotifiedHeight
if (!sizeChanged && lastNotifiedWidth > 0) {
return@Runnable
}
lastNotifiedWidth = openGlView.width
lastNotifiedHeight = openGlView.height
onSurfaceReady(openGlView)
}
init {
StreamPreviewHolder.openGlView = openGlView
openGlView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
StreamPreviewHolder.openGlView = openGlView
scheduleReadyDebounced()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
StreamPreviewHolder.openGlView = openGlView
if (width > 0 && height > 0) {
scheduleReadyDebounced()
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
}
})
}
private fun scheduleReadyDebounced() {
openGlView.removeCallbacks(notifyReadyRunnable)
openGlView.postDelayed(notifyReadyRunnable, SURFACE_NOTIFY_DEBOUNCE_MS)
}
override fun getView(): View = openGlView
override fun dispose() {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
if (StreamPreviewHolder.openGlView === openGlView) {
StreamPreviewHolder.openGlView = null
}
}
companion object {
private const val SURFACE_NOTIFY_DEBOUNCE_MS = 600L
}
}
class StreamingPreviewFactory(
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onSurfaceReady, onSurfaceDestroyed)
}
}

View File

@@ -1,12 +0,0 @@
<?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

@@ -1,12 +0,0 @@
<?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>

View File

@@ -1,18 +0,0 @@
<?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

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<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

@@ -1,18 +0,0 @@
<?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

@@ -1,9 +0,0 @@
<?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

@@ -1,7 +0,0 @@
<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

@@ -1,25 +0,0 @@
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

@@ -1,6 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,35 +0,0 @@
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
View File

@@ -1,34 +0,0 @@
**/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

@@ -1,24 +0,0 @@
<?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

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

View File

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

View File

@@ -1,644 +0,0 @@
// !$*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

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

View File

@@ -1,8 +0,0 @@
<?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

@@ -1,8 +0,0 @@
<?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

@@ -1,119 +0,0 @@
<?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

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

View File

@@ -1,8 +0,0 @@
<?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

@@ -1,8 +0,0 @@
<?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

@@ -1,16 +0,0 @@
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

@@ -1,122 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,23 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

View File

@@ -1,5 +0,0 @@
# 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

@@ -1,37 +0,0 @@
<?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

@@ -1,26 +0,0 @@
<?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

@@ -1,70 +0,0 @@
<?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

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

View File

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

View File

@@ -1,12 +0,0 @@
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

@@ -1,84 +0,0 @@
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/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) => LoginScreen(
inviteToken: state.uri.queryParameters['invite'],
),
),
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);
},
),
],
);
});

View File

@@ -1,93 +0,0 @@
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

@@ -1,64 +0,0 @@
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

@@ -1,24 +0,0 @@
/// 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

@@ -1,77 +0,0 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/matches/team_providers.dart';
import 'invite_storage.dart';
/// Gestisce link invito (join) e ritorno OAuth YouTube dall'app.
class DeepLinkListener extends ConsumerStatefulWidget {
const DeepLinkListener({super.key, required this.child});
final Widget child;
@override
ConsumerState<DeepLinkListener> createState() => _DeepLinkListenerState();
}
class _DeepLinkListenerState extends ConsumerState<DeepLinkListener> {
final _appLinks = AppLinks();
StreamSubscription<Uri>? _sub;
@override
void initState() {
super.initState();
_handleInitialLink();
_sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {});
}
Future<void> _handleInitialLink() async {
final uri = await _appLinks.getInitialLink();
if (uri != null) await _onUri(uri);
}
Future<void> _onUri(Uri uri) async {
final inviteToken = _extractInviteToken(uri);
if (inviteToken != null) {
await InviteStorage.saveToken(inviteToken);
if (!mounted) return;
context.go('/login?invite=$inviteToken');
return;
}
if (uri.scheme == 'matchlivetv' && uri.host == 'youtube-connected') {
ref.invalidate(teamsProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Canale YouTube collegato. Puoi selezionare YouTube Live.'),
duration: Duration(seconds: 4),
),
);
}
}
String? _extractInviteToken(Uri uri) {
if (uri.scheme == 'matchlivetv' && uri.host == 'join') {
final segment = uri.pathSegments.isNotEmpty ? uri.pathSegments.first : uri.path.replaceFirst('/', '');
return segment.isEmpty ? null : segment;
}
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'join') {
return uri.pathSegments[1];
}
return null;
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -1,20 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
class InviteStorage {
static const _keyToken = 'pending_invite_token';
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyToken, token);
}
static Future<String?> readToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyToken);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyToken);
}
}

View File

@@ -1,22 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
class TeamSelectionStorage {
static const _keySelectedTeamId = 'selected_team_id';
static Future<String?> load() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getString(_keySelectedTeamId);
if (id == null || id.isEmpty) return null;
return id;
}
static Future<void> save(String teamId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySelectedTeamId, teamId);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keySelectedTeamId);
}
}

View File

@@ -1,216 +0,0 @@
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/api_client.dart';
import '../../shared/models/recording_item.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(activeTeamProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Replay'),
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(
'Replay disponibili con Premium Light o Full',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Light: 30 giorni · Full: 90 giorni',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
context,
message: 'Salva e rivedi le gare 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: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.',
textAlign: TextAlign.center,
),
),
);
}
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];
final when = r.recordedAt ?? r.endedAt;
final subtitle = [
if (when != null) df.format(when.toLocal()),
if (r.durationLabel != null) r.durationLabel,
if (r.viewsLabel != null) r.viewsLabel,
r.statusLabel,
if (r.expiresAt != null)
'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}',
].whereType<String>().join(' · ');
return Card(
color: AppTheme.surface,
child: ListTile(
leading: r.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
r.thumbnailUrl!,
width: 72,
height: 40,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.movie),
),
)
: const Icon(Icons.movie_outlined, size: 40),
title: Text(r.title),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: r.isProcessing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: PopupMenuButton<String>(
onSelected: (action) => _onMenuAction(
context,
ref,
action,
r,
team.canDownloadOnPhone,
),
itemBuilder: (_) => [
if (r.isReady)
const PopupMenuItem(
value: 'play',
child: Text('Guarda replay'),
),
if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone)
const PopupMenuItem(
value: 'download',
child: Text('Scarica MP4'),
),
if (r.youtubeWatchUrl != null)
const PopupMenuItem(
value: 'youtube',
child: Text('Apri su YouTube'),
),
],
),
onTap: r.isReady ? () => _openReplay(r.replayUrl) : null,
),
);
},
);
},
);
},
),
);
}
Future<void> _openReplay(String? url) async {
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
Future<void> _onMenuAction(
BuildContext context,
WidgetRef ref,
String action,
RecordingItem r,
bool canDownload,
) async {
switch (action) {
case 'play':
await _openReplay(r.replayUrl);
break;
case 'download':
if (!canDownload) return;
try {
final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id);
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download: $e')),
);
}
}
break;
case 'youtube':
final yt = r.youtubeWatchUrl;
if (yt == null) return;
final uri = Uri.parse(yt);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
break;
}
}
}

View File

@@ -1,267 +0,0 @@
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 '../../core/invite_storage.dart';
import '../../features/matches/team_providers.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/invite_preview.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key, this.inviteToken});
final String? inviteToken;
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController(text: 'password123');
bool _obscure = true;
InvitePreview? _invite;
String? _inviteToken;
bool _loadingInvite = false;
@override
void initState() {
super.initState();
_bootstrapInvite();
}
Future<void> _bootstrapInvite() async {
_inviteToken = widget.inviteToken ?? await InviteStorage.readToken();
if (_inviteToken == null) return;
setState(() => _loadingInvite = true);
try {
final preview = await ApiClient().fetchInvitePreview(_inviteToken!);
if (!mounted) return;
setState(() {
_invite = preview;
if (preview.email.isNotEmpty) {
_emailController.text = preview.email;
}
});
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invito non valido o scaduto')),
);
}
await InviteStorage.clear();
} finally {
if (mounted) setState(() => _loadingInvite = false);
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _acceptInviteIfNeeded(ApiClient client) async {
final token = _inviteToken;
if (token == null) return;
try {
final message = await client.acceptInvitation(token);
await InviteStorage.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
} on DioException catch (e) {
final err = e.response?.data;
final msg = err is Map ? (err['error'] as String?) : null;
if (mounted && msg != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
rethrow;
}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
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,
);
final authed = ApiClient(accessToken: result.tokens.accessToken);
if (_inviteToken != null) {
await _acceptInviteIfNeeded(authed);
}
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
ref.read(authProvider.notifier).setLoading(false);
if (mounted) context.go('/matches');
return;
} on DioException catch (e) {
if (e.response?.statusCode == 422) {
ref.read(authProvider.notifier).setLoading(false);
return;
}
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
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),
if (_loadingInvite)
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
),
if (_invite != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.35)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Invito staff',
style: theme.textTheme.titleMedium?.copyWith(color: AppTheme.primaryRed),
),
const SizedBox(height: 6),
Text(
'Entra in ${_invite!.teamName} come responsabile trasmissione.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
'Accedi con ${_invite!.email}',
style: theme.textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 20),
],
Text(
'ACCEDI',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_invite != null
? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta'
: 'Gestisci le dirette della tua squadra',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
readOnly: _invite != null,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'coach@squadra.it',
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
),
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: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
],
),
),
),
);
}
}

View File

@@ -1,68 +0,0 @@
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 {
final notifier = ref.read(authProvider.notifier);
await notifier.restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
var auth = ref.read(authProvider);
if (auth.isAuthenticated) {
final ok = await notifier.validateOrRefreshSession();
if (!mounted) return;
auth = ref.read(authProvider);
if (!ok || !auth.isAuthenticated) {
context.go('/login');
return;
}
}
if (!mounted) return;
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

@@ -1,923 +0,0 @@
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 '../../providers/score_sync_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/regia_share.dart';
import '../../shared/watch_share.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/live_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? _sessionSyncTimer;
Timer? _telemetryTimer;
Timer? _statsTimer;
Timer? _scorePullTimer;
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;
bool _pauseInFlight = false;
bool _resumeInFlight = false;
String? _lastStreamErrorShown;
@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!),
);
} else {
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
}
});
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
unawaited(_syncSessionStartedAt());
});
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
_scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(ref.read(scoreSyncProvider).pullFromServer());
});
}
Map<String, dynamic> _streamVideoParams(BuildContext context) {
final portrait = MediaQuery.orientationOf(context) == Orientation.portrait;
return {
'width': portrait ? 720 : 1280,
'height': portrait ? 1280 : 720,
'portrait': portrait,
};
}
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<bool> _bindPreviewWithRetry({int attempts = 12}) async {
for (var i = 0; i < attempts; i++) {
final ok = await StreamingChannel.bindPreview();
if (ok) return true;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
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);
final statusAtOpen = session.status;
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// idle → startSession; wizard può aver già messo connecting prima della camera.
var justStartedSession = false;
if (session.status == 'idle') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
justStartedSession = true;
}
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300));
final previewReady = await _bindPreviewWithRetry();
if (!previewReady && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anteprima camera non pronta — video non disponibile. Riapri per riprovare.'),
duration: Duration(seconds: 4),
),
);
// Non return: sessione e score sono già settati, WebSocket connesso.
// L'utente può ancora gestire il punteggio anche senza preview video.
}
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.score != null) {
ref.read(scoreProvider.notifier).reset(fresh.score!);
}
if (!mounted) return;
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
final wasPausedAtOpen = statusAtOpen == 'paused';
final shouldAutoPublish = previewReady &&
fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen &&
fresh.status != 'paused' &&
(justStartedSession ||
fresh.status == 'connecting' ||
fresh.status == 'live' ||
fresh.status == 'reconnecting');
if (shouldAutoPublish) {
// Reset motore nativo (sessione precedente / RECONNECTING blocca startStream).
try {
await StreamingChannel.stopStream();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta avviata — connessione al server…'),
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)) ?? {},
);
final streamState = metrics['state'] as String?;
final lastError = metrics['lastError'] as String?;
setState(() {
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
});
if (lastError != null &&
lastError.isNotEmpty &&
lastError != _lastStreamErrorShown &&
(streamState == 'ERROR' || streamState == 'RECONNECTING') &&
mounted) {
_lastStreamErrorShown = lastError;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connessione RTMP: $lastError'),
duration: const Duration(seconds: 5),
),
);
}
});
} 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 (_) {}
}
/// Il server imposta `started_at` solo al primo go_live; sincronizza per il timer LIVE.
Future<void> _syncSessionStartedAt() async {
final current = ref.read(sessionProvider).session;
if (current == null || current.startedAt != null || !current.isLive) return;
try {
final fresh = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
if (!mounted || fresh.startedAt == null) return;
ref.read(sessionProvider.notifier).setSession(
fresh,
match: ref.read(sessionProvider).match,
);
} catch (_) {}
}
Future<void> _lockOrientations() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
Future<void> _shareRegia() async {
final match = ref.read(sessionProvider).match;
await shareRegiaLink(
context,
ref,
sessionId: widget.sessionId,
shareSubject:
'Regia — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}',
);
}
Future<void> _shareWatch() async {
final session = ref.read(sessionProvider).session;
final match = ref.read(sessionProvider).match;
if (session == null) return;
await shareWatchLink(
context,
session: session,
title:
'Diretta — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}',
);
}
void _showShareMenu() {
final session = ref.read(sessionProvider).session;
if (session == null) return;
final isYoutube = session.platform == 'youtube';
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.live_tv),
title: Text(isYoutube ? 'Condividi link YouTube' : 'Condividi link diretta'),
onTap: () {
Navigator.pop(ctx);
_shareWatch();
},
),
ListTile(
leading: const Icon(Icons.scoreboard),
title: const Text('Condividi link regia (punteggio)'),
onTap: () {
Navigator.pop(ctx);
_shareRegia();
},
),
],
),
),
);
}
Future<void> _leaveSession() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(websocketServiceProvider).disconnect();
} catch (_) {}
if (mounted) context.go('/matches');
}
/// Pausa temporanea: il server manda la copertina, la camera resta aperta.
Future<void> _pauseStream() async {
if (_pauseInFlight) return;
_pauseInFlight = true;
try {
final match = ref.read(sessionProvider).match;
final session =
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta in pausa — gli spettatori vedono la copertina'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa sessione: $e')),
);
}
} finally {
_pauseInFlight = false;
}
}
Future<void> _applyRemotePause() async {
try {
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pausa dalla regia — trasmissione fermata'),
duration: Duration(seconds: 3),
),
);
}
} catch (_) {}
}
Future<void> _applyRemoteResume() async {
if (_resumeInFlight) return;
final session = ref.read(sessionProvider).session;
if (session == null || session.rtmpIngestUrl == null) return;
_resumeInFlight = true;
try {
final isPortrait = mounted &&
MediaQuery.orientationOf(context) == Orientation.portrait;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ripresa dalla regia — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa RTMP: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
Future<void> _resumeStream() async {
if (_resumeInFlight) return;
_resumeInFlight = true;
try {
final match = ref.read(sessionProvider).match;
var session =
await ref.read(apiClientProvider).resumeSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) {
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
}
session = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa diretta: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
/// 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();
_sessionSyncTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_scorePullTimer?.cancel();
_metricsSub?.cancel();
unawaited(StreamingChannel.stopStream());
WakelockPlus.disable();
_unlockOrientation();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(
sessionProvider.select((s) => s.session?.status),
(previous, next) {
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
_applyRemotePause();
}
if (previous == 'paused' &&
(next == 'connecting' || next == 'live') &&
!_resumeInFlight) {
_applyRemoteResume();
}
if (next == 'ended' && previous != 'ended' && mounted) {
_leaveSession();
}
},
);
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
_trackScoreDelta(score);
final match = sessionState.match;
final sessionStatus = sessionState.session?.status ?? 'live';
final isPaused = sessionStatus == 'paused';
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 a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: NativeStreamingPreview(
key: _previewKey,
showGrid: true,
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
),
),
if (isPaused)
Positioned(
left: 20,
right: 20,
bottom: isLandscape ? 100 : 200,
child: Center(
child: FilledButton.icon(
onPressed: _resumeStream,
icon: const Icon(Icons.play_arrow, size: 28),
label: const Text(
'RIPRENDI DIRETTA',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
),
),
),
),
if (isLandscape)
_LandscapeOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
lastDelta: _lastDelta,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onShare: _showShareMenu,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
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,
onShare: _showShareMenu,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
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.onShare,
required this.isPaused,
required this.onPause,
required this.onResume,
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 onShare;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
final screenH = MediaQuery.sizeOf(context).height;
// Riserva almeno 35% dello schermo all'anteprima camera, il resto ai controlli.
final maxControlsH = screenH * 0.65;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Barra superiore: badge live + metriche ──────────────────────────
Container(
color: Colors.black54,
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, paused: isPaused),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, size: 20),
tooltip: 'Condividi link',
color: Colors.white70,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
// ── Area camera (AndroidView a tutto schermo sotto) ───────────────
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
// ── Pannello controlli ───────────────────────────────────────────────
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxControlsH),
child: Container(
color: AppTheme.background,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Tabellone + pulsanti punteggio — primo widget, sempre visibile.
LiveScoreControls(
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 12),
// Azioni stream: pausa/riprendi + chiudi.
Row(
children: [
Expanded(
child: isPaused
? FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
)
: OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Pausa'),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
),
],
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onShare,
icon: const Icon(Icons.share, size: 16),
label: const Text('Condividi link diretta'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Colors.white24),
),
),
],
),
),
),
),
],
);
}
}
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.onShare,
required this.isPaused,
required this.onPause,
required this.onResume,
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 onShare;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
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, paused: isPaused),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
light: true,
),
],
),
),
const Expanded(child: IgnorePointer(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: [
LiveScoreControls(
compact: true,
homeName: homeName,
awayName: awayName,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, color: Colors.white),
tooltip: 'Condividi link',
),
Expanded(
child: isPaused
? FilledButton(
onPressed: onResume,
child: const Text('Riprendi', style: TextStyle(fontSize: 11)),
)
: OutlinedButton(
onPressed: onPause,
child: const Text('Pausa', style: TextStyle(fontSize: 11)),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi',
compact: true,
onPressed: () => onCloseStream(),
),
),
],
),
],
),
),
],
);
}
}

View File

@@ -1,134 +0,0 @@
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

@@ -1,158 +0,0 @@
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

@@ -1,596 +0,0 @@
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 '../../core/config.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/screen_insets.dart';
import 'resume_session_sheet.dart';
import 'no_team_onboarding.dart';
import 'new_match_sheet.dart';
import 'schedule_match_sheet.dart';
import 'select_match_sheet.dart';
import 'team_picker.dart';
import 'team_providers.dart';
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(activeTeamProvider.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, stack) {
final msg = e.toString();
final unauthorized = msg.contains('401');
final timeout = msg.contains('timeout') || msg.contains('Timeout');
final offline = timeout ||
msg.contains('SocketException') ||
msg.contains('Failed host lookup');
String title = 'Errore nel caricamento squadre';
String hint = 'Controlla WiFi o dati mobili e riprova.';
if (unauthorized) {
title = 'Sessione scaduta';
hint = 'Accedi di nuovo con email e password.';
} else if (offline) {
title = 'Server non raggiungibile';
hint = timeout
? 'La connessione è troppo lenta o assente. Verifica la rete (serve almeno qualche Mbps).'
: 'Impossibile contattare ${AppConfig.apiBaseUrl}. Riprova tra poco.';
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
hint,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (unauthorized)
FilledButton(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
child: const Text('VAI AL LOGIN'),
)
else
TextButton(
onPressed: () => ref.invalidate(teamsProvider),
child: const Text('RIPROVA'),
),
],
),
),
);
},
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(teamsProvider);
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(
'Scegli una partita programmata o creane una nuova.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _pickScheduledMatch(context, ref, matches),
icon: const Icon(Icons.playlist_play),
label: const Text('Partita\nprogrammata'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () => _newMatch(context, ref),
icon: const Icon(Icons.add),
label: const Text('Nuova\npartita'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
],
),
const TeamPickerBar(),
if (activeMatch != null) ...[
const SizedBox(height: 12),
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,
),
],
),
),
),
),
],
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Text(
matches.isEmpty ? 'Calendario vuoto' : 'Calendario',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
),
),
if (matches.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
'Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final match = matches[index];
return _MatchCard(
match: match,
dateFormat: dateFormat,
onTap: () => _openMatch(context, ref, match),
onDelete: match.canResumeCamera
? null
: () => _deleteMatch(context, ref, match),
);
},
childCount: matches.length,
),
),
SliverToBoxAdapter(
child: SizedBox(height: 20 + bottomInset),
),
],
),
);
},
);
},
),
);
}
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')),
);
}
}
}
void _openMatch(BuildContext context, WidgetRef ref, MatchModel match) {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
}
Future<void> _pickScheduledMatch(
BuildContext context,
WidgetRef ref,
List<MatchModel> matches,
) async {
final selectable = matches.where((m) => !m.hasActiveSession).toList();
if (selectable.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nessuna partita disponibile. Crea una nuova partita.'),
),
);
}
return;
}
final picked = await showSelectMatchSheet(context, matches: matches);
if (picked != null && context.mounted) {
context.push('/setup/${picked.id}/1');
}
}
Future<void> _newMatch(BuildContext context, WidgetRef ref) async {
final choice = await showNewMatchSheet(context);
if (choice == null || !context.mounted) return;
switch (choice) {
case NewMatchChoice.schedule:
await _scheduleMatch(context, ref);
case NewMatchChoice.quickStart:
await _createMatchQuick(context, ref);
}
}
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
);
}
return;
}
final data = await showScheduleMatchSheet(context, teamName: team.name);
if (data == null || !context.mounted) return;
try {
final client = ref.read(apiClientProvider);
final match = await client.createMatch(team.id, {
'opponent_name': data.opponentName,
'scheduled_at': data.scheduledAt.toUtc().toIso8601String(),
if (data.location != null) 'location': data.location,
'sport': 'volleyball',
'sets_to_win': 3,
});
ref.invalidate(matchesProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partita programmata — visibile sul sito')),
);
final configure = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Configurare ora?'),
content: const Text(
'Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Più tardi'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Configura'),
),
],
),
);
if (configure == true && context.mounted) {
context.push('/setup/${match.id}/1');
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore: $e')),
);
}
}
}
Future<void> _createMatchQuick(BuildContext context, WidgetRef ref) async {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
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(team.id, {
'opponent_name': 'Avversario',
'sport': 'volleyball',
'sets_to_win': 3,
});
if (context.mounted) context.push('/setup/${match.id}/1');
}
}
String _matchStatusLabel(
MatchModel match, {
required bool canResume,
required bool hasSession,
}) {
if (canResume) return 'RIPRENDI';
if (hasSession) return 'IN CORSO';
final at = match.scheduledAt;
if (at != null && at.isAfter(DateTime.now())) return 'PROGRAMMATA';
return 'AVVIA';
}
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(
_matchStatusLabel(match, canResume: canResume, hasSession: hasSession),
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

@@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
enum NewMatchChoice { schedule, quickStart }
Future<NewMatchChoice?> showNewMatchSheet(BuildContext context) {
return showModalBottomSheet<NewMatchChoice>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final theme = Theme.of(ctx);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Nuova partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 6),
Text(
'Programma in anticipo o avvia la configurazione diretta subito.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 20),
_OptionTile(
icon: Icons.event_available,
title: 'Programma partita',
subtitle: 'Data, ora e avversario — visibile anche sul sito',
onTap: () => Navigator.pop(ctx, NewMatchChoice.schedule),
),
const SizedBox(height: 10),
_OptionTile(
icon: Icons.play_circle_outline,
title: 'Avvia subito',
subtitle: 'Crea la partita e passa al wizard senza orario',
onTap: () => Navigator.pop(ctx, NewMatchChoice.quickStart),
),
],
),
);
},
);
}
class _OptionTile extends StatelessWidget {
const _OptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: AppTheme.primaryRed, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodySmall),
],
),
),
const Icon(Icons.chevron_right, color: AppTheme.textSecondary),
],
),
),
),
);
}
}

View File

@@ -1,56 +0,0 @@
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

@@ -1,100 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../shared/models/match.dart';
import '../../shared/regia_share.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: () {
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: () {
shareRegiaLink(
ctx,
ref,
sessionId: sessionId,
shareSubject:
'Regia — ${match.teamName} vs ${match.opponentName}',
);
},
icon: const Icon(Icons.share),
label: const Text('Condividi link regia'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annulla'),
),
],
),
),
);
},
);
}

View File

@@ -1,176 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/theme.dart';
import '../../shared/widgets/primary_cta.dart';
class ScheduleMatchResult {
ScheduleMatchResult({
required this.opponentName,
required this.scheduledAt,
this.location,
});
final String opponentName;
final DateTime scheduledAt;
final String? location;
}
Future<ScheduleMatchResult?> showScheduleMatchSheet(
BuildContext context, {
String? teamName,
}) {
return showModalBottomSheet<ScheduleMatchResult>(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => _ScheduleMatchSheet(teamName: teamName),
);
}
class _ScheduleMatchSheet extends StatefulWidget {
const _ScheduleMatchSheet({this.teamName});
final String? teamName;
@override
State<_ScheduleMatchSheet> createState() => _ScheduleMatchSheetState();
}
class _ScheduleMatchSheetState extends State<_ScheduleMatchSheet> {
final _opponentController = TextEditingController();
final _locationController = TextEditingController();
late DateTime _scheduledAt;
@override
void initState() {
super.initState();
final now = DateTime.now();
_scheduledAt = DateTime(now.year, now.month, now.day, now.hour + 2, 0);
if (_scheduledAt.isBefore(now)) {
_scheduledAt = now.add(const Duration(hours: 2));
}
}
@override
void dispose() {
_opponentController.dispose();
_locationController.dispose();
super.dispose();
}
Future<void> _pickDateTime() async {
final date = await showDatePicker(
context: context,
initialDate: _scheduledAt,
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),
);
if (time == null) return;
setState(() {
_scheduledAt = DateTime(date.year, date.month, date.day, time.hour, time.minute);
});
}
void _submit() {
final opponent = _opponentController.text.trim();
if (opponent.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inserisci il nome dellavversario')),
);
return;
}
Navigator.pop(
context,
ScheduleMatchResult(
opponentName: opponent,
scheduledAt: _scheduledAt,
location: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateLabel = DateFormat('EEE d MMM yyyy · HH:mm', 'it_IT')
.format(_scheduledAt.toLocal());
final bottom = MediaQuery.paddingOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Programma partita', style: theme.textTheme.titleLarge),
if (widget.teamName != null && widget.teamName!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.teamName!,
style: theme.textTheme.titleSmall?.copyWith(
color: AppTheme.primaryRed,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 6),
Text(
'La diretta non parte ora: comparirà sul sito con data e ora. '
'Avvierai lo streaming dallapp quando sei in palestra.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 20),
TextField(
controller: _opponentController,
decoration: const InputDecoration(labelText: 'Avversario'),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
TextField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Luogo (opzionale)'),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Data e ora', style: theme.textTheme.bodyMedium),
subtitle: Text(dateLabel, style: theme.textTheme.titleMedium),
trailing: const Icon(Icons.calendar_today, color: AppTheme.primaryRed),
onTap: _pickDateTime,
),
const SizedBox(height: 20),
PrimaryCta(
label: 'SALVA IN PROGRAMMA',
icon: Icons.event_available,
onPressed: _submit,
),
],
),
);
}
}

View File

@@ -1,185 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/theme.dart';
import '../../shared/models/match.dart';
/// Scegli una partita già in calendario per avviare la diretta.
Future<MatchModel?> showSelectMatchSheet(
BuildContext context, {
required List<MatchModel> matches,
}) {
final selectable = matches.where((m) => !m.hasActiveSession).toList();
if (selectable.isEmpty) return Future.value(null);
return showModalBottomSheet<MatchModel>(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => _SelectMatchSheet(matches: selectable),
);
}
class _SelectMatchSheet extends StatelessWidget {
const _SelectMatchSheet({required this.matches});
final List<MatchModel> matches;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT');
final now = DateTime.now();
final scheduled = matches
.where((m) => m.scheduledAt != null)
.toList()
..sort((a, b) => a.scheduledAt!.compareTo(b.scheduledAt!));
final unscheduled = matches.where((m) => m.scheduledAt == null).toList();
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.55,
minChildSize: 0.35,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Scegli partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 6),
Text(
'Partite già programmate sul sito o in app.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Expanded(
child: ListView(
controller: scrollController,
children: [
if (scheduled.isNotEmpty) ...[
Text('Programmate', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
...scheduled.map(
(m) => _MatchTile(
match: m,
dateFormat: dateFormat,
now: now,
onTap: () => Navigator.pop(context, m),
),
),
const SizedBox(height: 16),
],
if (unscheduled.isNotEmpty) ...[
Text('Senza orario', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
...unscheduled.map(
(m) => _MatchTile(
match: m,
dateFormat: dateFormat,
now: now,
onTap: () => Navigator.pop(context, m),
),
),
],
],
),
),
],
),
);
},
);
}
}
class _MatchTile extends StatelessWidget {
const _MatchTile({
required this.match,
required this.dateFormat,
required this.now,
required this.onTap,
});
final MatchModel match;
final DateFormat dateFormat;
final DateTime now;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final at = match.scheduledAt;
final isFuture = at != null && at.isAfter(now);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${match.teamName} vs ${match.opponentName}',
style: theme.textTheme.titleSmall,
),
if (at != null)
Text(
dateFormat.format(at.toLocal()),
style: theme.textTheme.bodySmall,
),
if (match.location != null && match.location!.isNotEmpty)
Text(match.location!, style: theme.textTheme.bodySmall),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFuture
? AppTheme.primaryRed.withValues(alpha: 0.15)
: AppTheme.textSecondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
at != null ? (isFuture ? 'PROGRAMMATA' : 'IN CALENDARIO') : 'BOZZA',
style: theme.textTheme.labelSmall?.copyWith(
color: isFuture ? AppTheme.primaryRed : AppTheme.textSecondary,
fontSize: 10,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/theme.dart';
import '../../shared/models/team.dart';
import 'team_providers.dart';
/// Selettore squadra quando l'utente ne gestisce più di una (staff trasmissione / società).
class TeamPickerBar extends ConsumerWidget {
const TeamPickerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamsAsync = ref.watch(teamsProvider);
final activeAsync = ref.watch(activeTeamProvider);
return teamsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (teams) {
if (teams.length <= 1) return const SizedBox.shrink();
final active = activeAsync.valueOrNull;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => _showTeamSheet(context, ref, teams, active?.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
const Icon(Icons.groups_outlined, color: AppTheme.primaryRed, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Squadra per la diretta',
style: theme.textTheme.labelMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
Text(
active?.name ?? 'Seleziona squadra',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (active?.clubName != null && active!.clubName!.isNotEmpty)
Text(
active.clubName!,
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(Icons.expand_more, color: AppTheme.textSecondary),
],
),
),
),
),
);
},
);
}
Future<void> _showTeamSheet(
BuildContext context,
WidgetRef ref,
List<Team> teams,
String? selectedId,
) async {
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
'Scegli squadra',
style: Theme.of(ctx).textTheme.titleLarge,
),
),
...teams.map((team) {
final selected = team.id == selectedId;
return ListTile(
leading: Icon(
selected ? Icons.check_circle : Icons.circle_outlined,
color: selected ? AppTheme.primaryRed : AppTheme.textSecondary,
),
title: Text(team.name),
subtitle: team.clubName != null && team.clubName!.isNotEmpty
? Text(team.clubName!)
: null,
onTap: () => Navigator.pop(ctx, team.id),
);
}),
const SizedBox(height: 8),
],
),
);
},
);
if (picked == null) return;
await ref.read(selectedTeamIdProvider.notifier).select(picked);
ref.invalidate(matchesProvider);
}
}

View File

@@ -1,80 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/team_selection_storage.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/models/team.dart';
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) return [];
final team = await ref.watch(activeTeamProvider.future);
if (team == null) return [];
final client = ref.read(apiClientProvider);
final list = await client.fetchMatches(team.id);
list.sort((a, b) {
final sa = a.scheduledAt;
final sb = b.scheduledAt;
if (sa == null && sb == null) return 0;
if (sa == null) return 1;
if (sb == null) return -1;
return sa.compareTo(sb);
});
return list;
});
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 selectedTeamIdProvider =
AsyncNotifierProvider<SelectedTeamIdNotifier, String?>(SelectedTeamIdNotifier.new);
class SelectedTeamIdNotifier extends AsyncNotifier<String?> {
@override
Future<String?> build() => TeamSelectionStorage.load();
Future<void> select(String teamId) async {
await TeamSelectionStorage.save(teamId);
state = AsyncData(teamId);
}
}
/// Squadra attiva: preferisce la selezione salvata, altrimenti la prima disponibile.
final activeTeamProvider = FutureProvider<Team?>((ref) async {
final teams = await ref.watch(teamsProvider.future);
if (teams.isEmpty) return null;
var selectedId = ref.watch(selectedTeamIdProvider).valueOrNull;
final valid = selectedId != null && teams.any((t) => t.id == selectedId);
if (!valid) {
selectedId = teams.first.id;
await ref.read(selectedTeamIdProvider.notifier).select(selectedId);
}
return teams.firstWhere((t) => t.id == selectedId);
});
/// Compatibilità con codice esistente.
final primaryTeamProvider = activeTeamProvider;
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

@@ -1,284 +0,0 @@
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

@@ -1,425 +0,0 @@
import 'dart:async';
import 'dart:math';
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 '../../providers/score_provider.dart';
import '../../providers/score_sync_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/regia_share.dart';
import '../../shared/watch_share.dart';
import '../../providers/match_rules_provider.dart';
import '../../shared/widgets/live_score_controls.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 _testCompleted = false;
bool _ready = false;
double _downloadMbps = 0;
double _uploadMbps = 0;
int _latencyMs = 0;
String _networkType = '';
bool _starting = false;
Timer? _youtubePoll;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final session = ref.read(sessionProvider).session;
if (session?.score != null) {
ref.read(scoreProvider.notifier).reset(session!.score!);
}
_startYoutubeLinkPoll();
});
}
@override
void dispose() {
_youtubePoll?.cancel();
super.dispose();
}
void _startYoutubeLinkPoll() {
final session = ref.read(sessionProvider).session;
if (session == null || session.platform != 'youtube') return;
if (session.youtubeReady || (session.youtubeWatchUrl?.isNotEmpty ?? false)) {
return;
}
_youtubePoll?.cancel();
var attempts = 0;
_youtubePoll = Timer.periodic(const Duration(seconds: 3), (_) async {
attempts++;
if (!mounted || attempts > 60) {
_youtubePoll?.cancel();
return;
}
final current = ref.read(sessionProvider).session;
if (current == null) return;
try {
final updated =
await ref.read(apiClientProvider).fetchSession(current.id);
ref
.read(sessionProvider.notifier)
.setSession(updated, match: ref.read(sessionProvider).match);
if (updated.youtubeReady ||
(updated.youtubeWatchUrl?.isNotEmpty ?? false)) {
_youtubePoll?.cancel();
if (mounted) setState(() {});
}
} catch (_) {}
});
}
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;
_testCompleted = true;
_downloadMbps = download;
_uploadMbps = upload;
_latencyMs = latency;
_networkType = networkLabel;
_ready = testResult?.ready ?? upload >= 2.0;
});
await _ensureScoreboardLink();
}
}
Future<void> _ensureScoreboardLink() async {
final session = ref.read(sessionProvider).session;
if (session == null) return;
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// Tabellone via HTTP; WebSocket solo dalla camera (evita doppio connect controller/camera).
try {
await ref.read(scoreSyncProvider).pullFromServer();
} catch (_) {}
}
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());
}
if (!mounted) return;
await ref.read(websocketServiceProvider).disconnect();
if (!mounted) return;
context.go('/session/${started.id}/camera');
} on DioException catch (e) {
if (mounted) {
final msg = e.response?.data is Map
? (e.response!.data['error'] ?? e.response!.data['message'])?.toString()
: null;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
msg?.isNotEmpty == true ? 'Errore avvio diretta: $msg' : 'Errore avvio diretta: ${e.message}',
),
),
);
}
} 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);
final session = ref.watch(sessionProvider).session;
final shareUrl = session != null ? watchShareUrl(session) : null;
final shareTitle = 'Diretta — ${widget.match.teamName} vs ${widget.match.opponentName}';
final score = ref.watch(scoreProvider);
final pointsTarget = ref.watch(matchScoringRulesProvider).pointsTarget(score.currentSet);
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 (_testCompleted && _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,
),
),
const SizedBox(height: 12),
],
if (_testCompleted &&
session != null &&
session.platform == 'youtube' &&
shareUrl == null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Preparazione diretta sul canale YouTube Match Live TV… '
'Il link apparirà qui tra pochi secondi.',
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 8),
],
if (_testCompleted && session != null && shareUrl != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
session.platform == 'youtube'
? 'Link diretta YouTube (canale Match Live TV)'
: 'Link diretta Match Live TV',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SelectableText(
shareUrl,
style: theme.textTheme.bodySmall,
),
if (session.platform == 'youtube') ...[
const SizedBox(height: 6),
Text(
'Condividi solo questo link: gli spettatori guardano su YouTube, non sul sito.',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => copyWatchLink(context, session),
icon: const Icon(Icons.link, size: 18),
label: const Text('COPIA'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => shareWatchLink(
context,
session: session,
title: shareTitle,
),
icon: const Icon(Icons.share, size: 18),
label: const Text('CONDIVIDI'),
),
),
],
),
],
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => shareRegiaLink(
context,
ref,
sessionId: session.id,
shareSubject:
'Regia — ${widget.match.teamName} vs ${widget.match.opponentName}',
),
icon: const Icon(Icons.scoreboard),
label: const Text('Condividi link regia (punteggio)'),
),
const SizedBox(height: 16),
Text('Tabellone', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
LiveScoreControls(
homeName: widget.match.teamName,
awayName: widget.match.opponentName,
pointsTarget: pointsTarget,
),
],
if (_testCompleted) ...[
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,
),
),
],
if (!_testCompleted) ...[
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

@@ -1,399 +0,0 @@
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 = 'public';
String? _youtubeChannel;
bool _creating = false;
void _syncYoutubeChannel(Team? team) {
if (team == null) {
_youtubeChannel = null;
return;
}
if (!team.canChooseYoutubeChannel) {
if (team.youtubeTeamChannelAvailable) {
_youtubeChannel = 'team';
} else if (team.youtubePlatformAvailable) {
_youtubeChannel = 'platform';
} else {
_youtubeChannel = null;
}
return;
}
_youtubeChannel ??= 'platform';
}
Future<void> _createSession() async {
setState(() => _creating = true);
try {
final client = ref.read(apiClientProvider);
final session = await client.createSession(
widget.match.id,
platform: _platform,
youtubeChannel: _platform == 'youtube' ? _youtubeChannel : null,
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 if (e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.connectionTimeout) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Il server impiega più del previsto (YouTube). Attendi e non ritentare subito: '
'controlla se la sessione è già stata creata.',
),
),
);
} 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.isYoutubeReady) {
setState(() {
_platform = 'youtube';
_syncYoutubeChannel(team);
});
return;
}
if (team.canUseYoutube) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'YouTube non è ancora disponibile. Riprova più tardi o usa Match Live TV sul sito.',
),
),
);
return;
}
showPremiumRequiredDialog(
context,
message: 'YouTube Live richiede Premium Light o Full.',
billingUrl: team.billingUrl,
);
}
String _youtubeSubtitle(Team? team) {
if (team == null || !team.canUseYoutube) {
return 'Premium Light o Full';
}
if (!team.isYoutubeReady) {
return 'Canale Match Live TV (in attivazione)';
}
if (_platform == 'youtube' && team.canChooseYoutubeChannel) {
return _youtubeChannel == 'team'
? 'Canale ${team.youtubeTeamChannelTitle ?? "società"}'
: 'Canale Match Live TV';
}
if (team.youtubeUsesPlatformChannel || team.youtubePlatformAvailable) {
final label = team.youtubeChannelTitle;
return label != null ? 'Canale $label' : 'Canale Match Live TV';
}
final label = team.youtubeTeamChannelTitle ?? team.youtubeChannelTitle;
return label != null ? 'Canale $label' : 'Canale società collegato';
}
@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) {
_syncYoutubeChannel(team);
final youtubeSelectable = team != null && team.isYoutubeReady;
final showYoutubeChannels =
_platform == 'youtube' && team != null && team.canChooseYoutubeChannel;
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: _youtubeSubtitle(team),
selected: _platform == 'youtube',
enabled: youtubeSelectable,
badge: team?.canUseYoutube == true ? null : 'Premium',
onTap: () => _onYoutubeTap(team),
),
if (showYoutubeChannels) ...[
const SizedBox(height: 16),
Text('Canale YouTube', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_PlatformCard(
title: 'Match Live TV',
subtitle: 'Canale ufficiale della piattaforma',
selected: _youtubeChannel == 'platform',
enabled: true,
onTap: () => setState(() => _youtubeChannel = 'platform'),
),
const SizedBox(height: 8),
_PlatformCard(
title: team!.youtubeTeamChannelTitle ?? 'Canale società',
subtitle: 'Il tuo canale collegato',
selected: _youtubeChannel == 'team',
enabled: true,
onTap: () => setState(() => _youtubeChannel = 'team'),
),
],
const SizedBox(height: 24),
Text('Visibilità', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'Match Live TV e YouTube (canale nostro o società).',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SegmentedButton<String>(
segments: const [
ButtonSegment(
value: 'public',
label: Text('Pubblico'),
icon: Icon(Icons.public),
),
ButtonSegment(
value: 'unlisted',
label: Text('Privato / non in elenco'),
icon: Icon(Icons.link),
),
],
selected: {_privacy},
onSelectionChanged: (s) => setState(() => _privacy = s.first),
),
const SizedBox(height: 8),
Text(
_privacy == 'public'
? 'Compare nell\'elenco dirette e sul canale YouTube scelto.'
: 'Solo chi ha il link. Non in elenco pubblico né nelle ricerche.',
style: theme.textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
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 · 3 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

@@ -1,164 +0,0 @@
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_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 · Test rete',
];
@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 stepIndex = widget.step.clamp(1, 3) - 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 StepNetworkTest(
match: match,
onBack: () => _goToStep(2),
);
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(3, (i) {
final step = i + 1;
final active = step <= current;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
decoration: BoxDecoration(
color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
);
}
}

View File

@@ -1,94 +0,0 @@
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();
}
}
}

View File

@@ -1,41 +0,0 @@
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';
import 'core/deep_link_listener.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 DeepLinkListener(
child: MaterialApp.router(
title: 'Match Live TV',
debugShowCheckedModeBanner: false,
theme: AppTheme.dark,
routerConfig: router,
),
);
}
}

View File

@@ -1,87 +0,0 @@
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;
}
/// Dopo rotazione: riallinea preview/stream senza fermare RTMP.
static Future<void> syncOrientation() async {
await _channel.invokeMethod<void>('syncOrientation');
}
static Future<void> stopPreview() async {
await _channel.invokeMethod('stopPreview');
}
static Future<void> startStream({
required String rtmpUrl,
int targetBitrate = 3000000,
int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async {
await _channel.invokeMethod('startStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
});
}
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
static Future<void> resumeStream({
required String rtmpUrl,
int targetBitrate = 3000000,
int targetFps = 30,
int width = 1280,
int height = 720,
bool portrait = false,
int rotation = 0,
}) async {
await _channel.invokeMethod('resumeStream', {
'rtmpUrl': rtmpUrl,
'videoBitrate': targetBitrate,
'fps': targetFps,
'width': width,
'height': height,
'portrait': portrait,
'rotation': rotation,
});
}
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

@@ -1,103 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Preview camera nativa (OpenGL) integrata nel layout Flutter.
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: const 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

@@ -1,141 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth_storage.dart';
import '../core/team_selection_storage.dart';
import '../shared/api_client.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,
);
}
/// Dopo restore: verifica token o rinnova con refresh (evita 401 su /teams).
Future<bool> validateOrRefreshSession() async {
if (!state.isAuthenticated) return false;
final client = ApiClient(accessToken: state.accessToken);
try {
final user = await client.me();
state = state.copyWith(user: user);
await AuthStorage.save(
user: user,
accessToken: state.accessToken!,
refreshToken: state.refreshToken!,
);
return true;
} on DioException catch (e) {
if (e.response?.statusCode != 401) {
return true;
}
} catch (_) {
return true;
}
return refreshSession();
}
Future<bool> refreshSession() async {
final refreshToken = state.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
logout();
return false;
}
try {
final client = ApiClient();
final result = await client.refreshSession(refreshToken);
setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
return true;
} catch (_) {
logout();
return 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();
TeamSelectionStorage.clear();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);

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