diff --git a/README.md b/README.md index bf87585..3c9a1f8 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ 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) -├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal -└── docs/ # Documentazione +├── backend/ # Rails 7.2 API + Action Cable + Sidekiq +├── native/android/ # App Android nativa (Kotlin + Compose) +├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal +└── docs/ # Documentazione ``` ## Deploy produzione (server 192.168.1.146) @@ -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 ``` diff --git a/docs/infrastructure/SERVER_DEPLOYMENT.md b/docs/infrastructure/SERVER_DEPLOYMENT.md index 8d2f1a1..729aa16 100644 --- a/docs/infrastructure/SERVER_DEPLOYMENT.md +++ b/docs/infrastructure/SERVER_DEPLOYMENT.md @@ -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' diff --git a/mobile/.gitignore b/mobile/.gitignore deleted file mode 100644 index 3820a95..0000000 --- a/mobile/.gitignore +++ /dev/null @@ -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 diff --git a/mobile/.metadata b/mobile/.metadata deleted file mode 100644 index a906de7..0000000 --- a/mobile/.metadata +++ /dev/null @@ -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' diff --git a/mobile/README.md b/mobile/README.md deleted file mode 100644 index 15db89a..0000000 --- a/mobile/README.md +++ /dev/null @@ -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. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/mobile/analysis_options.yaml +++ /dev/null @@ -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 diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore deleted file mode 100644 index be3943c..0000000 --- a/mobile/android/.gitignore +++ /dev/null @@ -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 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts deleted file mode 100644 index 46895e5..0000000 --- a/mobile/android/app/build.gradle.kts +++ /dev/null @@ -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") -} diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro deleted file mode 100644 index 57c43b6..0000000 --- a/mobile/android/app/proguard-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ -# RootEncoder / SLF4J (R8 release) --dontwarn org.slf4j.impl.StaticLoggerBinder diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/mobile/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 8c44282..0000000 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt deleted file mode 100644 index d6348bd..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt +++ /dev/null @@ -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()) - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt deleted file mode 100644 index c93c24a..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt +++ /dev/null @@ -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 -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt deleted file mode 100644 index f52af47..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt +++ /dev/null @@ -1,1065 +0,0 @@ -package com.matchlivetv.match_live_tv - -import android.content.Context -import android.media.MediaCodecInfo -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.util.Log -import androidx.annotation.RequiresApi -import com.pedro.common.ConnectChecker -import com.pedro.library.generic.GenericStream -import com.pedro.library.util.BitrateAdapter -import com.pedro.library.util.SensorRotationManager -import com.pedro.library.view.OpenGlView -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger - -data class StreamingConfig( - val rtmpUrl: String, - val width: Int = 1280, - val height: Int = 720, - val videoBitrate: Int = 3_000_000, - val audioBitrate: Int = 128_000, - val fps: Int = 30, - val rotation: Int = 0, - val portrait: Boolean = false, - // Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono) - val sampleRate: Int = 48_000, - val stereo: Boolean = false, - val maxReconnectAttempts: Int = 10, - val reconnectDelayMs: Long = 5_000L, -) - -data class StreamingMetrics( - val isStreaming: Boolean, - val isPreviewActive: Boolean, - val isConnected: Boolean, - val state: String, - val bitrateKbps: Long, - val fps: Int, - val durationMs: Long, - val reconnectAttempts: Int, - val lastError: String?, -) { - fun toMap(): Map = mapOf( - "isStreaming" to isStreaming, - "isPreviewActive" to isPreviewActive, - "isConnected" to isConnected, - "state" to state, - "bitrateKbps" to bitrateKbps, - "fps" to fps, - "durationMs" to durationMs, - "reconnectAttempts" to reconnectAttempts, - "lastError" to lastError, - ) -} - -enum class StreamingState { - IDLE, - PREVIEWING, - CONNECTING, - STREAMING, - RECONNECTING, - STOPPING, - ERROR, -} - -interface StreamingEngineListener { - fun onStateChanged(state: StreamingState, message: String? = null) - fun onMetricsUpdated(metrics: StreamingMetrics) - fun onError(message: String) -} - -@RequiresApi(Build.VERSION_CODES.LOLLIPOP) -class StreamingEngine( - private val appContext: Context, -) : ConnectChecker { - - private val mainHandler = Handler(Looper.getMainLooper()) - private val listeners = mutableSetOf() - - private var genericStream: GenericStream? = null - private var previewSurface: OpenGlView? = null - - private var config: StreamingConfig? = null - private var state: StreamingState = StreamingState.IDLE - private var streamStartedAtMs: Long = 0L - private var currentBitrateKbps: Long = 0L - private var currentFps: Int = 0 - private var lastError: String? = null - private var lastVideoFrameCount: Long = 0L - private var lastFpsSampleAtMs: Long = 0L - private var videoStallChecks: Int = 0 - private var lastStallFrameCount: Long = -1L - private val reconnectAttempts = AtomicInteger(0) - private val isReleased = AtomicBoolean(false) - private val pausingIntentionally = AtomicBoolean(false) - private var previewRebindUntilMs = 0L - private var previewRebindAttempts = 0 - private var sensorRotationManager: SensorRotationManager? = null - private var surfaceLost = false - private var rebindInProgress = false - private var pendingRotation: Int? = null - private var pendingIsPortrait: Boolean? = null - private var lastAppliedRotation = -1 - - private val bitrateAdapter = BitrateAdapter { adaptedBitrate -> - genericStream?.setVideoBitrateOnFly(adaptedBitrate) - } - - private val idlePreviewRunnable = Runnable { - val glView = previewSurface ?: return@Runnable - if (!canAttachPreview(glView)) { - if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) { - scheduleIdlePreviewAttach(previewRebindAttempts + 1) - } - return@Runnable - } - finishIdlePreviewBind(glView) - } - - private val surfaceReadyRunnable = Runnable { - if (isReleased.get() || rebindInProgress) { - return@Runnable - } - val glView = previewSurface ?: return@Runnable - if (!canAttachPreview(glView)) { - if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) { - scheduleSurfaceReady(previewRebindAttempts + 1) - } - return@Runnable - } - surfaceLost = false - val stream = genericStream - if (stream != null && (stream.isStreaming || stream.isOnPreview)) { - safeReattachPreview(glView) - flushPendingOrientation() - } else { - attachPreviewToStream() - } - } - private val metricsRunnable = object : Runnable { - override fun run() { - emitMetrics() - if (state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING || - state == StreamingState.PREVIEWING - ) { - mainHandler.postDelayed(this, METRICS_INTERVAL_MS) - } - } - } - - fun addListener(listener: StreamingEngineListener) { - listeners.add(listener) - } - - fun removeListener(listener: StreamingEngineListener) { - listeners.remove(listener) - } - - fun setupPreviewGlView(glView: StreamingOpenGlView) { - ensureMainThread() - updatePreservePipelineFlag(glView) - glView.onPreviewSurfaceLost = { onPlatformSurfaceDestroyed() } - } - - fun attachPreviewHost(glView: StreamingOpenGlView) { - ensureMainThread() - setupPreviewGlView(glView) - bindStreamPreview(glView) - } - - private fun updatePreservePipelineFlag(glView: StreamingOpenGlView?) { - glView?.preservePipelineOnSurfaceLoss = - state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING - } - - /** Surface OpenGL pronta (debounced). */ - fun onPlatformSurfaceReady(glView: OpenGlView) { - ensureMainThread() - previewSurface = glView - StreamPreviewHolder.openGlView = glView - previewRebindAttempts = 0 - mainHandler.removeCallbacks(surfaceReadyRunnable) - val live = state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING - if (live) { - scheduleSurfaceReady(0) - return - } - scheduleIdlePreviewAttach(0) - } - - /** - * Chiamato da MainActivity.onConfigurationChanged *prima* di super, per fermare drawScreen - * sul thread GL encoder prima che Flutter ricrei le surface (evita GL_OUT_OF_MEMORY). - */ - fun pauseGlDuringRotation() { - ensureMainThread() - if (isReleased.get()) { - return - } - val active = state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING || - state == StreamingState.PREVIEWING - if (!active) { - return - } - Log.i(TAG, "pauseGlDuringRotation state=$state") - beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS) - } - - /** Chiamato quando Flutter distrugge la surface PlatformView durante la rotazione. */ - fun onPlatformSurfaceDestroyed() { - ensureMainThread() - beginSurfaceTransition(SURFACE_TRANSITION_GRACE_MS) - } - - private fun beginSurfaceTransition(graceMs: Long) { - surfaceLost = true - markPreviewRebinding(graceMs) - mainHandler.removeCallbacks(surfaceReadyRunnable) - mainHandler.removeCallbacks(idlePreviewRunnable) - mainHandler.removeCallbacks(orientationApplyRunnable) - mainHandler.removeCallbacks(postRebindOrientationRunnable) - stopPreviewGlRender() - try { - genericStream?.getGlInterface()?.setForceRender(false) - genericStream?.getGlInterface()?.deAttachPreview() - } catch (exception: Exception) { - Log.w(TAG, "beginSurfaceTransition: ${exception.message}") - } - } - - private fun stopPreviewGlRender() { - listOfNotNull(previewSurface, StreamPreviewHolder.openGlView) - .distinct() - .forEach { glView -> - (glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) } - try { - glView.setForceRender(false) - } catch (_: Exception) { - } - } - } - - /** Solo orientamento encoder: niente rebind preview (evita crash GL in rotazione). */ - fun applyOrientationFromSensor() { - ensureMainThread() - refreshOrientationSensor() - } - - fun bindStreamPreview(glView: OpenGlView) { - ensureMainThread() - previewSurface = glView - StreamPreviewHolder.openGlView = glView - (glView as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) } - val live = state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING - if (live) { - onPlatformSurfaceReady(glView) - return - } - bindPreviewForIdle(glView) - } - - private fun finishIdlePreviewBind(glView: OpenGlView) { - val stream = genericStream ?: return - if (stream.isOnPreview) { - return - } - try { - attachPreviewToStream() - ensureOrientationSensor() - updateState(StreamingState.PREVIEWING) - startMetricsLoop() - } catch (exception: Exception) { - Log.w(TAG, "finishIdlePreviewBind failed: ${exception.message}") - if (previewRebindAttempts < PREVIEW_REBIND_MAX_ATTEMPTS) { - scheduleIdlePreviewAttach(previewRebindAttempts + 1) - } - } - } - - private fun bindPreviewForIdle(glView: OpenGlView) { - val surfaceChanged = previewSurface != null && previewSurface !== glView - previewSurface = glView - - if (state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING - ) { - return - } - - if (surfaceChanged) { - genericStream?.let { stream -> - if (stream.isOnPreview) { - stream.stopPreview() - } - } - } - - val cfg = config ?: previewConfig() - val stream = genericStream ?: obtainGenericStream(cfg) - genericStream = stream - - if (!stream.isOnPreview) { - stopPreviewAndStreamForPrepare(stream) - val prepared = try { - stream.prepareVideo( - width = cfg.width, - height = cfg.height, - bitrate = cfg.videoBitrate, - fps = cfg.fps, - iFrameInterval = 1, - rotation = cfg.rotation, - profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, - level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, - ) && stream.prepareAudio( - cfg.sampleRate, - cfg.stereo, - cfg.audioBitrate, - ) - } catch (_: Exception) { - false - } - if (!prepared) { - notifyError("Impossibile preparare la preview camera") - return - } - } - - if (!canAttachPreview(glView)) { - Log.i(TAG, "bindPreviewForIdle waiting for surface state=$state") - scheduleIdlePreviewAttach(0) - return - } - finishIdlePreviewBind(glView) - } - - fun unbindStreamPreview() { - ensureMainThread() - previewSurface = null - genericStream?.let { stream -> - if (stream.isOnPreview) { - stream.stopPreview() - } - } - if (state == StreamingState.PREVIEWING) { - updateState(StreamingState.IDLE) - } - } - - /** Ripresa dopo pausa: stesso percorso di startStream (evita RTMP solo-audio dopo reconnect). */ - fun resumeStream(streamConfig: StreamingConfig) { - ensureMainThread() - Log.i(TAG, "resumeStream reset url=${streamConfig.rtmpUrl}") - stopStream() - updateState(StreamingState.IDLE) - mainHandler.postDelayed({ - if (!isReleased.get()) { - startStream(streamConfig) - } - }, 250) - } - - fun startStream(streamConfig: StreamingConfig) { - ensureMainThread() - Log.i(TAG, "startStream state=$state url=${streamConfig.rtmpUrl} bitrate=${streamConfig.videoBitrate} fps=${streamConfig.fps}") - if (isReleased.get()) { - notifyError("StreamingEngine rilasciato") - return - } - // Già in onda sullo stesso endpoint RTMP. - if (state == StreamingState.STREAMING && - config?.rtmpUrl == streamConfig.rtmpUrl && - genericStream?.isStreaming == true - ) { - return - } - // Nuova sessione o retry dopo errore: reset completo (evita startStream ignorato). - if (state != StreamingState.IDLE && state != StreamingState.PREVIEWING) { - stopStream() - } - - config = streamConfig - lastError = null - reconnectAttempts.set(0) - - val stream = genericStream ?: obtainGenericStream(streamConfig) - genericStream = stream - stopPreviewAndStreamForPrepare(stream) - - val prepared = try { - stream.prepareVideo( - width = streamConfig.width, - height = streamConfig.height, - bitrate = streamConfig.videoBitrate, - fps = streamConfig.fps, - iFrameInterval = 1, - rotation = streamConfig.rotation, - profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, - level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, - ) && stream.prepareAudio( - streamConfig.sampleRate, - streamConfig.stereo, - streamConfig.audioBitrate, - ) - } catch (exception: IllegalArgumentException) { - false - } catch (exception: IllegalStateException) { - false - } - - if (!prepared) { - notifyError("Configurazione audio/video non valida") - updateState(StreamingState.ERROR, "prepare failed") - return - } - - attachPreviewToStream() - lastAppliedRotation = -1 - val portraitHint = streamConfig.portrait || streamConfig.height > streamConfig.width - val cameraRotation = if (portraitHint && streamConfig.rotation == 0) 90 else streamConfig.rotation - applyGlOrientation( - rotation = cameraRotation, - isPortrait = portraitHint, - logLabel = "startStream", - ) - ensureOrientationSensor() - resetFpsTracking() - videoStallChecks = 0 - lastStallFrameCount = -1L - - stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts) - bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate) - waitForPreviewThenPublish(streamConfig, attempt = 0) - } - - private fun waitForPreviewThenPublish(streamConfig: StreamingConfig, attempt: Int) { - if (isReleased.get()) { - return - } - attachPreviewToStream() - val stream = genericStream - val previewReady = stream?.isOnPreview == true && previewGlView() != null - if (!previewReady) { - if (attempt >= 40) { - notifyError("Anteprima camera non pronta — impossibile inviare video") - updateState(StreamingState.ERROR, "preview_not_ready") - return - } - mainHandler.postDelayed({ - waitForPreviewThenPublish(streamConfig, attempt + 1) - }, 50L) - return - } - - mainHandler.postDelayed({ - if (isReleased.get()) { - return@postDelayed - } - val activeStream = genericStream ?: return@postDelayed - streamStartedAtMs = SystemClock.elapsedRealtime() - updateState(StreamingState.CONNECTING) - Log.i(TAG, "publish RTMP url=${streamConfig.rtmpUrl}") - activeStream.startStream(streamConfig.rtmpUrl) - startMetricsLoop() - }, 350L) - } - - fun pauseStream() { - ensureMainThread() - Log.i(TAG, "pauseStream state=$state") - if (state != StreamingState.STREAMING && - state != StreamingState.CONNECTING && - state != StreamingState.RECONNECTING - ) { - return - } - - stopMetricsLoop() - pausingIntentionally.set(true) - genericStream?.let { stream -> - if (stream.isStreaming) { - stream.stopStream() - } - } - pausingIntentionally.set(false) - - streamStartedAtMs = 0L - currentBitrateKbps = 0L - currentFps = 0 - reconnectAttempts.set(0) - attachPreviewToStream() - updateState(StreamingState.PREVIEWING) - startMetricsLoop() - } - - fun stopStream() { - ensureMainThread() - Log.i(TAG, "stopStream state=$state") - if (state == StreamingState.IDLE || state == StreamingState.STOPPING) { - return - } - - updateState(StreamingState.STOPPING) - stopMetricsLoop() - stopOrientationSensor() - (previewSurface as? StreamingOpenGlView)?.preservePipelineOnSurfaceLoss = false - - genericStream?.let { stream -> - if (stream.isStreaming) { - stream.stopStream() - } - if (stream.isOnPreview) { - stream.stopPreview() - } - stream.release() - } - genericStream = null - - streamStartedAtMs = 0L - currentBitrateKbps = 0L - currentFps = 0 - reconnectAttempts.set(0) - updateState(StreamingState.IDLE) - emitMetrics() - } - - fun release() { - ensureMainThread() - if (!isReleased.compareAndSet(false, true)) { - return - } - stopStream() - unbindStreamPreview() - listeners.clear() - } - - fun getMetrics(): StreamingMetrics = buildMetrics() - - override fun onConnectionStarted(url: String) { - postMain { - Log.i(TAG, "onConnectionStarted url=$url") - updateState(StreamingState.CONNECTING, url) - } - } - - override fun onConnectionSuccess() { - postMain { - Log.i(TAG, "onConnectionSuccess") - reconnectAttempts.set(0) - lastError = null - resetFpsTracking() - videoStallChecks = 0 - lastStallFrameCount = genericStream?.getStreamClient()?.getSentVideoFrames() ?: 0L - (previewSurface as? StreamingOpenGlView)?.let { updatePreservePipelineFlag(it) } - updateState(StreamingState.STREAMING) - } - } - - override fun onConnectionFailed(reason: String) { - postMain { - if (pausingIntentionally.get()) { - return@postMain - } - Log.w(TAG, "onConnectionFailed reason=$reason state=$state attempts=${reconnectAttempts.get()}") - lastError = reason - val stream = genericStream ?: return@postMain - val currentConfig = config ?: return@postMain - - updateState(StreamingState.RECONNECTING, reason) - val attempt = reconnectAttempts.incrementAndGet() - val retryScheduled = stream.getStreamClient().reTry( - currentConfig.reconnectDelayMs, - reason, - null, - ) - - if (retryScheduled) { - emitMetrics() - } else { - updateState(StreamingState.ERROR, reason) - notifyError("Connessione RTMP fallita: $reason") - stream.stopStream() - } - } - } - - override fun onNewBitrate(bitrate: Long) { - postMain { - currentBitrateKbps = bitrate / 1000 - // Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS. - emitMetrics() - } - } - - override fun onDisconnect() { - postMain { - if (pausingIntentionally.get()) { - return@postMain - } - Log.w(TAG, "onDisconnect state=$state") - if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) { - updateState(StreamingState.RECONNECTING, "disconnected") - } - } - } - - override fun onAuthError() { - postMain { - Log.w(TAG, "onAuthError") - lastError = "auth_error" - updateState(StreamingState.ERROR, "auth_error") - genericStream?.stopStream() - notifyError("Autenticazione RTMP fallita") - } - } - - override fun onAuthSuccess() { - postMain { - Log.i(TAG, "onAuthSuccess") - updateState(StreamingState.STREAMING) - } - } - - private fun stopPreviewAndStreamForPrepare(stream: GenericStream) { - if (stream.isStreaming) { - stream.stopStream() - } - if (stream.isOnPreview) { - stream.stopPreview() - } - } - - private fun previewGlView(): OpenGlView? = - previewSurface ?: StreamPreviewHolder.openGlView - - private fun resetFpsTracking() { - lastFpsSampleAtMs = 0L - lastVideoFrameCount = 0L - currentFps = 0 - } - - private fun attachPreviewToStream(forceRebind: Boolean = false) { - val glView = previewGlView() ?: return - previewSurface = glView - val stream = genericStream ?: return - val liveState = state == StreamingState.STREAMING || - state == StreamingState.CONNECTING || - state == StreamingState.RECONNECTING - if (liveState && (stream.isStreaming || stream.isOnPreview)) { - safeReattachPreview(glView) - return - } - if (forceRebind && stream.isOnPreview && !liveState) { - stream.stopPreview() - } - if (!stream.isOnPreview) { - if (!canAttachPreview(glView)) { - return - } - try { - stream.startPreview(glView, autoHandle = false) - ensureOrientationSensor() - } catch (exception: Exception) { - Log.w(TAG, "attachPreviewToStream failed: ${exception.message}") - } - } - } - - private fun scheduleIdlePreviewAttach(attempt: Int) { - previewRebindAttempts = attempt - if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) { - Log.w(TAG, "idle preview attach gave up after $attempt attempts state=$state") - return - } - val delayMs = if (attempt == 0) IDLE_PREVIEW_ATTACH_MS else PREVIEW_REBIND_RETRY_MS - mainHandler.removeCallbacks(idlePreviewRunnable) - mainHandler.postDelayed(idlePreviewRunnable, delayMs) - } - - private fun scheduleSurfaceReady(attempt: Int) { - previewRebindAttempts = attempt - if (attempt >= PREVIEW_REBIND_MAX_ATTEMPTS) { - Log.w(TAG, "surface rebind gave up after $attempt attempts state=$state") - return - } - val delayMs = when { - attempt == 0 -> SURFACE_READY_DEBOUNCE_MS - else -> PREVIEW_REBIND_RETRY_MS - } - mainHandler.postDelayed(surfaceReadyRunnable, delayMs) - } - - private fun markPreviewRebinding(durationMs: Long = PREVIEW_REBIND_GRACE_MS) { - previewRebindUntilMs = SystemClock.elapsedRealtime() + durationMs - videoStallChecks = 0 - lastStallFrameCount = -1L - } - - private fun isPreviewRebinding(): Boolean = - SystemClock.elapsedRealtime() < previewRebindUntilMs - - private fun canAttachPreview(glView: OpenGlView): Boolean { - if (!glView.isAttachedToWindow) { - return false - } - return try { - val surface = glView.holder?.surface - surface != null && surface.isValid && glView.width > 0 && glView.height > 0 - } catch (_: Exception) { - false - } - } - - private fun safeReattachPreview(glView: OpenGlView) { - if (rebindInProgress) { - return - } - val stream = genericStream ?: return - previewSurface = glView - if (!canAttachPreview(glView)) { - scheduleSurfaceReady(previewRebindAttempts + 1) - return - } - val surface = glView.holder?.surface - if (surface == null || !surface.isValid) { - scheduleSurfaceReady(previewRebindAttempts + 1) - return - } - rebindInProgress = true - markPreviewRebinding() - try { - Log.i( - TAG, - "safeReattachPreview state=$state isPreview=${stream.isOnPreview} isStreaming=${stream.isStreaming}", - ) - val glInterface = stream.getGlInterface() - val width = glView.width.coerceAtLeast(2) - val height = glView.height.coerceAtLeast(2) - glInterface.setForceRender(false) - glInterface.deAttachPreview() - glInterface.attachPreview(surface) - glInterface.setPreviewResolution(width, height) - glInterface.setForceRender(true, 30) - } catch (exception: Exception) { - Log.w(TAG, "safeReattachPreview failed: ${exception.message}") - scheduleSurfaceReady(previewRebindAttempts + 1) - } finally { - rebindInProgress = false - surfaceLost = false - finishRotationTransition() - } - } - - /** Orientamento camera/preview/stream via GL (senza metadata RTMP rotation). */ - private fun applyGlOrientation( - rotation: Int, - isPortrait: Boolean, - logLabel: String, - forceRender: Boolean = true, - ) { - if (surfaceLost || rebindInProgress || isPreviewRebinding()) { - pendingRotation = rotation - pendingIsPortrait = isPortrait - Log.i(TAG, "$logLabel deferred rotation=$rotation portrait=$isPortrait") - return - } - val stream = genericStream ?: return - val glView = previewSurface - if (glView != null && !canAttachPreview(glView)) { - return - } - try { - val glInterface = stream.getGlInterface() - if (forceRender) { - glInterface.setForceRender(false) - } - glInterface.autoHandleOrientation = false - glInterface.setIsPortrait(isPortrait) - glInterface.setCameraOrientation(rotation) - glInterface.setPreviewRotation(0) - glInterface.setStreamRotation(0) - lastAppliedRotation = rotation - pendingIsPortrait = isPortrait - Log.i( - TAG, - "$logLabel orientation rotation=$rotation portrait=$isPortrait state=$state", - ) - } catch (exception: Exception) { - Log.w(TAG, "$logLabel orientation failed: ${exception.message}") - } finally { - if (forceRender) { - try { - stream.getGlInterface().setForceRender(true, 30) - } catch (_: Exception) { - } - } - } - } - - private fun ensureOrientationSensor() { - if (sensorRotationManager != null) { - return - } - sensorRotationManager = SensorRotationManager(appContext, true, true) { rotation, isPortrait -> - postMain { applySensorRotation(rotation, isPortrait) } - } - sensorRotationManager?.start() - } - - private fun refreshOrientationSensor() { - sensorRotationManager?.stop() - sensorRotationManager = null - ensureOrientationSensor() - } - - private fun stopOrientationSensor() { - sensorRotationManager?.stop() - sensorRotationManager = null - } - - private val orientationApplyRunnable = Runnable { - val rotation = pendingRotation ?: return@Runnable - val isPortrait = pendingIsPortrait ?: return@Runnable - applySensorRotationNow(rotation, isPortrait) - } - - private fun applySensorRotation(rotation: Int, isPortrait: Boolean) { - pendingRotation = rotation - pendingIsPortrait = isPortrait - mainHandler.removeCallbacks(orientationApplyRunnable) - mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS) - } - - private fun applySensorRotationNow(rotation: Int, isPortrait: Boolean) { - if (rotation == lastAppliedRotation && !surfaceLost) { - return - } - if (surfaceLost || rebindInProgress || isPreviewRebinding()) { - Log.i(TAG, "sensorOrientation queued rotation=$rotation portrait=$isPortrait") - return - } - applyGlOrientation(rotation, isPortrait, "sensorOrientation") - } - - private val postRebindOrientationRunnable = Runnable { - val rotation = pendingRotation ?: return@Runnable - val isPortrait = pendingIsPortrait ?: return@Runnable - if (surfaceLost || rebindInProgress) { - return@Runnable - } - applyGlOrientation(rotation, isPortrait, "postRebind") - } - - private fun finishRotationTransition() { - mainHandler.removeCallbacks(postRebindOrientationRunnable) - mainHandler.postDelayed(postRebindOrientationRunnable, POST_REBIND_ORIENTATION_MS) - } - - private fun flushPendingOrientation() { - mainHandler.removeCallbacks(orientationApplyRunnable) - mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS) - } - - private fun previewConfig(): StreamingConfig { - return config ?: StreamingConfig( - rtmpUrl = "rtmp://127.0.0.1/preview", - width = 1280, - height = 720, - ) - } - - private fun obtainGenericStream(streamConfig: StreamingConfig): GenericStream { - return GenericStream( - appContext, - this, - ).apply { - getGlInterface().autoHandleOrientation = false - getStreamClient().setBitrateExponentialFactor(0.5f) - getStreamClient().setReTries(streamConfig.maxReconnectAttempts) - } - } - - private fun buildMetrics(): StreamingMetrics { - val durationMs = if (streamStartedAtMs > 0L) { - SystemClock.elapsedRealtime() - streamStartedAtMs - } else { - 0L - } - - return StreamingMetrics( - isStreaming = state == StreamingState.STREAMING || - state == StreamingState.RECONNECTING || - state == StreamingState.CONNECTING, - isPreviewActive = state == StreamingState.PREVIEWING || - (genericStream?.isOnPreview == true), - isConnected = state == StreamingState.STREAMING, - state = state.name.lowercase(), - bitrateKbps = currentBitrateKbps, - fps = currentFps, - durationMs = durationMs, - reconnectAttempts = reconnectAttempts.get(), - lastError = lastError, - ) - } - - private fun updateState(newState: StreamingState, message: String? = null) { - state = newState - listeners.forEach { it.onStateChanged(newState, message) } - emitMetrics() - } - - private fun emitMetrics() { - updateFpsEstimate() - checkVideoStallAndRecover() - val metrics = buildMetrics() - listeners.forEach { it.onMetricsUpdated(metrics) } - } - - /** Se RTMP è connesso ma non partono frame video, ripubblica con preview attiva. */ - private fun checkVideoStallAndRecover() { - if (isPreviewRebinding()) { - return - } - if (state != StreamingState.STREAMING && state != StreamingState.RECONNECTING) { - videoStallChecks = 0 - lastStallFrameCount = -1L - return - } - val stream = genericStream ?: return - if (!stream.isStreaming) { - return - } - val frames = stream.getStreamClient().getSentVideoFrames() - if (lastStallFrameCount < 0L) { - lastStallFrameCount = frames - return - } - if (frames > lastStallFrameCount) { - videoStallChecks = 0 - lastStallFrameCount = frames - return - } - videoStallChecks += 1 - if (videoStallChecks < 12) { - return - } - videoStallChecks = 0 - lastStallFrameCount = -1L - val cfg = config ?: return - recoverVideoStream(cfg) - } - - private fun recoverVideoStream(streamConfig: StreamingConfig) { - val stream = genericStream ?: return - Log.w(TAG, "recoverVideoStream state=$state url=${streamConfig.rtmpUrl}") - pausingIntentionally.set(true) - try { - if (stream.isStreaming) { - stream.stopStream() - } - } finally { - pausingIntentionally.set(false) - } - stopPreviewAndStreamForPrepare(stream) - val prepared = try { - stream.prepareVideo( - width = streamConfig.width, - height = streamConfig.height, - bitrate = streamConfig.videoBitrate, - fps = streamConfig.fps, - iFrameInterval = 1, - rotation = streamConfig.rotation, - profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, - level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, - ) && stream.prepareAudio( - streamConfig.sampleRate, - streamConfig.stereo, - streamConfig.audioBitrate, - ) - } catch (_: Exception) { - false - } - if (!prepared) { - notifyError("Ripristino video fallito") - updateState(StreamingState.ERROR, "video_recover_failed") - return - } - attachPreviewToStream() - ensureOrientationSensor() - resetFpsTracking() - waitForPreviewThenPublish(streamConfig, attempt = 0) - } - - private fun updateFpsEstimate() { - val stream = genericStream ?: return - val now = SystemClock.elapsedRealtime() - val sentFrames = stream.getStreamClient().getSentVideoFrames() - if (lastFpsSampleAtMs == 0L) { - lastFpsSampleAtMs = now - lastVideoFrameCount = sentFrames - return - } - - val elapsedMs = now - lastFpsSampleAtMs - if (elapsedMs >= 1_000L) { - val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L) - currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120) - lastVideoFrameCount = sentFrames - lastFpsSampleAtMs = now - } - } - - private fun notifyError(message: String) { - listeners.forEach { it.onError(message) } - } - - private fun startMetricsLoop() { - mainHandler.removeCallbacks(metricsRunnable) - mainHandler.post(metricsRunnable) - } - - private fun stopMetricsLoop() { - mainHandler.removeCallbacks(metricsRunnable) - } - - private fun postMain(block: () -> Unit) { - if (Looper.myLooper() == Looper.getMainLooper()) { - block() - } else { - mainHandler.post(block) - } - } - - private fun ensureMainThread() { - check(Looper.myLooper() == Looper.getMainLooper()) { - "StreamingEngine must be used on the main thread" - } - } - - companion object { - private const val TAG = "StreamingEngine" - private const val METRICS_INTERVAL_MS = 1_000L - private const val SURFACE_READY_DEBOUNCE_MS = 700L - private const val IDLE_PREVIEW_ATTACH_MS = 80L - private const val POST_REBIND_ORIENTATION_MS = 350L - private const val SURFACE_TRANSITION_GRACE_MS = 8_000L - private const val ORIENTATION_DEBOUNCE_MS = 700L - private const val PREVIEW_REBIND_RETRY_MS = 120L - private const val PREVIEW_REBIND_MAX_ATTEMPTS = 15 - private const val PREVIEW_REBIND_GRACE_MS = 6_000L - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt deleted file mode 100644 index ea2bcf1..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt +++ /dev/null @@ -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 - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt deleted file mode 100644 index b3e9ef3..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt +++ /dev/null @@ -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 = - 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) -> Unit>() - - fun addListener(listener: (Map) -> Unit) { - synchronized(listeners) { - listeners.add(listener) - } - } - - fun removeListener(listener: (Map) -> Unit) { - synchronized(listeners) { - listeners.remove(listener) - } - } - - fun emit(event: Map) { - val snapshot = synchronized(listeners) { listeners.toList() } - snapshot.forEach { it(event) } - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingOpenGlView.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingOpenGlView.kt deleted file mode 100644 index ffcfc83..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingOpenGlView.kt +++ /dev/null @@ -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) - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt deleted file mode 100644 index 89e22d1..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt +++ /dev/null @@ -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) -> 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" - } -} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt deleted file mode 100644 index 8fff3d1..0000000 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt +++ /dev/null @@ -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) - } -} diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/mobile/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be..0000000 --- a/mobile/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml deleted file mode 100644 index 0b09da7..0000000 --- a/mobile/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - Match Live TV - Streaming live - Notifiche durante lo streaming Match Live TV - Match Live TV - Streaming attivo - Connessione al server RTMP… - Trasmissione in corso - Riconnessione in corso… - Errore di streaming - Stop - diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88..0000000 --- a/mobile/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 82f15b7..0000000 --- a/mobile/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - 10.0.2.2 - localhost - 127.0.0.1 - - diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/mobile/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts deleted file mode 100644 index d08ad63..0000000 --- a/mobile/android/build.gradle.kts +++ /dev/null @@ -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("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties deleted file mode 100644 index e96108c..0000000 --- a/mobile/android/gradle.properties +++ /dev/null @@ -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 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bf..0000000 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -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 diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts deleted file mode 100644 index 2786c72..0000000 --- a/mobile/android/settings.gradle.kts +++ /dev/null @@ -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") diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore deleted file mode 100644 index 7a7f987..0000000 --- a/mobile/ios/.gitignore +++ /dev/null @@ -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 diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902..0000000 --- a/mobile/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/mobile/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/mobile/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index abfdfb5..0000000 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -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 = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 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 = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 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 = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* 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 = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 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 = ""; - }; -/* 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 = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* 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 */; -} diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c3fedb2..0000000 --- a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367..0000000 --- a/mobile/ios/Runner/AppDelegate.swift +++ /dev/null @@ -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) - } -} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -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" - } -} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d93..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b00..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe73094..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773c..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32a..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf1..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -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" - } -} diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/mobile/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist deleted file mode 100644 index 2189020..0000000 --- a/mobile/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Match Live TV - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - match_live_tv - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/mobile/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea..0000000 --- a/mobile/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b..0000000 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -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. - } - -} diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart deleted file mode 100644 index 4e62848..0000000 --- a/mobile/lib/app/router.dart +++ /dev/null @@ -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(authProvider, (prev, next) { - final authChanged = prev?.isAuthenticated != next.isAuthenticated; - if (authChanged) notifyListeners(); - }); - } - - final Ref _ref; -} - -final routerProvider = Provider((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); - }, - ), - ], - ); -}); diff --git a/mobile/lib/app/theme.dart b/mobile/lib/app/theme.dart deleted file mode 100644 index 64bd13c..0000000 --- a/mobile/lib/app/theme.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/mobile/lib/core/auth_storage.dart b/mobile/lib/core/auth_storage.dart deleted file mode 100644 index ed379dc..0000000 --- a/mobile/lib/core/auth_storage.dart +++ /dev/null @@ -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 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 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); - } -} diff --git a/mobile/lib/core/config.dart b/mobile/lib/core/config.dart deleted file mode 100644 index 035886f..0000000 --- a/mobile/lib/core/config.dart +++ /dev/null @@ -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'; - } -} diff --git a/mobile/lib/core/deep_link_listener.dart b/mobile/lib/core/deep_link_listener.dart deleted file mode 100644 index f9a5c1f..0000000 --- a/mobile/lib/core/deep_link_listener.dart +++ /dev/null @@ -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 createState() => _DeepLinkListenerState(); -} - -class _DeepLinkListenerState extends ConsumerState { - final _appLinks = AppLinks(); - StreamSubscription? _sub; - - @override - void initState() { - super.initState(); - _handleInitialLink(); - _sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {}); - } - - Future _handleInitialLink() async { - final uri = await _appLinks.getInitialLink(); - if (uri != null) await _onUri(uri); - } - - Future _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; -} diff --git a/mobile/lib/core/invite_storage.dart b/mobile/lib/core/invite_storage.dart deleted file mode 100644 index 587edc4..0000000 --- a/mobile/lib/core/invite_storage.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -class InviteStorage { - static const _keyToken = 'pending_invite_token'; - - static Future saveToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_keyToken, token); - } - - static Future readToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyToken); - } - - static Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_keyToken); - } -} diff --git a/mobile/lib/core/team_selection_storage.dart b/mobile/lib/core/team_selection_storage.dart deleted file mode 100644 index f442fbd..0000000 --- a/mobile/lib/core/team_selection_storage.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -class TeamSelectionStorage { - static const _keySelectedTeamId = 'selected_team_id'; - - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - final id = prefs.getString(_keySelectedTeamId); - if (id == null || id.isEmpty) return null; - return id; - } - - static Future save(String teamId) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_keySelectedTeamId, teamId); - } - - static Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_keySelectedTeamId); - } -} diff --git a/mobile/lib/features/archive/archive_screen.dart b/mobile/lib/features/archive/archive_screen.dart deleted file mode 100644 index fbdbea0..0000000 --- a/mobile/lib/features/archive/archive_screen.dart +++ /dev/null @@ -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().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( - 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 _openReplay(String? url) async { - if (url == null) return; - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.inAppBrowserView); - } - } - - Future _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; - } - } -} diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart deleted file mode 100644 index f4ebed7..0000000 --- a/mobile/lib/features/auth/login_screen.dart +++ /dev/null @@ -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 createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState { - final _formKey = GlobalKey(); - 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 _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 _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 _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 Wi‑Fi.', - ); - 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, - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/auth/splash_screen.dart b/mobile/lib/features/auth/splash_screen.dart deleted file mode 100644 index 2b25bdc..0000000 --- a/mobile/lib/features/auth/splash_screen.dart +++ /dev/null @@ -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 createState() => _SplashScreenState(); -} - -class _SplashScreenState extends ConsumerState { - @override - void initState() { - super.initState(); - _bootstrap(); - } - - Future _bootstrap() async { - final notifier = ref.read(authProvider.notifier); - await notifier.restoreSession(); - await Future.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), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart deleted file mode 100644 index 4031562..0000000 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ /dev/null @@ -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 createState() => _CameraScreenState(); -} - -class _CameraScreenState extends ConsumerState { - static const _previewKey = ValueKey('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>? _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 _streamVideoParams(BuildContext context) { - final portrait = MediaQuery.orientationOf(context) == Orientation.portrait; - return { - 'width': portrait ? 720 : 1280, - 'height': portrait ? 1280 : 720, - 'portrait': portrait, - }; - } - - Future _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 _bindPreviewWithRetry({int attempts = 12}) async { - for (var i = 0; i < attempts; i++) { - final ok = await StreamingChannel.bindPreview(); - if (ok) return true; - await Future.delayed(const Duration(milliseconds: 100)); - } - return false; - } - - Future _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.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.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.from(e as Map)) - .listen((event) { - if (event['type'] != 'metrics') return; - final metrics = Map.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 _readBattery() async { - final level = await Battery().batteryLevel; - if (mounted) setState(() => _batteryLevel = level); - } - - Future _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 _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 _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 _lockOrientations() async { - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - DeviceOrientation.portraitUp, - ]); - } - - Future _unlockOrientation() async { - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } - - Future _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 _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( - 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 _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 _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 _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 _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 _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 _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( - 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 Function() onPause; - final Future Function() onResume; - final Future 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 Function() onPause; - final Future Function() onResume; - final Future 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(), - ), - ), - ], - ), - ], - ), - ), - ], - ); - } -} diff --git a/mobile/lib/features/camera_mode/camera_screen_landscape.dart b/mobile/lib/features/camera_mode/camera_screen_landscape.dart deleted file mode 100644 index 7cd3320..0000000 --- a/mobile/lib/features/camera_mode/camera_screen_landscape.dart +++ /dev/null @@ -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, - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/camera_mode/camera_screen_portrait.dart b/mobile/lib/features/camera_mode/camera_screen_portrait.dart deleted file mode 100644 index a76c41a..0000000 --- a/mobile/lib/features/camera_mode/camera_screen_portrait.dart +++ /dev/null @@ -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), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/features/matches/matches_screen.dart b/mobile/lib/features/matches/matches_screen.dart deleted file mode 100644 index d974c63..0000000 --- a/mobile/lib/features/matches/matches_screen.dart +++ /dev/null @@ -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 Wi‑Fi 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 _deleteMatch( - BuildContext context, - WidgetRef ref, - MatchModel match, - ) async { - final confirmed = await showDialog( - 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 _pickScheduledMatch( - BuildContext context, - WidgetRef ref, - List 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 _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 _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( - 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 _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), - ), - ], - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/matches/new_match_sheet.dart b/mobile/lib/features/matches/new_match_sheet.dart deleted file mode 100644 index 875a552..0000000 --- a/mobile/lib/features/matches/new_match_sheet.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -enum NewMatchChoice { schedule, quickStart } - -Future showNewMatchSheet(BuildContext context) { - return showModalBottomSheet( - 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), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/matches/no_team_onboarding.dart b/mobile/lib/features/matches/no_team_onboarding.dart deleted file mode 100644 index ad91afd..0000000 --- a/mobile/lib/features/matches/no_team_onboarding.dart +++ /dev/null @@ -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); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/features/matches/resume_session_sheet.dart b/mobile/lib/features/matches/resume_session_sheet.dart deleted file mode 100644 index e86c6ba..0000000 --- a/mobile/lib/features/matches/resume_session_sheet.dart +++ /dev/null @@ -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( - 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'), - ), - ], - ), - ), - ); - }, - ); -} diff --git a/mobile/lib/features/matches/schedule_match_sheet.dart b/mobile/lib/features/matches/schedule_match_sheet.dart deleted file mode 100644 index 153c114..0000000 --- a/mobile/lib/features/matches/schedule_match_sheet.dart +++ /dev/null @@ -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 showScheduleMatchSheet( - BuildContext context, { - String? teamName, -}) { - return showModalBottomSheet( - 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 _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 dell’avversario')), - ); - 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 dall’app 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, - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/matches/select_match_sheet.dart b/mobile/lib/features/matches/select_match_sheet.dart deleted file mode 100644 index dedab7e..0000000 --- a/mobile/lib/features/matches/select_match_sheet.dart +++ /dev/null @@ -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 showSelectMatchSheet( - BuildContext context, { - required List matches, -}) { - final selectable = matches.where((m) => !m.hasActiveSession).toList(); - if (selectable.isEmpty) return Future.value(null); - - return showModalBottomSheet( - 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 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, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/matches/team_picker.dart b/mobile/lib/features/matches/team_picker.dart deleted file mode 100644 index 1426866..0000000 --- a/mobile/lib/features/matches/team_picker.dart +++ /dev/null @@ -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 _showTeamSheet( - BuildContext context, - WidgetRef ref, - List teams, - String? selectedId, - ) async { - final picked = await showModalBottomSheet( - 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); - } -} diff --git a/mobile/lib/features/matches/team_providers.dart b/mobile/lib/features/matches/team_providers.dart deleted file mode 100644 index 260bf2e..0000000 --- a/mobile/lib/features/matches/team_providers.dart +++ /dev/null @@ -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>((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>((ref) async { - final auth = ref.watch(authProvider); - if (!auth.isAuthenticated) return []; - final client = ref.read(apiClientProvider); - return client.fetchTeams(); -}); - -final selectedTeamIdProvider = - AsyncNotifierProvider(SelectedTeamIdNotifier.new); - -class SelectedTeamIdNotifier extends AsyncNotifier { - @override - Future build() => TeamSelectionStorage.load(); - - Future select(String teamId) async { - await TeamSelectionStorage.save(teamId); - state = AsyncData(teamId); - } -} - -/// Squadra attiva: preferisce la selezione salvata, altrimenti la prima disponibile. -final activeTeamProvider = FutureProvider((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((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, 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); -}); diff --git a/mobile/lib/features/setup_wizard/step_match.dart b/mobile/lib/features/setup_wizard/step_match.dart deleted file mode 100644 index ef8e65b..0000000 --- a/mobile/lib/features/setup_wizard/step_match.dart +++ /dev/null @@ -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 createState() => _StepMatchState(); -} - -class _StepMatchState extends ConsumerState { - 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 _parseRoster(String raw) { - return raw - .split(RegExp(r'[,\s]+')) - .where((s) => s.isNotEmpty) - .map(int.tryParse) - .whereType() - .toList(); - } - - Future _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 _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( - 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( - 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( - 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( - 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), - ], - ), - ); - } -} diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart deleted file mode 100644 index 5573294..0000000 --- a/mobile/lib/features/setup_wizard/step_network_test.dart +++ /dev/null @@ -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 createState() => _StepNetworkTestState(); -} - -class _StepNetworkTestState extends ConsumerState { - 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 _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.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 _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 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 _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, - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/setup_wizard/step_transmission.dart b/mobile/lib/features/setup_wizard/step_transmission.dart deleted file mode 100644 index 6429c9a..0000000 --- a/mobile/lib/features/setup_wizard/step_transmission.dart +++ /dev/null @@ -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 createState() => _StepTransmissionState(); -} - -class _StepTransmissionState extends ConsumerState { - 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 _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( - 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), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/setup_wizard/wizard_shell.dart b/mobile/lib/features/setup_wizard/wizard_shell.dart deleted file mode 100644 index 966388d..0000000 --- a/mobile/lib/features/setup_wizard/wizard_shell.dart +++ /dev/null @@ -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 createState() => _WizardShellState(); -} - -class _WizardShellState extends ConsumerState { - 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 _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), - ), - ), - ); - }), - ), - ); - } -} diff --git a/mobile/lib/features/shared/live_score_actions.dart b/mobile/lib/features/shared/live_score_actions.dart deleted file mode 100644 index 3fdf161..0000000 --- a/mobile/lib/features/shared/live_score_actions.dart +++ /dev/null @@ -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 afterPointChange( - BuildContext context, { - required String homeName, - required String awayName, - required Future 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 requestCloseSet( - BuildContext context, { - required Future 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 afterCloseSet( - BuildContext context, { - required String homeName, - required String awayName, - required Future 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(); - } - } -} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart deleted file mode 100644 index 88fc463..0000000 --- a/mobile/lib/main.dart +++ /dev/null @@ -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 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, - ), - ); - } -} diff --git a/mobile/lib/platform/streaming_channel.dart b/mobile/lib/platform/streaming_channel.dart deleted file mode 100644 index d10bba6..0000000 --- a/mobile/lib/platform/streaming_channel.dart +++ /dev/null @@ -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 startPreview() async { - await _channel.invokeMethod('startPreview'); - } - - static Future bindPreview() async { - final ok = await _channel.invokeMethod('bindPreview'); - return ok ?? false; - } - - /// Dopo rotazione: riallinea preview/stream senza fermare RTMP. - static Future syncOrientation() async { - await _channel.invokeMethod('syncOrientation'); - } - - static Future stopPreview() async { - await _channel.invokeMethod('stopPreview'); - } - - static Future 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 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 stopStream() async { - await _channel.invokeMethod('stopStream'); - } - - static Future pauseStream() async { - await _channel.invokeMethod('pauseStream'); - } - - static Future?> getMetrics() async { - final result = - await _channel.invokeMethod>('getMetrics'); - return result?.map((key, value) => MapEntry(key.toString(), value)); - } - - static EventChannel get metricsStream => _metricsChannel; -} diff --git a/mobile/lib/platform/streaming_preview.dart b/mobile/lib/platform/streaming_preview.dart deleted file mode 100644 index deffc35..0000000 --- a/mobile/lib/platform/streaming_preview.dart +++ /dev/null @@ -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), - ), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart deleted file mode 100644 index 1668a1f..0000000 --- a/mobile/lib/providers/auth_provider.dart +++ /dev/null @@ -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 { - 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 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 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 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( - (ref) => AuthNotifier(), -); diff --git a/mobile/lib/providers/match_rules_provider.dart b/mobile/lib/providers/match_rules_provider.dart deleted file mode 100644 index 7ab3c8b..0000000 --- a/mobile/lib/providers/match_rules_provider.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/scoring/match_scoring_rules.dart'; -import 'session_provider.dart'; - -final matchScoringRulesProvider = Provider((ref) { - final match = ref.watch(sessionProvider).match; - if (match == null) { - return const MatchScoringRules(setsToWin: 3); - } - return MatchScoringRules.fromMatch(match); -}); diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart deleted file mode 100644 index ccb28fd..0000000 --- a/mobile/lib/providers/score_provider.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/models/score_state.dart'; - -class ScoreNotifier extends StateNotifier { - ScoreNotifier() : super(const ScoreState()); - - void reset(ScoreState score) { - state = score; - } - - void applyRemote(ScoreState score) { - state = score; - } - - void incrementHome() { - state = state.copyWith(homePoints: state.homePoints + 1); - } - - void incrementAway() { - state = state.copyWith(awayPoints: state.awayPoints + 1); - } - - void decrementHome() { - if (state.homePoints > 0) { - state = state.copyWith(homePoints: state.homePoints - 1); - } - } - - void decrementAway() { - if (state.awayPoints > 0) { - state = state.copyWith(awayPoints: state.awayPoints - 1); - } - } - - void closeSet() { - final homeWon = state.homePoints > state.awayPoints; - final partials = List.from(state.setPartials); - if (state.homePoints > 0 || state.awayPoints > 0) { - partials.add(SetPartial( - set: state.currentSet, - home: state.homePoints, - away: state.awayPoints, - )); - } - state = state.copyWith( - homeSets: homeWon ? state.homeSets + 1 : state.homeSets, - awaySets: homeWon ? state.awaySets : state.awaySets + 1, - homePoints: 0, - awayPoints: 0, - currentSet: state.currentSet + 1, - setPartials: partials, - timeoutHome: false, - timeoutAway: false, - ); - } - - void timeoutHome() { - state = state.copyWith(timeoutHome: true); - } - - void timeoutAway() { - state = state.copyWith(timeoutAway: true); - } - - void setTimeoutHome(bool value) { - state = state.copyWith(timeoutHome: value); - } - - void setTimeoutAway(bool value) { - state = state.copyWith(timeoutAway: value); - } -} - -final scoreProvider = StateNotifierProvider( - (ref) => ScoreNotifier(), -); diff --git a/mobile/lib/providers/score_sync_provider.dart b/mobile/lib/providers/score_sync_provider.dart deleted file mode 100644 index 162a55f..0000000 --- a/mobile/lib/providers/score_sync_provider.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/api_client.dart'; -import '../shared/models/score_state.dart'; -import 'score_provider.dart'; -import 'session_provider.dart'; - -/// Coda sync HTTP: evita richieste fuori ordine e allinea l'app al DB (pagina live). -class ScoreSyncService { - ScoreSyncService(this._ref); - - final Ref _ref; - bool _busy = false; - bool _dirty = false; - DateTime? _suppressStaleRemoteUntil; - - /// Ignora broadcast WebSocket obsoleti subito dopo un push HTTP. - bool acceptCableScore(ScoreState remote) { - final until = _suppressStaleRemoteUntil; - if (until == null || DateTime.now().isAfter(until)) return true; - final local = _ref.read(scoreProvider); - return !_isClearlyBehind(remote, local); - } - - bool _isClearlyBehind(ScoreState remote, ScoreState local) { - final r = remote.homeSets * 1000 + - remote.awaySets * 1000 + - remote.homePoints + - remote.awayPoints; - final l = local.homeSets * 1000 + - local.awaySets * 1000 + - local.homePoints + - local.awayPoints; - return r < l; - } - - Future push() async { - _dirty = true; - if (_busy) return; - _busy = true; - while (_dirty) { - _dirty = false; - final sessionId = _ref.read(sessionProvider).session?.id; - if (sessionId == null) continue; - - final payload = _ref.read(scoreProvider).toCablePayload(); - try { - final remote = await _ref.read(apiClientProvider).syncScore(sessionId, payload); - _suppressStaleRemoteUntil = DateTime.now().add(const Duration(milliseconds: 500)); - if (remote != null) { - _ref.read(scoreProvider.notifier).applyRemote(remote); - } - } catch (_) { - // Mantieni stato locale; il prossimo pull periodico riallinea se possibile. - } - } - _busy = false; - } - - Future pullFromServer() async { - if (_busy) return; - final sessionId = _ref.read(sessionProvider).session?.id; - if (sessionId == null) return; - try { - final session = await _ref.read(apiClientProvider).fetchSession(sessionId); - if (session.score != null) { - _ref.read(scoreProvider.notifier).applyRemote(session.score!); - } - } catch (_) {} - } -} - -final scoreSyncProvider = Provider((ref) { - return ScoreSyncService(ref); -}); diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart deleted file mode 100644 index 1080316..0000000 --- a/mobile/lib/providers/session_provider.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/models/device_state.dart'; -import '../shared/models/match.dart'; -import '../shared/models/stream_session.dart'; - -class SessionState { - const SessionState({ - this.session, - this.match, - this.elapsed = Duration.zero, - this.connected = false, - this.cameraDevice, - this.viewerCount = 0, - }); - - final StreamSession? session; - final MatchModel? match; - final Duration elapsed; - final bool connected; - final DeviceStateModel? cameraDevice; - final int viewerCount; - - SessionState copyWith({ - StreamSession? session, - MatchModel? match, - Duration? elapsed, - bool? connected, - DeviceStateModel? cameraDevice, - int? viewerCount, - }) { - return SessionState( - session: session ?? this.session, - match: match ?? this.match, - elapsed: elapsed ?? this.elapsed, - connected: connected ?? this.connected, - cameraDevice: cameraDevice ?? this.cameraDevice, - viewerCount: viewerCount ?? this.viewerCount, - ); - } -} - -class SessionNotifier extends StateNotifier { - SessionNotifier() : super(const SessionState()); - - void setSession(StreamSession session, {MatchModel? match}) { - final prevId = state.session?.id; - state = state.copyWith( - session: session, - match: match, - elapsed: prevId != null && prevId != session.id ? Duration.zero : state.elapsed, - ); - } - - void updateYoutubeLinks({ - required String? youtubeWatchUrl, - required String? shareUrl, - String? youtubeBroadcastId, - bool youtubeReady = true, - }) { - final s = state.session; - if (s == null || s.platform != 'youtube') return; - state = state.copyWith( - session: StreamSession( - id: s.id, - matchId: s.matchId, - status: s.status, - platform: s.platform, - rtmpIngestUrl: s.rtmpIngestUrl, - hlsPlaybackUrl: s.hlsPlaybackUrl, - watchPageUrl: s.watchPageUrl, - shareUrl: shareUrl, - youtubeWatchUrl: youtubeWatchUrl, - youtubeBroadcastId: youtubeBroadcastId ?? s.youtubeBroadcastId, - youtubeReady: youtubeReady, - privacyStatus: s.privacyStatus, - qualityPreset: s.qualityPreset, - targetBitrate: s.targetBitrate, - startedAt: s.startedAt, - disconnectionCount: s.disconnectionCount, - score: s.score, - devices: s.devices, - ), - ); - } - - void setMatch(MatchModel match) { - state = state.copyWith(match: match); - } - - void setElapsed(Duration elapsed) { - state = state.copyWith(elapsed: elapsed); - } - - void setConnected(bool connected) { - state = state.copyWith(connected: connected); - } - - void setCameraDevice(DeviceStateModel? device) { - state = state.copyWith(cameraDevice: device); - } - - void setViewerCount(int count) { - state = state.copyWith(viewerCount: count); - } - - void updateDeviceState(DeviceStateModel device) { - if (device.deviceRole == 'camera') { - state = state.copyWith(cameraDevice: device); - } - if (device.status != null && state.session != null) { - final s = state.session!; - final incoming = device.status!; - // Non sovrascrivere una pausa intenzionale con stati transitori (live/connecting). - if (s.status == 'paused' && incoming != 'paused' && incoming != 'ended') { - state = state.copyWith(cameraDevice: device); - return; - } - state = state.copyWith( - session: StreamSession( - id: s.id, - matchId: s.matchId, - status: incoming, - platform: s.platform, - rtmpIngestUrl: s.rtmpIngestUrl, - hlsPlaybackUrl: s.hlsPlaybackUrl, - watchPageUrl: s.watchPageUrl, - shareUrl: s.shareUrl, - youtubeWatchUrl: s.youtubeWatchUrl, - youtubeBroadcastId: s.youtubeBroadcastId, - youtubeReady: s.youtubeReady, - privacyStatus: s.privacyStatus, - qualityPreset: s.qualityPreset, - targetBitrate: s.targetBitrate, - startedAt: s.startedAt, - disconnectionCount: s.disconnectionCount, - score: s.score, - devices: s.devices, - ), - ); - } - } - - void applyCommand(String action) { - final session = state.session; - if (session == null) return; - - String newStatus = session.status; - switch (action) { - case 'start_stream': - newStatus = 'live'; - break; - case 'pause_stream': - newStatus = 'paused'; - break; - case 'resume_stream': - newStatus = 'connecting'; - break; - case 'stop_stream': - newStatus = 'ended'; - break; - } - - if (newStatus != session.status) { - state = state.copyWith( - session: StreamSession( - id: session.id, - matchId: session.matchId, - status: newStatus, - platform: session.platform, - rtmpIngestUrl: session.rtmpIngestUrl, - hlsPlaybackUrl: session.hlsPlaybackUrl, - watchPageUrl: session.watchPageUrl, - shareUrl: session.shareUrl, - youtubeWatchUrl: session.youtubeWatchUrl, - youtubeBroadcastId: session.youtubeBroadcastId, - youtubeReady: session.youtubeReady, - privacyStatus: session.privacyStatus, - qualityPreset: session.qualityPreset, - targetBitrate: session.targetBitrate, - startedAt: session.startedAt, - disconnectionCount: session.disconnectionCount, - score: session.score, - devices: session.devices, - ), - ); - } - } - - void clear() { - state = const SessionState(); - } -} - -final sessionProvider = - StateNotifierProvider((ref) { - return SessionNotifier(); -}); diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart deleted file mode 100644 index 2072a8b..0000000 --- a/mobile/lib/shared/api_client.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../core/config.dart'; -import '../providers/auth_provider.dart'; -import 'models/invite_preview.dart'; -import 'models/match.dart'; -import 'models/recording_item.dart'; -import 'models/score_state.dart'; -import 'models/stream_session.dart'; -import 'models/team.dart'; -import 'models/user.dart'; - -final apiClientProvider = Provider((ref) { - final auth = ref.watch(authProvider); - return ApiClient(accessToken: auth.accessToken); -}); - -class ApiClient { - ApiClient({this.accessToken}) { - _dio = Dio( - BaseOptions( - baseUrl: AppConfig.apiV1, - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 30), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ), - ); - - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - if (accessToken != null && accessToken!.isNotEmpty) { - options.headers['Authorization'] = 'Bearer $accessToken'; - } - handler.next(options); - }, - ), - ); - } - - late final Dio _dio; - final String? accessToken; - - // --- Auth --- - - Future<({User user, AuthTokens tokens})> login({ - required String email, - required String password, - }) async { - final response = await _dio.post>( - '/auth/login', - data: {'email': email, 'password': password}, - ); - final data = response.data!; - return ( - user: User.fromJson(data['user'] as Map), - tokens: AuthTokens.fromJson(data), - ); - } - - Future<({User user, AuthTokens tokens})> refreshSession( - String refreshToken, - ) async { - final response = await _dio.post>( - '/auth/refresh', - data: {'refresh_token': refreshToken}, - ); - final data = response.data!; - return ( - user: User.fromJson(data['user'] as Map), - tokens: AuthTokens.fromJson(data), - ); - } - - Future me() async { - final response = await _dio.get>('/auth/me'); - return User.fromJson(response.data!); - } - - Future logout() async { - await _dio.post('/auth/logout'); - } - - // --- Teams --- - - Future> fetchTeams() async { - final response = await _dio.get>('/teams'); - return response.data! - .map((e) => Team.fromJson(e as Map)) - .toList(); - } - - Future fetchInvitePreview(String token) async { - final response = await _dio.get>('/invitations/$token'); - return InvitePreview.fromJson(response.data!); - } - - Future acceptInvitation(String token) async { - final response = await _dio.post>('/invitations/$token/accept'); - return response.data!['message'] as String? ?? 'Invito accettato'; - } - - Future youtubeAuthorizeUrl(String teamId, {bool returnApp = true}) async { - final response = await _dio.get>( - '/teams/$teamId/youtube/authorize', - queryParameters: returnApp ? {'return_app': '1'} : null, - ); - return response.data!['authorization_url'] as String; - } - - Future> fetchRecordings(String teamId) async { - final response = await _dio.get>('/teams/$teamId/recordings'); - return response.data! - .map((e) => RecordingItem.fromJson(e as Map)) - .toList(); - } - - Future fetchRecordingDownloadUrl(String recordingId) async { - final response = await _dio.get>( - '/recordings/$recordingId/download', - ); - return response.data!['download_url'] as String; - } - - // --- Matches --- - - Future> fetchMatches(String teamId) async { - final response = - await _dio.get>('/teams/$teamId/matches'); - return response.data! - .map((e) => MatchModel.fromJson(e as Map)) - .toList(); - } - - Future fetchMatch(String matchId) async { - final response = - await _dio.get>('/matches/$matchId'); - return MatchModel.fromJson(response.data!); - } - - Future updateMatch(String matchId, Map body) async { - final response = await _dio.patch>( - '/matches/$matchId', - data: {'match': body}, - ); - return MatchModel.fromJson(response.data!); - } - - Future deleteMatch(String matchId) async { - await _dio.delete('/matches/$matchId'); - } - - Future createMatch(String teamId, Map body) async { - final response = await _dio.post>( - '/teams/$teamId/matches', - data: {'match': body}, - ); - return MatchModel.fromJson(response.data!); - } - - // --- Sessions --- - - Future createSession( - String matchId, { - String platform = 'matchlivetv', - String? youtubeChannel, - String privacyStatus = 'public', - String qualityPreset = '720p_30_2.5mbps', - int? targetBitrate, - int? targetFps, - }) async { - // YouTube: creazione broadcast + path MediaMTX può richiedere 20–30s lato server. - final sessionTimeout = platform == 'youtube' - ? const Duration(seconds: 60) - : const Duration(seconds: 30); - final response = await _dio.post>( - '/matches/$matchId/sessions', - data: { - 'platform': platform, - 'privacy_status': privacyStatus, - 'quality_preset': qualityPreset, - if (youtubeChannel != null) 'youtube_channel': youtubeChannel, - if (targetBitrate != null) 'target_bitrate': targetBitrate, - if (targetFps != null) 'target_fps': targetFps, - }, - options: Options( - sendTimeout: sessionTimeout, - receiveTimeout: sessionTimeout, - ), - ); - return StreamSession.fromJson(response.data!); - } - - Future fetchSession(String sessionId) async { - final response = - await _dio.get>('/sessions/$sessionId'); - return StreamSession.fromJson(response.data!); - } - - Future startSession(String sessionId) async { - final response = - await _dio.patch>('/sessions/$sessionId/start'); - return StreamSession.fromJson(response.data!); - } - - Future stopSession(String sessionId) async { - final response = - await _dio.patch>('/sessions/$sessionId/stop'); - return StreamSession.fromJson(response.data!); - } - - Future pauseSession(String sessionId) async { - final response = - await _dio.patch>('/sessions/$sessionId/pause'); - return StreamSession.fromJson(response.data!); - } - - Future resumeSession(String sessionId) async { - final response = - await _dio.patch>('/sessions/$sessionId/resume'); - return StreamSession.fromJson(response.data!); - } - - /// Persiste il tabellone e restituisce lo stato salvato sul server. - Future syncScore(String sessionId, Map payload) async { - final response = await _dio.patch>( - '/sessions/$sessionId/score', - data: payload, - ); - final score = response.data?['score']; - if (score is Map) { - return ScoreState.fromJson(score); - } - return null; - } - - Future createRegiaLink(String sessionId) async { - final response = await _dio.post>( - '/sessions/$sessionId/regia_link', - ); - return RegiaLinkResponse.fromJson(response.data!); - } - - Future createPairingToken(String sessionId) async { - final response = await _dio.post>( - '/sessions/$sessionId/pairing_token', - ); - return PairingTokenResponse.fromJson(response.data!); - } - - Future claimPairing({ - required String sessionId, - required String pairingToken, - required String deviceRole, - }) async { - final response = await _dio.post>( - '/sessions/$sessionId/claim_pairing', - data: { - 'pairing_token': pairingToken, - 'device_role': deviceRole, - }, - ); - return StreamSession.fromJson(response.data!); - } - - Future networkTest({ - required String sessionId, - required double downloadMbps, - required double uploadMbps, - required int latencyMs, - required String networkType, - }) async { - final response = await _dio.post>( - '/sessions/$sessionId/network_test', - data: { - 'download_mbps': downloadMbps, - 'upload_mbps': uploadMbps, - 'latency_ms': latencyMs, - 'network_type': networkType, - }, - ); - return NetworkTestResult.fromJson(response.data!); - } - - Future youtubeStats(String sessionId) async { - final response = - await _dio.get>('/sessions/$sessionId/youtube_stats'); - return YoutubeStats.fromJson(response.data!); - } - - Future postTelemetry({ - required String sessionId, - required String deviceRole, - int? batteryLevel, - String? networkType, - int? signalStrength, - int? currentBitrate, - int? targetBitrate, - int? fps, - }) async { - await _dio.post( - '/sessions/$sessionId/telemetry', - data: { - 'device_role': deviceRole, - if (batteryLevel != null) 'battery_level': batteryLevel, - if (networkType != null) 'network_type': networkType, - if (signalStrength != null) 'signal_strength': signalStrength, - if (currentBitrate != null) 'current_bitrate': currentBitrate, - if (targetBitrate != null) 'target_bitrate': targetBitrate, - if (fps != null) 'fps': fps, - }, - ); - } -} diff --git a/mobile/lib/shared/api_error.dart b/mobile/lib/shared/api_error.dart deleted file mode 100644 index 63ce291..0000000 --- a/mobile/lib/shared/api_error.dart +++ /dev/null @@ -1,32 +0,0 @@ -class ApiException implements Exception { - ApiException({ - required this.message, - this.statusCode, - this.errorCode, - this.billingUrl, - }); - - final String message; - final int? statusCode; - final String? errorCode; - final String? billingUrl; - - bool get isPremiumRequired => errorCode == 'premium_required'; - - @override - String toString() => message; - - static ApiException? fromDio(dynamic error) { - // ignore: avoid_dynamic_calls - final response = error.response; - if (response == null) return null; - final data = response.data; - if (data is! Map) return null; - return ApiException( - message: data['error'] as String? ?? 'Errore di rete', - statusCode: response.statusCode as int?, - errorCode: data['error_code'] as String?, - billingUrl: data['billing_url'] as String?, - ); - } -} diff --git a/mobile/lib/shared/models/device_state.dart b/mobile/lib/shared/models/device_state.dart deleted file mode 100644 index 4642bba..0000000 --- a/mobile/lib/shared/models/device_state.dart +++ /dev/null @@ -1,37 +0,0 @@ -class DeviceStateModel { - const DeviceStateModel({ - this.deviceRole = 'camera', - this.batteryLevel, - this.networkType, - this.signalStrength, - this.currentBitrate, - this.targetBitrate, - this.fps, - this.status, - }); - - final String deviceRole; - final int? batteryLevel; - final String? networkType; - final int? signalStrength; - final int? currentBitrate; - final int? targetBitrate; - final int? fps; - final String? status; - - factory DeviceStateModel.fromJson(Map json) { - return DeviceStateModel( - deviceRole: json['device_role'] as String? ?? 'camera', - batteryLevel: json['battery'] as int? ?? json['battery_level'] as int?, - networkType: json['network'] as String? ?? json['network_type'] as String?, - signalStrength: json['signal_strength'] as int?, - currentBitrate: json['bitrate'] as int? ?? json['current_bitrate'] as int?, - targetBitrate: json['target_bitrate'] as int?, - fps: json['fps'] as int?, - status: json['status'] as String?, - ); - } - - double get bitrateMbps => - currentBitrate != null ? currentBitrate! / 1_000_000 : 0; -} diff --git a/mobile/lib/shared/models/invite_preview.dart b/mobile/lib/shared/models/invite_preview.dart deleted file mode 100644 index e4f6319..0000000 --- a/mobile/lib/shared/models/invite_preview.dart +++ /dev/null @@ -1,28 +0,0 @@ -class InvitePreview { - const InvitePreview({ - required this.valid, - required this.email, - required this.teamName, - this.clubName, - this.teamId, - this.error, - }); - - final bool valid; - final String email; - final String teamName; - final String? clubName; - final String? teamId; - final String? error; - - factory InvitePreview.fromJson(Map json) { - return InvitePreview( - valid: json['valid'] as bool? ?? false, - email: json['email'] as String? ?? '', - teamName: json['team_name'] as String? ?? '', - clubName: json['club_name'] as String?, - teamId: json['team_id'] as String?, - error: json['error'] as String?, - ); - } -} diff --git a/mobile/lib/shared/models/match.dart b/mobile/lib/shared/models/match.dart deleted file mode 100644 index 181c890..0000000 --- a/mobile/lib/shared/models/match.dart +++ /dev/null @@ -1,81 +0,0 @@ -class MatchModel { - const MatchModel({ - required this.id, - required this.teamId, - required this.teamName, - required this.opponentName, - this.location, - this.scheduledAt, - this.sport = 'volleyball', - this.setsToWin = 3, - this.scoringRules, - this.rosterNumbers = const [], - this.category, - this.phase, - this.activeSessionId, - this.activeSessionStatus, - }); - - final String id; - final String teamId; - final String teamName; - final String opponentName; - final String? location; - final DateTime? scheduledAt; - final String sport; - final int setsToWin; - final Map? scoringRules; - final List rosterNumbers; - final String? category; - final String? phase; - final String? activeSessionId; - final String? activeSessionStatus; - - /// Sessione avviata (o in ripresa) — si può rientrare in camera senza rifare il wizard. - bool get canResumeCamera { - const resumable = {'connecting', 'live', 'reconnecting', 'paused'}; - return activeSessionId != null && - activeSessionStatus != null && - resumable.contains(activeSessionStatus); - } - - bool get hasActiveSession => activeSessionId != null; - - factory MatchModel.fromJson(Map json) { - return MatchModel( - id: json['id'] as String, - teamId: json['team_id'] as String, - teamName: json['team_name'] as String? ?? '', - opponentName: json['opponent_name'] as String, - location: json['location'] as String?, - scheduledAt: json['scheduled_at'] != null - ? DateTime.tryParse(json['scheduled_at'] as String) - : null, - sport: json['sport'] as String? ?? 'volleyball', - setsToWin: json['sets_to_win'] as int? ?? 3, - scoringRules: json['scoring_rules'] is Map - ? Map.from(json['scoring_rules'] as Map) - : null, - rosterNumbers: (json['roster_numbers'] as List?) - ?.map((e) => (e as num).toInt()) - .toList() ?? - const [], - category: json['category'] as String?, - phase: json['phase'] as String?, - activeSessionId: json['active_session_id'] as String?, - activeSessionStatus: json['active_session_status'] as String?, - ); - } - - Map toJson() => { - 'opponent_name': opponentName, - 'location': location, - 'scheduled_at': scheduledAt?.toIso8601String(), - 'sport': sport, - 'sets_to_win': setsToWin, - if (scoringRules != null) 'scoring_rules': scoringRules, - 'roster_numbers': rosterNumbers, - 'category': category, - 'phase': phase, - }; -} diff --git a/mobile/lib/shared/models/recording_item.dart b/mobile/lib/shared/models/recording_item.dart deleted file mode 100644 index 0649e98..0000000 --- a/mobile/lib/shared/models/recording_item.dart +++ /dev/null @@ -1,76 +0,0 @@ -class RecordingItem { - const RecordingItem({ - required this.id, - required this.sessionId, - required this.title, - required this.opponentName, - required this.teamName, - this.status = 'ready', - this.statusLabel = 'Disponibile', - this.privacyStatus = 'unlisted', - this.endedAt, - this.recordedAt, - this.replayUrl, - this.playbackUrl, - this.thumbnailUrl, - this.durationLabel, - this.viewCount = 0, - this.viewsLabel, - this.downloadEnabled = false, - this.youtubeWatchUrl, - this.expiresAt, - }); - - final String id; - final String sessionId; - final String title; - final String opponentName; - final String teamName; - final String status; - final String statusLabel; - final String privacyStatus; - final DateTime? endedAt; - final DateTime? recordedAt; - final String? replayUrl; - final String? playbackUrl; - final String? thumbnailUrl; - final String? durationLabel; - final int viewCount; - final String? viewsLabel; - final bool downloadEnabled; - final String? youtubeWatchUrl; - final DateTime? expiresAt; - - bool get isReady => status == 'ready'; - bool get isProcessing => status == 'processing'; - - factory RecordingItem.fromJson(Map json) { - return RecordingItem( - id: json['id'] as String, - sessionId: json['session_id'] as String, - title: json['title'] as String? ?? '${json['team_name']} vs ${json['opponent_name']}', - opponentName: json['opponent_name'] as String, - teamName: json['team_name'] as String, - status: json['status'] as String? ?? 'ready', - statusLabel: json['status_label'] as String? ?? 'Disponibile', - privacyStatus: json['privacy_status'] as String? ?? 'unlisted', - endedAt: json['ended_at'] != null - ? DateTime.parse(json['ended_at'] as String) - : null, - recordedAt: json['recorded_at'] != null - ? DateTime.parse(json['recorded_at'] as String) - : null, - replayUrl: json['replay_url'] as String?, - playbackUrl: json['playback_url'] as String?, - thumbnailUrl: json['thumbnail_url'] as String?, - durationLabel: json['duration_label'] as String?, - viewCount: json['view_count'] as int? ?? 0, - viewsLabel: json['views_label'] as String?, - downloadEnabled: json['download_enabled'] as bool? ?? false, - youtubeWatchUrl: json['youtube_watch_url'] as String?, - expiresAt: json['expires_at'] != null - ? DateTime.parse(json['expires_at'] as String) - : null, - ); - } -} diff --git a/mobile/lib/shared/models/score_state.dart b/mobile/lib/shared/models/score_state.dart deleted file mode 100644 index 55cf4cf..0000000 --- a/mobile/lib/shared/models/score_state.dart +++ /dev/null @@ -1,100 +0,0 @@ -class SetPartial { - const SetPartial({ - required this.set, - required this.home, - required this.away, - }); - - final int set; - final int home; - final int away; - - factory SetPartial.fromJson(Map json) { - return SetPartial( - set: json['set'] as int? ?? 1, - home: json['home'] as int? ?? 0, - away: json['away'] as int? ?? 0, - ); - } - - Map toJson() => { - 'set': set, - 'home': home, - 'away': away, - }; -} - -class ScoreState { - const ScoreState({ - this.homeSets = 0, - this.awaySets = 0, - this.homePoints = 0, - this.awayPoints = 0, - this.currentSet = 1, - this.setPartials = const [], - this.timeoutHome = false, - this.timeoutAway = false, - }); - - final int homeSets; - final int awaySets; - final int homePoints; - final int awayPoints; - final int currentSet; - final List setPartials; - final bool timeoutHome; - final bool timeoutAway; - - factory ScoreState.fromJson(Map json) { - final rawPartials = json['set_partials']; - final partials = rawPartials is List - ? rawPartials - .whereType() - .map((e) => SetPartial.fromJson(Map.from(e))) - .toList() - : []; - - return ScoreState( - homeSets: json['home_sets'] as int? ?? 0, - awaySets: json['away_sets'] as int? ?? 0, - homePoints: json['home_points'] as int? ?? 0, - awayPoints: json['away_points'] as int? ?? 0, - currentSet: json['current_set'] as int? ?? 1, - setPartials: partials, - timeoutHome: json['timeout_home'] as bool? ?? false, - timeoutAway: json['timeout_away'] as bool? ?? false, - ); - } - - Map toCablePayload() => { - 'type': 'score_update', - 'home_sets': homeSets, - 'away_sets': awaySets, - 'home_points': homePoints, - 'away_points': awayPoints, - 'current_set': currentSet, - 'set_partials': setPartials.map((p) => p.toJson()).toList(), - }; - - ScoreState copyWith({ - int? homeSets, - int? awaySets, - int? homePoints, - int? awayPoints, - int? currentSet, - List? setPartials, - bool? timeoutHome, - bool? timeoutAway, - }) { - return ScoreState( - homeSets: homeSets ?? this.homeSets, - awaySets: awaySets ?? this.awaySets, - homePoints: homePoints ?? this.homePoints, - awayPoints: awayPoints ?? this.awayPoints, - currentSet: currentSet ?? this.currentSet, - setPartials: setPartials ?? this.setPartials, - timeoutHome: timeoutHome ?? this.timeoutHome, - timeoutAway: timeoutAway ?? this.timeoutAway, - ); - } -} diff --git a/mobile/lib/shared/models/stream_session.dart b/mobile/lib/shared/models/stream_session.dart deleted file mode 100644 index 5f97b39..0000000 --- a/mobile/lib/shared/models/stream_session.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'device_state.dart'; -import 'score_state.dart'; - -class StreamSession { - const StreamSession({ - required this.id, - required this.matchId, - required this.status, - this.platform = 'matchlivetv', - this.rtmpIngestUrl, - this.hlsPlaybackUrl, - this.watchPageUrl, - this.shareUrl, - this.youtubeWatchUrl, - this.youtubeBroadcastId, - this.youtubeReady = false, - this.privacyStatus = 'public', - this.qualityPreset = '720p_30_2.5mbps', - this.targetBitrate = 2500000, - this.startedAt, - this.disconnectionCount = 0, - this.score, - this.devices = const [], - }); - - final String id; - final String matchId; - final String status; - final String platform; - final String? rtmpIngestUrl; - final String? hlsPlaybackUrl; - final String? watchPageUrl; - final String? shareUrl; - final String? youtubeWatchUrl; - final String? youtubeBroadcastId; - final bool youtubeReady; - final String privacyStatus; - final String qualityPreset; - final int targetBitrate; - final DateTime? startedAt; - final int disconnectionCount; - final ScoreState? score; - final List devices; - - bool get isLive => status == 'live' || status == 'reconnecting'; - - bool get isTerminal => status == 'ended' || status == 'error'; - - bool get canResumeBroadcast => - status == 'connecting' || - status == 'live' || - status == 'reconnecting' || - status == 'paused'; - - factory StreamSession.fromJson(Map json) { - return StreamSession( - id: json['id'] as String, - matchId: json['match_id'] as String, - status: json['status'] as String? ?? 'idle', - platform: json['platform'] as String? ?? 'matchlivetv', - rtmpIngestUrl: json['rtmp_ingest_url'] as String?, - hlsPlaybackUrl: json['hls_playback_url'] as String?, - watchPageUrl: json['watch_page_url'] as String?, - shareUrl: json['share_url'] as String?, - youtubeWatchUrl: json['youtube_watch_url'] as String?, - youtubeBroadcastId: json['youtube_broadcast_id'] as String?, - youtubeReady: json['youtube_ready'] as bool? ?? false, - privacyStatus: json['privacy_status'] as String? ?? 'public', - qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps', - targetBitrate: json['target_bitrate'] as int? ?? 2500000, - startedAt: json['started_at'] != null - ? DateTime.tryParse(json['started_at'] as String) - : null, - disconnectionCount: json['disconnection_count'] as int? ?? 0, - score: json['score'] != null - ? ScoreState.fromJson(json['score'] as Map) - : null, - devices: (json['devices'] as List?) - ?.map((e) => DeviceStateModel.fromJson(e as Map)) - .toList() ?? - const [], - ); - } -} - -class RegiaLinkResponse { - const RegiaLinkResponse({ - required this.regiaUrl, - required this.expiresAt, - }); - - final String regiaUrl; - final DateTime expiresAt; - - factory RegiaLinkResponse.fromJson(Map json) { - return RegiaLinkResponse( - regiaUrl: json['regia_url'] as String, - expiresAt: DateTime.parse(json['expires_at'] as String), - ); - } -} - -class PairingTokenResponse { - const PairingTokenResponse({ - required this.pairingToken, - required this.expiresAt, - required this.qrPayload, - }); - - final String pairingToken; - final DateTime expiresAt; - final Map qrPayload; - - factory PairingTokenResponse.fromJson(Map json) { - return PairingTokenResponse( - pairingToken: json['pairing_token'] as String, - expiresAt: DateTime.parse(json['expires_at'] as String), - qrPayload: json['qr_payload'] as Map, - ); - } -} - -class NetworkTestResult { - const NetworkTestResult({ - required this.ready, - required this.targetUploadMbps, - }); - - final bool ready; - final double targetUploadMbps; - - factory NetworkTestResult.fromJson(Map json) { - return NetworkTestResult( - ready: json['ready'] as bool? ?? false, - targetUploadMbps: (json['target_upload_mbps'] as num?)?.toDouble() ?? 2.5, - ); - } -} - -class YoutubeStats { - const YoutubeStats({ - required this.concurrentViewers, - required this.live, - }); - - final int concurrentViewers; - final bool live; - - factory YoutubeStats.fromJson(Map json) { - return YoutubeStats( - concurrentViewers: json['concurrent_viewers'] as int? ?? 0, - live: json['live'] as bool? ?? false, - ); - } -} diff --git a/mobile/lib/shared/models/team.dart b/mobile/lib/shared/models/team.dart deleted file mode 100644 index adf0163..0000000 --- a/mobile/lib/shared/models/team.dart +++ /dev/null @@ -1,125 +0,0 @@ -class Team { - const Team({ - required this.id, - required this.name, - required this.sport, - this.logoUrl, - this.youtubeConnected = false, - this.youtubeSelectable = false, - this.youtubeChannelTitle, - this.youtubeUsesPlatformChannel = false, - this.youtubePlatformAvailable = false, - this.youtubeTeamChannelAvailable = false, - this.youtubeTeamChannelTitle, - this.planSlug = 'free', - this.planName = 'Free', - this.premiumActive = false, - this.premiumFull = false, - this.billingUrl, - this.staffManageUrl, - this.maxStaff = 2, - this.staffUsed = 0, - this.maxStaffTransmission, - this.maxStaffRegia, - this.staffTransmissionUsed = 0, - this.staffRegiaUsed = 0, - this.concurrentStreamsUsed = 0, - this.concurrentStreamsLimit, - this.recordingsEnabled = false, - this.phoneDownloadEnabled = false, - this.youtubeEnabled = false, - this.clubName, - this.staffRole, - this.canStream = true, - this.youtubeMode, - this.canConnectYoutube = false, - }); - - final String id; - final String name; - final String sport; - final String? clubName; - final String? staffRole; - final bool canStream; - final String? logoUrl; - final bool youtubeConnected; - final bool youtubeSelectable; - final String? youtubeChannelTitle; - final bool youtubeUsesPlatformChannel; - final bool youtubePlatformAvailable; - final bool youtubeTeamChannelAvailable; - final String? youtubeTeamChannelTitle; - final String planSlug; - final String planName; - final bool premiumActive; - final bool premiumFull; - final String? billingUrl; - final String? staffManageUrl; - final int maxStaff; - final int staffUsed; - final int? maxStaffTransmission; - final int? maxStaffRegia; - final int staffTransmissionUsed; - final int staffRegiaUsed; - final int concurrentStreamsUsed; - final int? concurrentStreamsLimit; - final bool recordingsEnabled; - final bool phoneDownloadEnabled; - final bool youtubeEnabled; - final String? youtubeMode; - final bool canConnectYoutube; - - bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light'); - - bool get isYoutubeReady => youtubeSelectable; - - bool get canChooseYoutubeChannel => - youtubePlatformAvailable && youtubeTeamChannelAvailable; - bool get canUseRecordings => recordingsEnabled; - bool get canDownloadOnPhone => phoneDownloadEnabled; - - String get staffLimitLabel { - if (maxStaffTransmission == null) { - return 'trasmissione illimitata'; - } - return 'trasmissione $staffTransmissionUsed/$maxStaffTransmission'; - } - - factory Team.fromJson(Map json) { - return Team( - id: json['id'] as String, - name: json['name'] as String, - sport: json['sport'] as String? ?? 'volleyball', - clubName: json['club_name'] as String?, - staffRole: json['staff_role'] as String?, - canStream: json['can_stream'] as bool? ?? true, - logoUrl: json['logo_url'] as String?, - youtubeConnected: json['youtube_connected'] as bool? ?? false, - youtubeSelectable: json['youtube_selectable'] as bool? ?? false, - youtubeChannelTitle: json['youtube_channel_title'] as String?, - youtubeUsesPlatformChannel: json['youtube_uses_platform_channel'] as bool? ?? false, - youtubePlatformAvailable: json['youtube_platform_available'] as bool? ?? false, - youtubeTeamChannelAvailable: json['youtube_team_channel_available'] as bool? ?? false, - youtubeTeamChannelTitle: json['youtube_team_channel_title'] as String?, - planSlug: json['plan_slug'] as String? ?? 'free', - planName: json['plan_name'] as String? ?? 'Free', - premiumActive: json['premium_active'] as bool? ?? false, - premiumFull: json['premium_full'] as bool? ?? false, - billingUrl: json['billing_url'] as String?, - staffManageUrl: json['staff_manage_url'] as String?, - maxStaff: json['max_staff'] as int? ?? 2, - staffUsed: json['staff_used'] as int? ?? 0, - maxStaffTransmission: json['max_staff_transmission'] as int?, - maxStaffRegia: json['max_staff_regia'] as int?, - staffTransmissionUsed: json['staff_transmission_used'] as int? ?? 0, - staffRegiaUsed: json['staff_regia_used'] as int? ?? 0, - concurrentStreamsUsed: json['concurrent_streams_used'] as int? ?? 0, - concurrentStreamsLimit: json['concurrent_streams_limit'] as int?, - recordingsEnabled: json['recordings_enabled'] as bool? ?? false, - phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false, - youtubeEnabled: json['youtube_enabled'] as bool? ?? false, - youtubeMode: json['youtube_mode'] as String?, - canConnectYoutube: json['can_connect_youtube'] as bool? ?? false, - ); - } -} diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart deleted file mode 100644 index 7f35f05..0000000 --- a/mobile/lib/shared/models/user.dart +++ /dev/null @@ -1,46 +0,0 @@ -class User { - const User({ - required this.id, - required this.email, - required this.name, - required this.role, - }); - - final String id; - final String email; - final String name; - final String role; - - factory User.fromJson(Map json) { - return User( - id: json['id'] as String, - email: json['email'] as String, - name: json['name'] as String, - role: json['role'] as String? ?? 'coach', - ); - } - - Map toJson() => { - 'id': id, - 'email': email, - 'name': name, - 'role': role, - }; -} - -class AuthTokens { - const AuthTokens({ - required this.accessToken, - required this.refreshToken, - }); - - final String accessToken; - final String refreshToken; - - factory AuthTokens.fromJson(Map json) { - return AuthTokens( - accessToken: json['access_token'] as String, - refreshToken: json['refresh_token'] as String, - ); - } -} diff --git a/mobile/lib/shared/premium_dialog.dart b/mobile/lib/shared/premium_dialog.dart deleted file mode 100644 index 169f946..0000000 --- a/mobile/lib/shared/premium_dialog.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../app/theme.dart'; - -Future showPremiumRequiredDialog( - BuildContext context, { - required String message, - String? billingUrl, -}) async { - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - title: const Text('Premium richiesto'), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Chiudi'), - ), - if (billingUrl != null && billingUrl.isNotEmpty) - FilledButton( - onPressed: () async { - final uri = Uri.parse(billingUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - if (ctx.mounted) Navigator.pop(ctx); - }, - style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed), - child: const Text('Attiva sul sito'), - ), - ], - ), - ); -} diff --git a/mobile/lib/shared/regia_share.dart b/mobile/lib/shared/regia_share.dart deleted file mode 100644 index 9e53457..0000000 --- a/mobile/lib/shared/regia_share.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:share_plus/share_plus.dart'; - -import 'api_client.dart'; - -Future shareRegiaLink( - BuildContext context, - WidgetRef ref, { - required String sessionId, - required String shareSubject, -}) async { - try { - final link = await ref.read(apiClientProvider).createRegiaLink(sessionId); - await Share.share( - 'Apri questo link per gestire il punteggio della diretta:\n${link.regiaUrl}', - subject: shareSubject, - ); - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Errore generazione link regia')), - ); - } - } -} diff --git a/mobile/lib/shared/scoring/match_scoring_rules.dart b/mobile/lib/shared/scoring/match_scoring_rules.dart deleted file mode 100644 index b01f5d6..0000000 --- a/mobile/lib/shared/scoring/match_scoring_rules.dart +++ /dev/null @@ -1,70 +0,0 @@ -import '../models/match.dart'; - -/// Lato che ha vinto set o partita. -enum ScoringSide { home, away } - -/// Regole punteggio (pallavolo di default, campi sovrascrivibili per tornei). -class MatchScoringRules { - const MatchScoringRules({ - required this.setsToWin, - this.pointsPerSet = 25, - this.pointsDecidingSet = 15, - this.minPointLead = 2, - }); - - final int setsToWin; - final int pointsPerSet; - final int pointsDecidingSet; - final int minPointLead; - - int get maxSets => (setsToWin * 2) - 1; - - factory MatchScoringRules.fromMatch(MatchModel match) { - final overrides = match.scoringRules; - return MatchScoringRules( - setsToWin: match.setsToWin, - pointsPerSet: overrides?['points_per_set'] as int? ?? 25, - pointsDecidingSet: overrides?['points_deciding_set'] as int? ?? 15, - minPointLead: overrides?['min_point_lead'] as int? ?? 2, - ); - } - - Map toJson() => { - 'points_per_set': pointsPerSet, - 'points_deciding_set': pointsDecidingSet, - 'min_point_lead': minPointLead, - }; - - /// Punti necessari per chiudere il set corrente. - int pointsTarget(int currentSet) { - if (currentSet >= maxSets) return pointsDecidingSet; - return pointsPerSet; - } - - /// Chi ha vinto il set in corso in base ai punti (null se ancora aperto). - ScoringSide? setWinnerFromPoints({ - required int homePoints, - required int awayPoints, - required int currentSet, - }) { - final target = pointsTarget(currentSet); - final lead = minPointLead; - if (homePoints >= target && homePoints - awayPoints >= lead) { - return ScoringSide.home; - } - if (awayPoints >= target && awayPoints - homePoints >= lead) { - return ScoringSide.away; - } - return null; - } - - bool isMatchOver({required int homeSets, required int awaySets}) { - return homeSets >= setsToWin || awaySets >= setsToWin; - } - - ScoringSide? matchWinner({required int homeSets, required int awaySets}) { - if (homeSets >= setsToWin) return ScoringSide.home; - if (awaySets >= setsToWin) return ScoringSide.away; - return null; - } -} diff --git a/mobile/lib/shared/watch_share.dart b/mobile/lib/shared/watch_share.dart deleted file mode 100644 index 570d2b9..0000000 --- a/mobile/lib/shared/watch_share.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:share_plus/share_plus.dart'; - -import 'models/stream_session.dart'; - -String? watchShareUrl(StreamSession session) { - if (session.platform == 'youtube') { - return session.youtubeWatchUrl ?? session.shareUrl; - } - return session.shareUrl ?? session.watchPageUrl; -} - -String watchShareLabel(StreamSession session) { - if (session.platform == 'youtube') { - return 'Guarda la diretta su YouTube'; - } - return 'Guarda la diretta su Match Live TV'; -} - -Future shareWatchLink( - BuildContext context, { - required StreamSession session, - required String title, -}) async { - final url = watchShareUrl(session); - if (url == null || url.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Link diretta non ancora disponibile')), - ); - } - return; - } - await Share.share( - '${watchShareLabel(session)}:\n$url', - subject: title, - ); -} - -Future copyWatchLink(BuildContext context, StreamSession session) async { - final url = watchShareUrl(session); - if (url == null || url.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Link diretta non ancora disponibile')), - ); - } - return; - } - await Clipboard.setData(ClipboardData(text: url)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Link copiato')), - ); - } -} diff --git a/mobile/lib/shared/websocket_service.dart b/mobile/lib/shared/websocket_service.dart deleted file mode 100644 index a8795ff..0000000 --- a/mobile/lib/shared/websocket_service.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:x_action_cable_v2/x_action_cable.dart'; - -import '../core/config.dart'; -import '../providers/auth_provider.dart'; -import '../providers/score_provider.dart'; -import '../providers/score_sync_provider.dart'; -import '../providers/session_provider.dart'; -import 'models/device_state.dart'; -import 'models/score_state.dart'; - -final websocketServiceProvider = Provider((ref) { - final service = WebsocketService(ref); - ref.onDispose(service.dispose); - return service; -}); - -typedef CableMessageHandler = void Function(Map data); - -class WebsocketService { - WebsocketService(this._ref); - - final Ref _ref; - ActionCable? _cable; - ActionChannel? _channel; - String? _sessionId; - Future? _connectOp; - - bool get isConnected => _cable != null && _channel != null; - - Future connect({ - required String sessionId, - required String deviceRole, - }) async { - if (_sessionId == sessionId && isConnected) return; - - final prev = _connectOp; - if (prev != null) await prev.catchError((_) {}); - - _connectOp = _connect(sessionId: sessionId, deviceRole: deviceRole); - final op = _connectOp!; - try { - await op; - } finally { - if (identical(_connectOp, op)) { - _connectOp = null; - } - } - } - - Future _connect({ - required String sessionId, - required String deviceRole, - }) async { - await disconnect(); - - final token = _ref.read(authProvider).accessToken; - if (token == null || token.isEmpty) { - throw StateError('Token di accesso mancante'); - } - - _sessionId = sessionId; - - _cable = ActionCable.connect( - AppConfig.cableUrl, - headers: {'Authorization': 'Bearer $token'}, - onConnected: () { - _ref.read(sessionProvider.notifier).setConnected(true); - }, - onConnectionLost: () { - _ref.read(sessionProvider.notifier).setConnected(false); - }, - onCannotConnect: (_) { - _ref.read(sessionProvider.notifier).setConnected(false); - }, - ); - - _channel = _cable!.subscribe( - 'SessionChannel', - channelParams: { - 'session_id': sessionId, - 'device_role': deviceRole, - }, - onSubscribed: () { - _ref.read(sessionProvider.notifier).setConnected(true); - }, - onDisconnected: () { - _ref.read(sessionProvider.notifier).setConnected(false); - }, - callbacks: [ - ActionCallback( - name: 'receive_message', - callback: _handleMessage, - ), - ], - ); - } - - void _handleMessage(ActionResponse response) { - if (response.hasError || response.data == null) return; - _dispatch(Map.from(response.data!)); - } - - void _dispatch(Map data) { - final type = data['type'] as String?; - - switch (type) { - case 'score_update': - final remote = ScoreState.fromJson(data); - if (_ref.read(scoreSyncProvider).acceptCableScore(remote)) { - _ref.read(scoreProvider.notifier).applyRemote(remote); - } - break; - case 'device_state': - _ref - .read(sessionProvider.notifier) - .updateDeviceState(DeviceStateModel.fromJson(data)); - break; - case 'timeout': - final team = data['team'] as String?; - if (team == 'home') { - _ref.read(scoreProvider.notifier).setTimeoutHome(true); - } else if (team == 'away') { - _ref.read(scoreProvider.notifier).setTimeoutAway(true); - } - break; - case 'command': - final action = data['action'] as String?; - if (action != null) { - _ref.read(sessionProvider.notifier).applyCommand(action); - } - break; - case 'stream_event': - final event = data['event'] as String?; - if (event == 'paused') { - _ref.read(sessionProvider.notifier).applyCommand('pause_stream'); - } else if (event == 'resumed') { - _ref.read(sessionProvider.notifier).applyCommand('resume_stream'); - } - break; - case 'youtube_ready': - _ref.read(sessionProvider.notifier).updateYoutubeLinks( - youtubeWatchUrl: data['youtube_watch_url'] as String?, - shareUrl: data['share_url'] as String?, - youtubeBroadcastId: data['youtube_broadcast_id'] as String?, - ); - break; - default: - break; - } - } - - void sendScoreUpdate(ScoreState score) { - _perform({'type': 'score_update', ...score.toCablePayload()}); - } - - void sendTimeout(String team) { - _perform({'type': 'timeout', 'team': team}); - } - - void sendCommand(String action) { - _perform({'type': 'command', 'action': action}); - } - - void sendDeviceState(DeviceStateModel state) { - _perform({ - 'type': 'device_state', - 'device_role': state.deviceRole, - 'battery': state.batteryLevel, - 'network': state.networkType, - 'bitrate': state.currentBitrate, - 'fps': state.fps, - }); - } - - void _perform(Map payload) { - _channel?.performAction('receive', params: payload); - } - - Future disconnect() async { - final channel = _channel; - final cable = _cable; - _channel = null; - _cable = null; - _sessionId = null; - - try { - channel?.unsubscribe(); - } catch (_) {} - try { - cable?.disconnect(); - } catch (_) {} - - await Future.delayed(const Duration(milliseconds: 150)); - _ref.read(sessionProvider.notifier).setConnected(false); - } - - void dispose() { - disconnect(); - } - - String encodeQrPayload(Map payload) => jsonEncode(payload); -} diff --git a/mobile/lib/shared/widgets/camera_compact_metrics.dart b/mobile/lib/shared/widgets/camera_compact_metrics.dart deleted file mode 100644 index e36d0c2..0000000 --- a/mobile/lib/shared/widgets/camera_compact_metrics.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Riga compatta metriche stream (sostituisce le MetricCard ingombranti in camera). -class CameraCompactMetrics extends StatelessWidget { - const CameraCompactMetrics({ - super.key, - required this.networkType, - required this.bitrateMbps, - required this.fps, - this.batteryLevel, - this.viewerCount, - this.light = false, - }); - - final String networkType; - final double bitrateMbps; - final int fps; - final int? batteryLevel; - final int? viewerCount; - final bool light; - - @override - Widget build(BuildContext context) { - final color = light ? Colors.white70 : Theme.of(context).textTheme.bodySmall?.color; - final parts = [ - networkType, - '${bitrateMbps.toStringAsFixed(1)} Mbps', - '$fps fps', - if (batteryLevel != null) '$batteryLevel%', - if (viewerCount != null && viewerCount! > 0) '$viewerCount spett.', - ]; - return Text( - parts.join(' · '), - style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } -} diff --git a/mobile/lib/shared/widgets/connected_badge.dart b/mobile/lib/shared/widgets/connected_badge.dart deleted file mode 100644 index 931332c..0000000 --- a/mobile/lib/shared/widgets/connected_badge.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Dot verde + etichetta COLLEGATO. -class ConnectedBadge extends StatelessWidget { - const ConnectedBadge({ - super.key, - this.connected = true, - this.label, - }); - - final bool connected; - final String? label; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = connected ? AppTheme.successGreen : AppTheme.textSecondary; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - label ?? (connected ? 'COLLEGATO' : 'DISCONNESSO'), - style: theme.textTheme.labelLarge?.copyWith( - color: color, - fontSize: 12, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/destructive_cta.dart b/mobile/lib/shared/widgets/destructive_cta.dart deleted file mode 100644 index 24f5f66..0000000 --- a/mobile/lib/shared/widgets/destructive_cta.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// CTA distruttiva nera con icona rossa (INTERROMPI, FERMA TRASMISSIONE). -class DestructiveCta extends StatelessWidget { - const DestructiveCta({ - super.key, - required this.label, - required this.onPressed, - this.loading = false, - this.compact = false, - }); - - final String label; - final VoidCallback? onPressed; - final bool loading; - final bool compact; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: compact ? null : double.infinity, - height: compact ? 44 : 52, - child: OutlinedButton( - onPressed: loading ? null : onPressed, - style: OutlinedButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: AppTheme.primaryRed, - side: BorderSide( - color: AppTheme.primaryRed.withValues(alpha: 0.6), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(compact ? 10 : 12), - ), - ), - child: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppTheme.primaryRed, - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max, - children: [ - const Icon(Icons.stop_circle_outlined, size: 20), - const SizedBox(width: 8), - Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: AppTheme.primaryRed, - fontSize: compact ? 12 : 14, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/shared/widgets/end_stream_dialog.dart b/mobile/lib/shared/widgets/end_stream_dialog.dart deleted file mode 100644 index 69610ca..0000000 --- a/mobile/lib/shared/widgets/end_stream_dialog.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Conferma chiusura definitiva della diretta (non riprendibile). -Future confirmEndStream(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - title: const Text('Chiudi diretta'), - content: const Text( - 'La diretta verrà chiusa definitivamente. ' - 'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n' - 'Per una pausa temporanea usa «Metti in pausa».', - ), - 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('Chiudi definitivamente'), - ), - ], - ), - ); - return result == true; -} diff --git a/mobile/lib/shared/widgets/live_badge.dart b/mobile/lib/shared/widgets/live_badge.dart deleted file mode 100644 index fd9ccd4..0000000 --- a/mobile/lib/shared/widgets/live_badge.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Pill rossa con dot pulsante e timer diretta. -class LiveBadge extends StatelessWidget { - const LiveBadge({ - super.key, - required this.elapsed, - this.compact = false, - this.paused = false, - }); - - final Duration elapsed; - final bool compact; - final bool paused; - - String _format(Duration d) { - final h = d.inHours; - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - if (h > 0) return '$h:$m:$s'; - return '$m:$s'; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bg = paused ? const Color(0xFF555555) : AppTheme.primaryRed; - final label = paused - ? (compact ? 'PAUSA ${_format(elapsed)}' : 'IN PAUSA ${_format(elapsed)}') - : (compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}'); - return Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 14, - vertical: compact ? 4 : 6, - ), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!paused) - Container( - width: compact ? 6 : 8, - height: compact ? 6 : 8, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - ), - if (!paused) SizedBox(width: compact ? 6 : 8), - Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: Colors.white, - fontSize: compact ? 11 : 13, - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/shared/widgets/live_score_controls.dart b/mobile/lib/shared/widgets/live_score_controls.dart deleted file mode 100644 index e5176f2..0000000 --- a/mobile/lib/shared/widgets/live_score_controls.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../app/theme.dart'; -import '../../features/shared/live_score_actions.dart'; -import '../../providers/score_provider.dart'; -import '../../providers/score_sync_provider.dart'; -import 'score_overlay_bar.dart'; - -/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia). -class LiveScoreControls extends ConsumerWidget { - const LiveScoreControls({ - super.key, - required this.homeName, - required this.awayName, - this.compact = false, - this.lastDelta, - this.pointsTarget, - this.onStopStream, - }); - - final String homeName; - final String awayName; - final bool compact; - final int? lastDelta; - final int? pointsTarget; - final Future Function()? onStopStream; - - void _sync(WidgetRef ref) { - // HTTP serializzato; il server fa broadcast WebSocket. Pagina live = status.json. - unawaited(ref.read(scoreSyncProvider).push()); - } - - Future _pointHome(BuildContext context, WidgetRef ref) async { - ref.read(scoreProvider.notifier).incrementHome(); - _sync(ref); - if (!context.mounted) return; - await LiveScoreActions(ref).afterPointChange( - context, - homeName: homeName, - awayName: awayName, - onCloseSetConfirmed: () => _closeSet(context, ref), - ); - } - - Future _pointAway(BuildContext context, WidgetRef ref) async { - ref.read(scoreProvider.notifier).incrementAway(); - _sync(ref); - if (!context.mounted) return; - await LiveScoreActions(ref).afterPointChange( - context, - homeName: homeName, - awayName: awayName, - onCloseSetConfirmed: () => _closeSet(context, ref), - ); - } - - Future _closeSet(BuildContext context, WidgetRef ref) async { - await LiveScoreActions(ref).requestCloseSet( - context, - onCloseSetConfirmed: () async { - ref.read(scoreProvider.notifier).closeSet(); - _sync(ref); - if (!context.mounted) return; - await LiveScoreActions(ref).afterCloseSet( - context, - homeName: homeName, - awayName: awayName, - onStopStream: onStopStream ?? () async {}, - ); - }, - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final score = ref.watch(scoreProvider); - final target = pointsTarget ?? 25; - final btnStyle = compact - ? const ButtonStyle( - padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10, vertical: 6)), - minimumSize: WidgetStatePropertyAll(Size(44, 32)), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ) - : null; - - Widget teamControls({ - required String label, - required int points, - required VoidCallback onPlus, - required VoidCallback onMinus, - }) { - return Expanded( - child: Column( - children: [ - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: compact ? Colors.white70 : null, - fontSize: compact ? 10 : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - '$points', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: compact ? Colors.white : AppTheme.primaryRed, - fontWeight: FontWeight.bold, - fontSize: compact ? 22 : null, - ), - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton( - style: btnStyle?.merge(FilledButton.styleFrom( - backgroundColor: AppTheme.primaryRed, - foregroundColor: Colors.white, - )), - onPressed: onPlus, - child: Text(compact ? '+1' : '+1 punto', style: TextStyle(fontSize: compact ? 12 : 13)), - ), - const SizedBox(width: 6), - OutlinedButton( - style: btnStyle, - onPressed: onMinus, - child: Text('−', style: TextStyle(fontSize: compact ? 16 : 18)), - ), - ], - ), - ], - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ScoreOverlayBar( - homeName: homeName, - awayName: awayName, - homePoints: score.homePoints, - awayPoints: score.awayPoints, - currentSet: score.currentSet, - homeSets: score.homeSets, - awaySets: score.awaySets, - compact: compact, - lastDelta: lastDelta, - pointsTarget: pointsTarget, - ), - SizedBox(height: compact ? 8 : 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - teamControls( - label: homeName, - points: score.homePoints, - onPlus: () => _pointHome(context, ref), - onMinus: () { - ref.read(scoreProvider.notifier).decrementHome(); - _sync(ref); - }, - ), - Padding( - padding: EdgeInsets.only(top: compact ? 18 : 24), - child: Text( - '$target pt', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: compact ? Colors.white54 : AppTheme.textSecondary, - ), - ), - ), - teamControls( - label: awayName, - points: score.awayPoints, - onPlus: () => _pointAway(context, ref), - onMinus: () { - ref.read(scoreProvider.notifier).decrementAway(); - _sync(ref); - }, - ), - ], - ), - SizedBox(height: compact ? 6 : 10), - OutlinedButton( - style: compact - ? OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - padding: const EdgeInsets.symmetric(vertical: 8), - ) - : null, - onPressed: () => _closeSet(context, ref), - child: Text(compact ? 'Chiudi set' : 'CHIUDI SET'), - ), - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/match_live_wordmark.dart b/mobile/lib/shared/widgets/match_live_wordmark.dart deleted file mode 100644 index ce76a26..0000000 --- a/mobile/lib/shared/widgets/match_live_wordmark.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Wordmark orizzontale: MATCH + pill LIVE + TV. -class MatchLiveWordmark extends StatelessWidget { - const MatchLiveWordmark({ - super.key, - this.compact = false, - this.showSlogan = false, - }); - - final bool compact; - final bool showSlogan; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final matchStyle = theme.textTheme.displaySmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w900, - letterSpacing: 1, - ); - final tvStyle = theme.textTheme.displaySmall?.copyWith( - color: AppTheme.textSecondary, - fontWeight: FontWeight.w900, - letterSpacing: 1, - ); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text('MATCH', style: matchStyle), - const SizedBox(width: 8), - Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 8 : 12, - vertical: compact ? 2 : 4, - ), - decoration: BoxDecoration( - color: AppTheme.primaryRed, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - 'LIVE', - style: theme.textTheme.labelLarge?.copyWith( - color: Colors.white, - fontSize: compact ? 14 : 18, - ), - ), - ), - const SizedBox(width: 8), - Text('TV', style: tvStyle), - ], - ), - if (showSlogan) ...[ - const SizedBox(height: 12), - Text( - 'LO STREAMING CHE NON MUORE', - style: theme.textTheme.labelLarge?.copyWith( - color: AppTheme.textSecondary, - fontSize: 12, - letterSpacing: 2, - ), - ), - ], - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/metric_card.dart b/mobile/lib/shared/widgets/metric_card.dart deleted file mode 100644 index 613bd0c..0000000 --- a/mobile/lib/shared/widgets/metric_card.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Card metrica singola (segnale, Mbps, fps). -class MetricCard extends StatelessWidget { - const MetricCard({ - super.key, - required this.label, - required this.value, - this.icon, - this.highlight = false, - this.expand = true, - }); - - final String label; - final String value; - final IconData? icon; - final bool highlight; - final bool expand; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final card = Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - decoration: BoxDecoration( - color: AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(10), - border: highlight - ? Border.all(color: AppTheme.successGreen.withValues(alpha: 0.5)) - : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 18, color: AppTheme.textSecondary), - const SizedBox(height: 4), - ], - Text( - value, - style: theme.textTheme.titleLarge?.copyWith( - color: highlight ? AppTheme.successGreen : Colors.white, - fontWeight: FontWeight.w900, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - Text( - label, - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11), - textAlign: TextAlign.center, - ), - ], - ), - ); - - if (!expand) { - return SizedBox(width: 72, child: card); - } - - return Expanded(child: card); - } -} diff --git a/mobile/lib/shared/widgets/primary_cta.dart b/mobile/lib/shared/widgets/primary_cta.dart deleted file mode 100644 index ad916fd..0000000 --- a/mobile/lib/shared/widgets/primary_cta.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// CTA primaria rossa full-width (AVANTI, INIZIA). -class PrimaryCta extends StatelessWidget { - const PrimaryCta({ - super.key, - required this.label, - required this.onPressed, - this.loading = false, - this.icon, - }); - - final String label; - final VoidCallback? onPressed; - final bool loading; - final IconData? icon; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: loading ? null : onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryRed, - foregroundColor: Colors.white, - disabledBackgroundColor: AppTheme.primaryRed.withValues(alpha: 0.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, - ), - child: loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w800, - letterSpacing: 1, - ), - ), - if (icon != null) ...[ - const SizedBox(width: 8), - Icon(icon, size: 20), - ], - ], - ), - ), - ); - } -} diff --git a/mobile/lib/shared/widgets/score_outcome_dialogs.dart b/mobile/lib/shared/widgets/score_outcome_dialogs.dart deleted file mode 100644 index e1de669..0000000 --- a/mobile/lib/shared/widgets/score_outcome_dialogs.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; -import '../scoring/match_scoring_rules.dart'; - -Future showSetWonDialog( - BuildContext context, { - required ScoringSide winner, - required String homeName, - required String awayName, - required int homePoints, - required int awayPoints, -}) async { - final winnerName = winner == ScoringSide.home ? homeName : awayName; - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - title: const Text('Set concluso'), - content: Text( - '$winnerName vince il set $homePoints-$awayPoints.\n\nChiudere il set e passare al successivo?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Continua a segnare'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom(backgroundColor: AppTheme.accentYellow), - child: const Text('Chiudi set', style: TextStyle(color: Colors.black)), - ), - ], - ), - ); - return result == true; -} - -Future showCloseSetAnywayDialog(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - title: const Text('Chiudi set'), - content: const Text( - 'Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Annulla'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Chiudi comunque'), - ), - ], - ), - ); - return result == true; -} - -Future showMatchWonDialog( - BuildContext context, { - required ScoringSide winner, - required String homeName, - required String awayName, - required int homeSets, - required int awaySets, -}) async { - final winnerName = winner == ScoringSide.home ? homeName : awayName; - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - title: const Text('Partita terminata'), - content: Text( - '$winnerName vince la partita ($homeSets-$awaySets set).\n\nChiudere definitivamente la diretta?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Continua in onda'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed), - child: const Text('Chiudi diretta'), - ), - ], - ), - ); - return result == true; -} diff --git a/mobile/lib/shared/widgets/score_overlay_bar.dart b/mobile/lib/shared/widgets/score_overlay_bar.dart deleted file mode 100644 index ac9a5c6..0000000 --- a/mobile/lib/shared/widgets/score_overlay_bar.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; - -/// Barra overlay punteggio: SET n | HOME x - AWAY y. -class ScoreOverlayBar extends StatelessWidget { - const ScoreOverlayBar({ - super.key, - required this.homeName, - required this.awayName, - required this.homePoints, - required this.awayPoints, - required this.currentSet, - this.homeSets, - this.awaySets, - this.compact = false, - this.lastDelta, - this.pointsTarget, - }); - - final String homeName; - final String awayName; - final int homePoints; - final int awayPoints; - final int currentSet; - final int? homeSets; - final int? awaySets; - final bool compact; - final int? lastDelta; - final int? pointsTarget; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bg = Colors.black.withValues(alpha: 0.72); - - final bar = Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 14, - vertical: compact ? 6 : 8, - ), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(compact ? 8 : 10), - border: Border.all(color: Colors.white12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - pointsTarget != null ? 'SET $currentSet → $pointsTarget' : 'SET $currentSet', - style: theme.textTheme.labelLarge?.copyWith( - color: AppTheme.accentYellow, - fontSize: compact ? 11 : 13, - ), - ), - Container( - width: 1, - height: compact ? 14 : 18, - margin: const EdgeInsets.symmetric(horizontal: 10), - color: Colors.white24, - ), - Text( - homeName.toUpperCase(), - style: theme.textTheme.labelLarge?.copyWith( - fontSize: compact ? 11 : 13, - ), - ), - const SizedBox(width: 6), - Text( - '$homePoints', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - fontSize: compact ? 16 : 20, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - '-', - style: theme.textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ), - Text( - '$awayPoints', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - fontSize: compact ? 16 : 20, - ), - ), - const SizedBox(width: 6), - Text( - awayName.toUpperCase(), - style: theme.textTheme.labelLarge?.copyWith( - fontSize: compact ? 11 : 13, - ), - ), - if (homeSets != null && awaySets != null) ...[ - const SizedBox(width: 10), - Text( - '($homeSets-$awaySets)', - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11), - ), - ], - if (lastDelta != null && lastDelta != 0) ...[ - const SizedBox(width: 8), - Text( - lastDelta! > 0 ? '+$lastDelta' : '$lastDelta', - style: theme.textTheme.labelLarge?.copyWith( - color: lastDelta! > 0 ? AppTheme.successGreen : AppTheme.primaryRed, - fontSize: compact ? 11 : 13, - ), - ), - ], - ], - ), - ); - - return LayoutBuilder( - builder: (context, constraints) { - if (!constraints.hasBoundedWidth) { - return bar; - } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: bar, - ); - }, - ); - } -} diff --git a/mobile/lib/shared/widgets/screen_insets.dart b/mobile/lib/shared/widgets/screen_insets.dart deleted file mode 100644 index 2d5e283..0000000 --- a/mobile/lib/shared/widgets/screen_insets.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; - -/// Padding di sistema (status bar, notch, barra navigazione) con minimo configurabile. -class ScreenInsets { - ScreenInsets._(); - - static EdgeInsets of(BuildContext context, {double minimum = 8}) { - final mq = MediaQuery.of(context); - final padding = mq.padding; - final view = mq.viewPadding; - return EdgeInsets.only( - left: math.max(math.max(padding.left, view.left), minimum), - top: math.max(math.max(padding.top, view.top), minimum), - right: math.max(math.max(padding.right, view.right), minimum), - bottom: math.max(math.max(padding.bottom, view.bottom), minimum), - ); - } - - /// Padding per contenuti scrollabili (login, wizard, elenco partite). - /// Overlay camera: in landscape su Android la barra nav può stare a sinistra. - static EdgeInsets cameraOverlay(BuildContext context, {double minimum = 12}) { - final base = of(context, minimum: minimum); - final landscape = - MediaQuery.orientationOf(context) == Orientation.landscape; - if (landscape && base.left < 48) { - return base.copyWith(left: 48); - } - return base; - } - - static EdgeInsets contentPadding( - BuildContext context, { - double horizontal = 20, - double vertical = 16, - double minimum = 8, - }) { - final inset = of(context, minimum: minimum); - return EdgeInsets.fromLTRB( - math.max(inset.left, horizontal), - math.max(inset.top, vertical), - math.max(inset.right, horizontal), - math.max(inset.bottom, vertical), - ); - } -} - -/// SafeArea con padding minimo su tutti i lati (gestisce edge-to-edge Android). -class AppSafeArea extends StatelessWidget { - const AppSafeArea({ - super.key, - required this.child, - this.minimum = 8, - this.top = true, - this.bottom = true, - this.left = true, - this.right = true, - }); - - final Widget child; - final double minimum; - final bool top; - final bool bottom; - final bool left; - final bool right; - - @override - Widget build(BuildContext context) { - return SafeArea( - minimum: EdgeInsets.only( - left: left ? minimum : 0, - top: top ? minimum : 0, - right: right ? minimum : 0, - bottom: bottom ? minimum : 0, - ), - top: top, - bottom: bottom, - left: left, - right: right, - child: child, - ); - } -} diff --git a/mobile/linux/.gitignore b/mobile/linux/.gitignore deleted file mode 100644 index d3896c9..0000000 --- a/mobile/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/mobile/linux/CMakeLists.txt b/mobile/linux/CMakeLists.txt deleted file mode 100644 index 3cb0f5d..0000000 --- a/mobile/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "match_live_tv") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.matchlivetv.match_live_tv") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/mobile/linux/flutter/CMakeLists.txt b/mobile/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd016..0000000 --- a/mobile/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 3792af4..0000000 --- a/mobile/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) gtk_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); - gtk_plugin_register_with_registrar(gtk_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/mobile/linux/flutter/generated_plugin_registrant.h b/mobile/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47..0000000 --- a/mobile/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/linux/flutter/generated_plugins.cmake b/mobile/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 21d8f8b..0000000 --- a/mobile/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,26 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - gtk - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/mobile/linux/runner/CMakeLists.txt b/mobile/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc..0000000 --- a/mobile/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/mobile/linux/runner/main.cc b/mobile/linux/runner/main.cc deleted file mode 100644 index e7c5c54..0000000 --- a/mobile/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/mobile/linux/runner/my_application.cc b/mobile/linux/runner/my_application.cc deleted file mode 100644 index cc32559..0000000 --- a/mobile/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "match_live_tv"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "match_live_tv"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/mobile/linux/runner/my_application.h b/mobile/linux/runner/my_application.h deleted file mode 100644 index db16367..0000000 --- a/mobile/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/mobile/macos/.gitignore b/mobile/macos/.gitignore deleted file mode 100644 index 746adbb..0000000 --- a/mobile/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/mobile/macos/Flutter/Flutter-Debug.xcconfig b/mobile/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/mobile/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/Flutter-Release.xcconfig b/mobile/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/mobile/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 29cf942..0000000 --- a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import app_links -import battery_plus -import connectivity_plus -import mobile_scanner -import package_info_plus -import share_plus -import shared_preferences_foundation -import url_launcher_macos -import wakelock_plus - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) - BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) - ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) -} diff --git a/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index b82ced3..0000000 --- a/mobile/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,729 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* match_live_tv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "match_live_tv.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* match_live_tv.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* match_live_tv.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - 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_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 34d04f3..0000000 --- a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c1761..0000000 --- a/mobile/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f..0000000 --- a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eb..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb5722..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e7..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632c..0000000 Binary files a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/mobile/macos/Runner/Base.lproj/MainMenu.xib b/mobile/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a..0000000 --- a/mobile/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mobile/macos/Runner/Configs/AppInfo.xcconfig b/mobile/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index e97a26c..0000000 --- a/mobile/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = match_live_tv - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.matchlivetv. All rights reserved. diff --git a/mobile/macos/Runner/Configs/Debug.xcconfig b/mobile/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd9..0000000 --- a/mobile/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Release.xcconfig b/mobile/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f49..0000000 --- a/mobile/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Warnings.xcconfig b/mobile/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf4..0000000 --- a/mobile/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/mobile/macos/Runner/DebugProfile.entitlements b/mobile/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index dddb8a3..0000000 --- a/mobile/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - diff --git a/mobile/macos/Runner/Info.plist b/mobile/macos/Runner/Info.plist deleted file mode 100644 index 4789daa..0000000 --- a/mobile/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb..0000000 --- a/mobile/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/mobile/macos/Runner/Release.entitlements b/mobile/macos/Runner/Release.entitlements deleted file mode 100644 index 852fa1a..0000000 --- a/mobile/macos/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1..0000000 --- a/mobile/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -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. - } - -} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml deleted file mode 100644 index 0f22541..0000000 --- a/mobile/pubspec.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: match_live_tv -description: Match Live TV - Lo streaming che non muore -publish_to: 'none' -version: 1.2.10+13 - -environment: - sdk: ^3.12.0 - -dependencies: - flutter: - sdk: flutter - cupertino_icons: ^1.0.8 - flutter_riverpod: ^2.6.1 - go_router: ^14.8.1 - dio: ^5.8.0+1 - x_action_cable_v2: ^1.0.0 - connectivity_plus: ^6.1.4 - battery_plus: ^6.2.1 - wakelock_plus: ^1.3.2 - permission_handler: ^11.4.0 - google_fonts: ^6.2.1 - intl: ^0.20.2 - qr_flutter: ^4.1.0 - mobile_scanner: ^6.0.10 - shared_preferences: ^2.5.3 - url_launcher: ^6.3.1 - share_plus: ^10.1.4 - app_links: ^6.4.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - -flutter: - uses-material-design: true diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart deleted file mode 100644 index 6e46190..0000000 --- a/mobile/test/widget_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:match_live_tv/shared/widgets/live_badge.dart'; -import 'package:match_live_tv/shared/widgets/match_live_wordmark.dart'; - -void main() { - testWidgets('MatchLiveWordmark renders', (tester) async { - await tester.pumpWidget( - const MaterialApp(home: Scaffold(body: MatchLiveWordmark())), - ); - expect(find.text('MATCH'), findsOneWidget); - expect(find.text('LIVE'), findsOneWidget); - }); - - testWidgets('LiveBadge shows timer', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold(body: LiveBadge(elapsed: Duration(minutes: 45, seconds: 35))), - ), - ); - expect(find.textContaining('IN DIRETTA'), findsOneWidget); - }); -} diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png deleted file mode 100644 index 8aaa46a..0000000 Binary files a/mobile/web/favicon.png and /dev/null differ diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png deleted file mode 100644 index b749bfe..0000000 Binary files a/mobile/web/icons/Icon-192.png and /dev/null differ diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48..0000000 Binary files a/mobile/web/icons/Icon-512.png and /dev/null differ diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d7..0000000 Binary files a/mobile/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c566..0000000 Binary files a/mobile/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/mobile/web/index.html b/mobile/web/index.html deleted file mode 100644 index be80a12..0000000 --- a/mobile/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - match_live_tv - - - - - - - diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json deleted file mode 100644 index 49cc9de..0000000 --- a/mobile/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "match_live_tv", - "short_name": "match_live_tv", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/mobile/windows/.gitignore b/mobile/windows/.gitignore deleted file mode 100644 index d492d0d..0000000 --- a/mobile/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/mobile/windows/CMakeLists.txt b/mobile/windows/CMakeLists.txt deleted file mode 100644 index b0419b6..0000000 --- a/mobile/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(match_live_tv LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "match_live_tv") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/mobile/windows/flutter/CMakeLists.txt b/mobile/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f489..0000000 --- a/mobile/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index df8aa87..0000000 --- a/mobile/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,29 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - AppLinksPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("AppLinksPluginCApi")); - BatteryPlusWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); - ConnectivityPlusWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - SharePlusWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); -} diff --git a/mobile/windows/flutter/generated_plugin_registrant.h b/mobile/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d8..0000000 --- a/mobile/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 861f51c..0000000 --- a/mobile/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,30 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - app_links - battery_plus - connectivity_plus - permission_handler_windows - share_plus - url_launcher_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/mobile/windows/runner/CMakeLists.txt b/mobile/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c..0000000 --- a/mobile/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/mobile/windows/runner/Runner.rc b/mobile/windows/runner/Runner.rc deleted file mode 100644 index 960b12c..0000000 --- a/mobile/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.matchlivetv" "\0" - VALUE "FileDescription", "match_live_tv" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "match_live_tv" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.matchlivetv. All rights reserved." "\0" - VALUE "OriginalFilename", "match_live_tv.exe" "\0" - VALUE "ProductName", "match_live_tv" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/mobile/windows/runner/flutter_window.cpp b/mobile/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee30..0000000 --- a/mobile/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/mobile/windows/runner/flutter_window.h b/mobile/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652..0000000 --- a/mobile/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/mobile/windows/runner/main.cpp b/mobile/windows/runner/main.cpp deleted file mode 100644 index 6373396..0000000 --- a/mobile/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"match_live_tv", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/mobile/windows/runner/resource.h b/mobile/windows/runner/resource.h deleted file mode 100644 index 66a65d1..0000000 --- a/mobile/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/mobile/windows/runner/resources/app_icon.ico b/mobile/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20c..0000000 Binary files a/mobile/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/mobile/windows/runner/runner.exe.manifest b/mobile/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e..0000000 --- a/mobile/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/mobile/windows/runner/utils.cpp b/mobile/windows/runner/utils.cpp deleted file mode 100644 index 3cb7146..0000000 --- a/mobile/windows/runner/utils.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - // First, find the length of the string with a safe upper bound (CWE-126). - // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. - int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); - // Now use that bounded length to determine the required buffer size. - // When an explicit length is passed, WideCharToMultiByte does not include - // the null terminator in its returned size. - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/mobile/windows/runner/utils.h b/mobile/windows/runner/utils.h deleted file mode 100644 index 3879d54..0000000 --- a/mobile/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/mobile/windows/runner/win32_window.cpp b/mobile/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0..0000000 --- a/mobile/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/mobile/windows/runner/win32_window.h b/mobile/windows/runner/win32_window.h deleted file mode 100644 index e901dde..0000000 --- a/mobile/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/native/android/README.md b/native/android/README.md index 9ef3060..5a80dc0 100644 --- a/native/android/README.md +++ b/native/android/README.md @@ -1,6 +1,6 @@ # Match Live TV — app Android nativa -Implementazione **da zero** in Kotlin + Jetpack Compose. Non riusa codice dalla app Flutter (`mobile/`). +Implementazione **da zero** in Kotlin + Jetpack Compose. ## Stack @@ -39,4 +39,4 @@ API default: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...`). ## iOS (prossimo passo su Mac) -Il backend REST/WebSocket è condiviso. Su Mac creeremo `native/ios/` con SwiftUI e AVFoundation/RTMP equivalente, riusando gli stessi contratti API documentati in `mobile/lib/shared/api_client.dart` come riferimento funzionale (non codice). +Il backend REST/WebSocket è condiviso. Su Mac creeremo `native/ios/` con SwiftUI e AVFoundation/RTMP equivalente, riusando gli stessi contratti API del backend. diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 80e51c3..0f3f341 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -18,6 +18,7 @@ android { val apiBaseUrl = project.findProperty("API_BASE_URL") as String? ?: "https://www.matchlivetv.it" buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -72,4 +73,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("com.github.pedroSG94.RootEncoder:library:2.5.5") + + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test:core:1.6.1") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") } diff --git a/native/android/app/proguard-rules.pro b/native/android/app/proguard-rules.pro index 5ca49b7..fb2d2e0 100644 --- a/native/android/app/proguard-rules.pro +++ b/native/android/app/proguard-rules.pro @@ -2,3 +2,37 @@ -keep class com.pedro.** { *; } -dontwarn com.pedro.** -dontwarn org.slf4j.** + +# --- Retrofit + OkHttp + Moshi (release minify) --- +-keepattributes Signature, InnerClasses, EnclosingMethod, Exceptions, *Annotation* + +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +-keep,allowobfuscation,allowshrinking class retrofit2.Response + +-keep interface com.matchlivetv.match_live_tv.data.api.MatchLiveApi { *; } + +-keep class com.matchlivetv.match_live_tv.data.api.** { *; } +-keepclassmembers class com.matchlivetv.match_live_tv.data.api.** { + (...); +} + +-keep class kotlin.Metadata { *; } + +-keep class com.squareup.moshi.** { *; } +-keep @com.squareup.moshi.JsonQualifier interface * +-keepclassmembers class * { + @com.squareup.moshi.FromJson ; + @com.squareup.moshi.ToJson ; + @com.squareup.moshi.Json ; +} + +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* +-dontwarn okhttp3.internal.platform.ConscryptPlatform +-dontwarn org.conscrypt.ConscryptHostnameVerifier diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt new file mode 100644 index 0000000..fafb977 --- /dev/null +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt @@ -0,0 +1,163 @@ +package com.matchlivetv.match_live_tv + +import android.content.Intent +import android.view.KeyEvent +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta. + */ +@RunWith(AndroidJUnit4::class) +class E2EWizardFlowTest { + private lateinit var device: UiDevice + private val pkg = "com.matchlivetv.match_live_tv" + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + grantRuntimePermissions() + launchApp() + } + + @Test + fun login_newMatch_wizard_reachesBroadcastScreen() { + waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000) + fillLogin() + waitForText("NUOVA PARTITA", timeoutMs = 45_000) + tapClickableText("NUOVA PARTITA") + waitForText("Avvia subito", timeoutMs = 15_000) + tapClickableText("Avvia subito") + waitForText("01 · Partita", timeoutMs = 45_000) + scrollDown() + tapClickableText("AVANTI >") + waitForText("02 · Trasmissione", timeoutMs = 45_000) + waitForText("Piattaforma", timeoutMs = 30_000) + scrollDown() + tapClickableText("AVANTI >") + waitForText("03 · Test rete", timeoutMs = 45_000) + waitForText("AVVIA TEST RETE", timeoutMs = 30_000) + tapClickableText("AVVIA TEST RETE") + waitForText("INIZIA >", timeoutMs = 30_000) + waitUntilEnabled("INIZIA >", timeoutMs = 25_000) + scrollDown() + tapClickableText("INIZIA >") + val diretta = waitForText("Diretta", timeoutMs = 60_000) + assertNotNull(diretta) + assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000)) + assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000)) + } + + private fun grantRuntimePermissions() { + listOf( + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.POST_NOTIFICATIONS", + ).forEach { permission -> + device.executeShellCommand("pm grant $pkg $permission") + } + } + + private fun launchApp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + } ?: error("Launch intent mancante per $pkg") + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000) + } + + private fun fillLogin() { + val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000) + if (fields.size < 2) error("Campi login non trovati (${fields.size})") + pasteIntoField(fields[0], "coach@matchlivetv.test") + pasteIntoField(fields[1], "password123") + device.pressKeyCode(KeyEvent.KEYCODE_ENTER) + device.waitForIdle() + } + + private fun pasteIntoField(field: UiObject2, value: String) { + field.click() + device.waitForIdle() + field.clear() + field.text = value + device.waitForIdle() + } + + private fun waitForText(text: String, timeoutMs: Long): UiObject2 { + val obj = device.wait(Until.findObject(By.text(text)), timeoutMs) + assertNotNull("Testo non trovato entro ${timeoutMs}ms: $text", obj) + return obj!! + } + + private fun waitForAnyText(vararg texts: String, timeoutMs: Long) { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (SystemClock.elapsedRealtime() < deadline) { + for (text in texts) { + if (device.hasObject(By.text(text))) return + } + SystemClock.sleep(250) + } + error("Nessuno dei testi trovato: ${texts.joinToString()}") + } + + private fun tapClickableText(text: String) { + device.findObject(By.text(text).clickable(true))?.let { node -> + node.click() + device.waitForIdle() + return + } + val label = device.wait(Until.findObject(By.text(text)), 15_000) + ?: error("Testo non trovato: $text") + var node: UiObject2? = label + for (i in 0 until 6) { + val current = node ?: break + if (current.isClickable) { + current.click() + device.waitForIdle() + return + } + node = current.parent + } + label.click() + device.waitForIdle() + } + + private fun waitUntilEnabled(text: String, timeoutMs: Long) { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (SystemClock.elapsedRealtime() < deadline) { + val label = device.findObject(By.text(text)) + if (label != null) { + var node: UiObject2? = label + for (i in 0 until 6) { + val current = node ?: break + if (current.isClickable && current.isEnabled) return + node = current.parent + } + } + SystemClock.sleep(500) + } + error("Pulsante non abilitato entro timeout: $text") + } + + private fun scrollDown(steps: Int = 2) { + val centerX = device.displayWidth / 2 + val startY = (device.displayHeight * 0.75).toInt() + val endY = (device.displayHeight * 0.25).toInt() + repeat(steps) { + device.swipe(centerX, startY, centerX, endY, 24) + device.waitForIdle() + SystemClock.sleep(300) + } + } +} diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt new file mode 100644 index 0000000..8095c55 --- /dev/null +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt @@ -0,0 +1,43 @@ +package com.matchlivetv.match_live_tv + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.matchlivetv.match_live_tv.data.AppContainer +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReleaseApiSmokeTest { + private lateinit var container: AppContainer + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + container = AppContainer(context) + } + + @Test + fun login_parsesResponse() = runBlocking { + val session = container.authRepository.login( + email = "coach@matchlivetv.test", + password = "password123", + ) + assertEquals("coach@matchlivetv.test", session.user.email) + assertTrue(session.accessToken.isNotBlank()) + } + + @Test + fun fetchMatches_afterLogin() = runBlocking { + container.authRepository.login( + email = "coach@matchlivetv.test", + password = "password123", + ) + val matches = container.matchRepository.fetchMatches() + assertTrue(matches.isNotEmpty()) + } +} diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml index bedeaaf..4545d0b 100644 --- a/native/android/app/src/main/AndroidManifest.xml +++ b/native/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -16,7 +17,7 @@ "WiFi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "4G" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" + else -> "Sconosciuto" + } + }.getOrDefault("Sconosciuto") +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt index 99744ce..351fb14 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -4,13 +4,19 @@ import android.content.Context import com.matchlivetv.match_live_tv.core.AppConfig import com.matchlivetv.match_live_tv.core.TokenStore import com.matchlivetv.match_live_tv.data.api.MatchLiveApi +import com.matchlivetv.match_live_tv.data.cable.SessionCableService import com.matchlivetv.match_live_tv.data.repository.AuthRepository import com.matchlivetv.match_live_tv.data.repository.MatchRepository +import com.matchlivetv.match_live_tv.data.repository.ScoreRepository import com.matchlivetv.match_live_tv.data.repository.SessionRepository +import com.matchlivetv.match_live_tv.data.scoring.ScoreController import com.matchlivetv.match_live_tv.BuildConfig import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Dispatchers import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -69,6 +75,16 @@ class AppContainer(context: Context) { val sessionRepository = SessionRepository(api) + val scoreRepository = ScoreRepository(api) + + val sessionCable = SessionCableService(moshi) + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + val scoreController = ScoreController(scoreRepository, sessionCable, appScope) + + val wizardSession = WizardSessionHolder() + val broadcastCoordinator = LiveBroadcastCoordinator(appContext) suspend fun bootstrapAuth() { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/WizardSessionHolder.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/WizardSessionHolder.kt new file mode 100644 index 0000000..ed55991 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/WizardSessionHolder.kt @@ -0,0 +1,20 @@ +package com.matchlivetv.match_live_tv.data + +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.StreamSession + +/** Stato condiviso del wizard setup (equivalente a sessionProvider Flutter). */ +class WizardSessionHolder { + var match: Match? = null + var session: StreamSession? = null + + fun setSession(value: StreamSession, matchValue: Match? = null) { + session = value + matchValue?.let { match = it } + } + + fun clear() { + match = null + session = null + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index 6f7ec01..371262a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.api import com.matchlivetv.match_live_tv.domain.AuthTokens import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.Recording import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.User @@ -34,7 +35,15 @@ data class TeamDto( val name: String, val sport: String? = null, @Json(name = "can_stream") val canStream: Boolean? = true, + @Json(name = "club_name") val clubName: String? = null, @Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false, + @Json(name = "youtube_connected") val youtubeConnected: Boolean? = false, + @Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false, + @Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null, + @Json(name = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null, + @Json(name = "plan_name") val planName: String? = null, + @Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false, + @Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false, @Json(name = "billing_url") val billingUrl: String? = null, @Json(name = "staff_manage_url") val staffManageUrl: String? = null, ) { @@ -43,7 +52,15 @@ data class TeamDto( name = name, sport = sport ?: "volleyball", canStream = canStream ?: true, + clubName = clubName, youtubeEnabled = youtubeEnabled ?: false, + youtubeConnected = youtubeConnected ?: false, + youtubeSelectable = youtubeSelectable ?: false, + youtubeChannelTitle = youtubeChannelTitle, + youtubeTeamChannelTitle = youtubeTeamChannelTitle, + planName = planName, + recordingsEnabled = recordingsEnabled ?: false, + phoneDownloadEnabled = phoneDownloadEnabled ?: false, billingUrl = billingUrl, staffManageUrl = staffManageUrl, ) @@ -56,6 +73,10 @@ data class MatchDto( @Json(name = "opponent_name") val opponentName: String, val location: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null, + @Json(name = "sets_to_win") val setsToWin: Int? = null, + val category: String? = null, + val phase: String? = null, + @Json(name = "roster_numbers") val rosterNumbers: List? = null, @Json(name = "active_session_id") val activeSessionId: String? = null, @Json(name = "active_session_status") val activeSessionStatus: String? = null, ) { @@ -66,6 +87,10 @@ data class MatchDto( opponentName = opponentName, location = location, scheduledAt = scheduledAt, + setsToWin = setsToWin ?: 3, + category = category, + phase = phase, + rosterNumbers = rosterNumbers.orEmpty(), activeSessionId = activeSessionId, activeSessionStatus = activeSessionStatus, ) @@ -80,9 +105,13 @@ data class StreamSessionDto( @Json(name = "hls_playback_url") val hlsPlaybackUrl: String? = null, @Json(name = "watch_page_url") val watchPageUrl: String? = null, @Json(name = "share_url") val shareUrl: String? = null, + @Json(name = "youtube_watch_url") val youtubeWatchUrl: String? = null, + @Json(name = "youtube_ready") val youtubeReady: Boolean? = null, + @Json(name = "privacy_status") val privacyStatus: String? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_fps") val targetFps: Int? = null, @Json(name = "started_at") val startedAt: String? = null, + val score: ScoreStateDto? = null, ) { fun toDomain() = StreamSession( id = id, @@ -93,21 +122,150 @@ data class StreamSessionDto( hlsPlaybackUrl = hlsPlaybackUrl, watchPageUrl = watchPageUrl, shareUrl = shareUrl, + youtubeWatchUrl = youtubeWatchUrl, + youtubeReady = youtubeReady ?: false, + privacyStatus = privacyStatus ?: "public", targetBitrate = targetBitrate ?: 2_500_000, targetFps = targetFps ?: 30, startedAt = startedAt, + score = score?.toDomain(), ) } +data class SetPartialDto( + val set: Int? = null, + val home: Int? = null, + val away: Int? = null, +) + +data class ScoreStateDto( + @Json(name = "home_sets") val homeSets: Int? = null, + @Json(name = "away_sets") val awaySets: Int? = null, + @Json(name = "home_points") val homePoints: Int? = null, + @Json(name = "away_points") val awayPoints: Int? = null, + @Json(name = "current_set") val currentSet: Int? = null, + @Json(name = "set_partials") val setPartials: List? = null, + @Json(name = "timeout_home") val timeoutHome: Boolean? = null, + @Json(name = "timeout_away") val timeoutAway: Boolean? = null, +) { + fun toDomain() = com.matchlivetv.match_live_tv.domain.ScoreState( + homeSets = homeSets ?: 0, + awaySets = awaySets ?: 0, + homePoints = homePoints ?: 0, + awayPoints = awayPoints ?: 0, + currentSet = currentSet ?: 1, + setPartials = setPartials.orEmpty().map { + com.matchlivetv.match_live_tv.domain.SetPartial(it.set ?: 1, it.home ?: 0, it.away ?: 0) + }, + timeoutHome = timeoutHome ?: false, + timeoutAway = timeoutAway ?: false, + ) +} + +data class ScoreSyncRequest( + @Json(name = "home_sets") val homeSets: Int, + @Json(name = "away_sets") val awaySets: Int, + @Json(name = "home_points") val homePoints: Int, + @Json(name = "away_points") val awayPoints: Int, + @Json(name = "current_set") val currentSet: Int, + @Json(name = "set_partials") val setPartials: List, +) + data class CreateSessionRequest( val platform: String = "matchlivetv", @Json(name = "privacy_status") val privacyStatus: String = "public", @Json(name = "quality_preset") val qualityPreset: String = "720p_30_2.5mbps", @Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000, @Json(name = "target_fps") val targetFps: Int = 30, + @Json(name = "youtube_channel") val youtubeChannel: String? = null, ) -data class CreateMatchRequest(val match: Map) +data class UpdateMatchBody( + @Json(name = "opponent_name") val opponentName: String? = null, + val location: String? = null, + @Json(name = "scheduled_at") val scheduledAt: String? = null, + @Json(name = "sets_to_win") val setsToWin: Int? = null, + val category: String? = null, + val phase: String? = null, + @Json(name = "roster_numbers") val rosterNumbers: List? = null, +) + +data class UpdateMatchRequest(val match: UpdateMatchBody) + +data class ScoringRulesBody( + @Json(name = "points_per_set") val pointsPerSet: Int, + @Json(name = "points_deciding_set") val pointsDecidingSet: Int, + @Json(name = "min_point_lead") val minPointLead: Int, +) + +data class UpdateMatchBodyFull( + @Json(name = "opponent_name") val opponentName: String, + val location: String? = null, + @Json(name = "scheduled_at") val scheduledAt: String? = null, + @Json(name = "sets_to_win") val setsToWin: Int, + val category: String? = null, + val phase: String? = null, + @Json(name = "roster_numbers") val rosterNumbers: List? = null, + @Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null, +) + +data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull) + +data class NetworkTestResponse( + val ready: Boolean, + @Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null, +) + +data class RecordingDto( + val id: String, + val title: String? = null, + @Json(name = "opponent_name") val opponentName: String? = null, + @Json(name = "team_name") val teamName: String? = null, + val status: String? = null, + @Json(name = "status_label") val statusLabel: String? = null, + @Json(name = "recorded_at") val recordedAt: String? = null, + @Json(name = "ended_at") val endedAt: String? = null, + @Json(name = "duration_label") val durationLabel: String? = null, + @Json(name = "views_label") val viewsLabel: String? = null, + @Json(name = "replay_url") val replayUrl: String? = null, + @Json(name = "thumbnail_url") val thumbnailUrl: String? = null, + @Json(name = "download_enabled") val downloadEnabled: Boolean? = null, + @Json(name = "youtube_watch_url") val youtubeWatchUrl: String? = null, + @Json(name = "expires_at") val expiresAt: String? = null, + @Json(name = "title_or_default") val titleOrDefault: String? = null, +) { + fun toDomain() = Recording( + id = id, + title = title ?: titleOrDefault ?: "Replay", + opponentName = opponentName.orEmpty(), + teamName = teamName.orEmpty(), + status = status ?: "processing", + statusLabel = statusLabel ?: status.orEmpty(), + recordedAt = recordedAt, + endedAt = endedAt, + durationLabel = durationLabel, + viewsLabel = viewsLabel, + replayUrl = replayUrl, + thumbnailUrl = thumbnailUrl, + downloadEnabled = downloadEnabled ?: false, + youtubeWatchUrl = youtubeWatchUrl, + expiresAt = expiresAt, + ) +} + +data class RegiaLinkResponse( + @Json(name = "regia_url") val regiaUrl: String, +) + +data class CreateMatchBody( + @Json(name = "opponent_name") val opponentName: String, + val sport: String = "volleyball", + @Json(name = "sets_to_win") val setsToWin: Int = 3, + @Json(name = "scheduled_at") val scheduledAt: String? = null, + val location: String? = null, +) + +data class CreateMatchRequest(val match: CreateMatchBody) data class NetworkTestRequest( @Json(name = "download_mbps") val downloadMbps: Double, @@ -115,3 +273,13 @@ data class NetworkTestRequest( @Json(name = "latency_ms") val latencyMs: Int, @Json(name = "network_type") val networkType: String, ) + +data class TelemetryRequest( + @Json(name = "device_role") val deviceRole: String, + @Json(name = "battery_level") val batteryLevel: Int? = null, + @Json(name = "network_type") val networkType: String? = null, + @Json(name = "signal_strength") val signalStrength: Int? = null, + @Json(name = "current_bitrate") val currentBitrate: Int? = null, + @Json(name = "target_bitrate") val targetBitrate: Int? = null, + val fps: Int? = null, +) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index 44a6849..fe2479d 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -1,6 +1,7 @@ package com.matchlivetv.match_live_tv.data.api import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.POST @@ -22,12 +23,30 @@ interface MatchLiveApi { @GET("teams") suspend fun teams(): List + @GET("teams/{teamId}/recordings") + suspend fun recordings(@Path("teamId") teamId: String): List + @GET("teams/{teamId}/matches") suspend fun matches(@Path("teamId") teamId: String): List + @POST("teams/{teamId}/matches") + suspend fun createMatch( + @Path("teamId") teamId: String, + @Body body: CreateMatchRequest, + ): MatchDto + @GET("matches/{matchId}") suspend fun match(@Path("matchId") matchId: String): MatchDto + @PATCH("matches/{matchId}") + suspend fun updateMatch( + @Path("matchId") matchId: String, + @Body body: UpdateMatchRequestFull, + ): MatchDto + + @DELETE("matches/{matchId}") + suspend fun deleteMatch(@Path("matchId") matchId: String) + @POST("matches/{matchId}/sessions") suspend fun createSession( @Path("matchId") matchId: String, @@ -53,5 +72,23 @@ interface MatchLiveApi { suspend fun networkTest( @Path("sessionId") sessionId: String, @Body body: NetworkTestRequest, + ): NetworkTestResponse + + @POST("sessions/{sessionId}/regia_link") + suspend fun regiaLink(@Path("sessionId") sessionId: String): RegiaLinkResponse + + @PATCH("sessions/{sessionId}/score") + suspend fun syncScore( + @Path("sessionId") sessionId: String, + @Body body: ScoreSyncRequest, + ): StreamSessionDto + + @POST("sessions/{sessionId}/telemetry") + suspend fun postTelemetry( + @Path("sessionId") sessionId: String, + @Body body: TelemetryRequest, ) + + @GET("recordings/{recordingId}/download") + suspend fun recordingDownload(@Path("recordingId") recordingId: String): Map } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt new file mode 100644 index 0000000..5ffc9c2 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt @@ -0,0 +1,141 @@ +package com.matchlivetv.match_live_tv.data.cable + +import android.util.Log +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class ActionCableClient( + private val cableUrl: String, + private val accessToken: String, + moshi: Moshi, +) { + interface Listener { + fun onConnected() {} + fun onDisconnected() {} + fun onChannelMessage(payload: Map) {} + } + + private val envelopeAdapter: JsonAdapter> = + moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + + private val client = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + private var webSocket: WebSocket? = null + private var channelIdentifier: String? = null + private var listener: Listener? = null + + fun connect( + channel: String, + params: Map, + listener: Listener, + ) { + disconnect() + this.listener = listener + channelIdentifier = JSONObject().apply { + put("channel", channel) + params.forEach { (key, value) -> put(key, value) } + }.toString() + + val request = Request.Builder() + .url(cableUrl) + .header("Authorization", "Bearer $accessToken") + .build() + + webSocket = client.newWebSocket( + request, + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + sendSubscribe() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleIncoming(text) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + listener.onDisconnected() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.w(TAG, "ActionCable failure", t) + listener.onDisconnected() + } + }, + ) + } + + fun performReceive(payload: Map) { + val identifier = channelIdentifier ?: return + val dataJson = JSONObject(payload).apply { + put("action", "receive") + }.toString() + send( + mapOf( + "command" to "message", + "identifier" to identifier, + "data" to dataJson, + ), + ) + } + + fun disconnect() { + webSocket?.close(1000, "bye") + webSocket = null + channelIdentifier = null + listener = null + } + + private fun sendSubscribe() { + val identifier = channelIdentifier ?: return + send(mapOf("command" to "subscribe", "identifier" to identifier)) + } + + private fun send(payload: Map) { + val json = envelopeAdapter.toJson(payload) + webSocket?.send(json) + } + + private fun handleIncoming(raw: String) { + val json = runCatching { JSONObject(raw) }.getOrNull() ?: return + when (json.optString("type")) { + "ping" -> { + val message = json.opt("message") + webSocket?.send("""{"type":"pong","message":$message}""") + } + "confirm_subscription" -> listener?.onConnected() + "welcome" -> Unit + else -> { + if (json.has("message")) { + @Suppress("UNCHECKED_CAST") + val message = json.opt("message") + when (message) { + is JSONObject -> listener?.onChannelMessage(messageToMap(message)) + is Map<*, *> -> listener?.onChannelMessage(message as Map) + } + } + } + } + } + + private fun messageToMap(json: JSONObject): Map { + val map = mutableMapOf() + json.keys().forEach { key -> + map[key] = json.get(key) + } + return map + } + + companion object { + private const val TAG = "ActionCableClient" + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt new file mode 100644 index 0000000..ab10c52 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt @@ -0,0 +1,86 @@ +package com.matchlivetv.match_live_tv.data.cable + +import com.matchlivetv.match_live_tv.core.AppConfig +import com.matchlivetv.match_live_tv.domain.ScoreState +import com.squareup.moshi.Moshi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SessionCableService(private val moshi: Moshi) { + private var client: ActionCableClient? = null + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() + + var onScoreUpdate: ((ScoreState) -> Unit)? = null + var onPauseStream: (() -> Unit)? = null + var onResumeStream: (() -> Unit)? = null + + fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") { + disconnect() + client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable -> + cable.connect( + channel = "SessionChannel", + params = mapOf( + "session_id" to sessionId, + "device_role" to deviceRole, + ), + listener = object : ActionCableClient.Listener { + override fun onConnected() { + _connected.value = true + } + + override fun onDisconnected() { + _connected.value = false + } + + override fun onChannelMessage(payload: Map) { + dispatch(payload) + } + }, + ) + } + } + + fun sendScoreUpdate(score: ScoreState) { + client?.performReceive(score.cablePayload()) + } + + fun disconnect() { + client?.disconnect() + client = null + _connected.value = false + } + + private fun dispatch(data: Map) { + when (data["type"] as? String) { + "score_update" -> onScoreUpdate?.invoke(parseScore(data)) + "stream_event" -> when (data["event"] as? String) { + "paused" -> onPauseStream?.invoke() + "resumed" -> onResumeStream?.invoke() + } + } + } + + private fun parseScore(data: Map): ScoreState { + @Suppress("UNCHECKED_CAST") + val partialsRaw = data["set_partials"] as? List> ?: emptyList() + return ScoreState( + homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0, + awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0, + homePoints = (data["home_points"] as? Number)?.toInt() ?: 0, + awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0, + currentSet = (data["current_set"] as? Number)?.toInt() ?: 1, + setPartials = partialsRaw.map { + com.matchlivetv.match_live_tv.domain.SetPartial( + set = (it["set"] as? Number)?.toInt() ?: 1, + home = (it["home"] as? Number)?.toInt() ?: 0, + away = (it["away"] as? Number)?.toInt() ?: 0, + ) + }, + timeoutHome = data["timeout_home"] as? Boolean ?: false, + timeoutAway = data["timeout_away"] as? Boolean ?: false, + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index b86f680..02d15a5 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -1,10 +1,17 @@ package com.matchlivetv.match_live_tv.data.repository import com.matchlivetv.match_live_tv.core.TokenStore +import com.matchlivetv.match_live_tv.data.api.CreateMatchBody +import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest import com.matchlivetv.match_live_tv.data.api.MatchLiveApi +import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody +import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull +import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.Recording import com.matchlivetv.match_live_tv.domain.Team import kotlinx.coroutines.flow.first +import java.time.Instant class MatchRepository( private val api: MatchLiveApi, @@ -13,15 +20,98 @@ class MatchRepository( suspend fun fetchTeams(): List = api.teams().map { it.toDomain() } - suspend fun fetchMatches(): List { - val teamId = tokenStore.activeTeamIdFlow.first() - ?: fetchTeams().firstOrNull()?.id - ?: return emptyList() - tokenStore.saveActiveTeamId(teamId) - return api.matches(teamId).map { it.toDomain() } + suspend fun fetchTeam(teamId: String): Team? = + fetchTeams().firstOrNull { it.id == teamId } + + suspend fun resolveActiveTeam(teams: List): Team? { + if (teams.isEmpty()) return null + val savedId = tokenStore.activeTeamIdFlow.first() + val selected = teams.firstOrNull { it.id == savedId } ?: teams.first() + tokenStore.saveActiveTeamId(selected.id) + return selected } + suspend fun fetchMatch(matchId: String): Match = + api.match(matchId).toDomain() + + suspend fun fetchMatchesForTeam(teamId: String): List = + api.matches(teamId) + .map { it.toDomain() } + .sortedWith(matchComparator) + + suspend fun fetchMatches(): List { + val teams = fetchTeams() + val team = resolveActiveTeam(teams) ?: return emptyList() + return fetchMatchesForTeam(team.id) + } + + suspend fun fetchRecordings(teamId: String): List = + api.recordings(teamId).map { it.toDomain() } + suspend fun selectTeam(teamId: String) { tokenStore.saveActiveTeamId(teamId) } + + suspend fun createQuickMatch(teamId: String): Match = + api.createMatch( + teamId, + CreateMatchRequest(match = CreateMatchBody(opponentName = "Avversario")), + ).toDomain() + + suspend fun createScheduledMatch( + teamId: String, + opponentName: String, + scheduledAt: Instant, + location: String?, + ): Match = api.createMatch( + teamId, + CreateMatchRequest( + match = CreateMatchBody( + opponentName = opponentName, + scheduledAt = scheduledAt.toString(), + location = location?.takeIf { it.isNotBlank() }, + ), + ), + ).toDomain() + + suspend fun updateMatch( + matchId: String, + opponentName: String, + location: String?, + scheduledAt: String?, + setsToWin: Int, + category: String?, + phase: String?, + rosterNumbers: List, + scoringRules: ScoringRulesBody?, + ): Match = api.updateMatch( + matchId, + UpdateMatchRequestFull( + match = UpdateMatchBodyFull( + opponentName = opponentName, + location = location?.takeIf { it.isNotBlank() }, + scheduledAt = scheduledAt, + setsToWin = setsToWin, + category = category?.takeIf { it.isNotBlank() }, + phase = phase?.takeIf { it.isNotBlank() }, + rosterNumbers = rosterNumbers.takeIf { it.isNotEmpty() }, + scoringRules = scoringRules, + ), + ), + ).toDomain() + + suspend fun deleteMatch(matchId: String) { + api.deleteMatch(matchId) + } + + suspend fun recordingDownloadUrl(recordingId: String): String { + val body = api.recordingDownload(recordingId) + return body["download_url"] ?: error("URL download mancante") + } + + companion object { + private val matchComparator = compareBy(nullsLast()) { match -> + match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + }.thenByDescending { it.id } + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchSessionLauncher.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchSessionLauncher.kt new file mode 100644 index 0000000..7b47997 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchSessionLauncher.kt @@ -0,0 +1,15 @@ +package com.matchlivetv.match_live_tv.data.repository + +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.StreamSession + +object MatchSessionLauncher { + /** Riprende una diretta già attiva (camera live / paused / reconnecting). */ + suspend fun resumeBroadcastSession( + match: Match, + sessionRepository: SessionRepository, + ): StreamSession { + val sessionId = match.activeSessionId ?: error("Sessione mancante") + return sessionRepository.fetchSession(sessionId) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt new file mode 100644 index 0000000..cb1d898 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt @@ -0,0 +1,27 @@ +package com.matchlivetv.match_live_tv.data.repository + +import com.matchlivetv.match_live_tv.data.api.MatchLiveApi +import com.matchlivetv.match_live_tv.data.api.ScoreSyncRequest +import com.matchlivetv.match_live_tv.data.api.SetPartialDto +import com.matchlivetv.match_live_tv.domain.ScoreState + +class ScoreRepository( + private val api: MatchLiveApi, +) { + suspend fun syncScore(sessionId: String, score: ScoreState): ScoreState? { + val response = api.syncScore( + sessionId, + ScoreSyncRequest( + homeSets = score.homeSets, + awaySets = score.awaySets, + homePoints = score.homePoints, + awayPoints = score.awayPoints, + currentSet = score.currentSet, + setPartials = score.setPartials.map { + SetPartialDto(set = it.set, home = it.home, away = it.away) + }, + ), + ) + return response.score?.toDomain() + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt index 4d2ac82..659350a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt @@ -3,13 +3,25 @@ package com.matchlivetv.match_live_tv.data.repository import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest +import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse import com.matchlivetv.match_live_tv.domain.StreamSession class SessionRepository( private val api: MatchLiveApi, ) { - suspend fun createSession(matchId: String): StreamSession = - api.createSession(matchId, CreateSessionRequest()).toDomain() + suspend fun createSession( + matchId: String, + platform: String = "matchlivetv", + privacyStatus: String = "public", + youtubeChannel: String? = null, + ): StreamSession = api.createSession( + matchId, + CreateSessionRequest( + platform = platform, + privacyStatus = privacyStatus, + youtubeChannel = youtubeChannel, + ), + ).toDomain() suspend fun fetchSession(sessionId: String): StreamSession = api.session(sessionId).toDomain() @@ -32,10 +44,35 @@ class SessionRepository( uploadMbps: Double, latencyMs: Int, networkType: String, + ): NetworkTestResponse = api.networkTest( + sessionId, + NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType), + ) + + suspend fun createRegiaLink(sessionId: String): String = + api.regiaLink(sessionId).regiaUrl + + suspend fun postTelemetry( + sessionId: String, + deviceRole: String = "camera", + batteryLevel: Int? = null, + networkType: String? = null, + signalStrength: Int? = null, + currentBitrate: Int? = null, + targetBitrate: Int? = null, + fps: Int? = null, ) { - api.networkTest( + api.postTelemetry( sessionId, - NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType), + com.matchlivetv.match_live_tv.data.api.TelemetryRequest( + deviceRole = deviceRole, + batteryLevel = batteryLevel, + networkType = networkType, + signalStrength = signalStrength, + currentBitrate = currentBitrate, + targetBitrate = targetBitrate, + fps = fps, + ), ) } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt new file mode 100644 index 0000000..6602945 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt @@ -0,0 +1,112 @@ +package com.matchlivetv.match_live_tv.data.scoring + +import com.matchlivetv.match_live_tv.data.cable.SessionCableService +import com.matchlivetv.match_live_tv.data.repository.ScoreRepository +import com.matchlivetv.match_live_tv.domain.ScoreState +import com.matchlivetv.match_live_tv.domain.SetPartial +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ScoreController( + private val scoreRepository: ScoreRepository, + private val sessionCable: SessionCableService, + private val scope: CoroutineScope, +) { + private val _score = MutableStateFlow(ScoreState()) + val score: StateFlow = _score.asStateFlow() + + private var sessionId: String? = null + private var suppressRemoteUntilMs: Long = 0 + private val syncMutex = Mutex() + private var syncJob: Job? = null + + fun bind(sessionId: String, initial: ScoreState?) { + this.sessionId = sessionId + if (initial != null) { + _score.value = initial + } + } + + fun applyRemote(remote: ScoreState) { + val now = System.currentTimeMillis() + if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) { + return + } + _score.value = remote + } + + fun incrementHome() { + _score.update { it.copy(homePoints = it.homePoints + 1) } + schedulePush() + } + + fun incrementAway() { + _score.update { it.copy(awayPoints = it.awayPoints + 1) } + schedulePush() + } + + fun decrementHome() { + _score.update { current -> + if (current.homePoints > 0) current.copy(homePoints = current.homePoints - 1) else current + } + schedulePush() + } + + fun decrementAway() { + _score.update { current -> + if (current.awayPoints > 0) current.copy(awayPoints = current.awayPoints - 1) else current + } + schedulePush() + } + + fun closeSet() { + val current = _score.value + val homeWon = current.homePoints > current.awayPoints + val partials = current.setPartials.toMutableList() + if (current.homePoints > 0 || current.awayPoints > 0) { + partials.add( + SetPartial( + set = current.currentSet, + home = current.homePoints, + away = current.awayPoints, + ), + ) + } + _score.value = current.copy( + homeSets = if (homeWon) current.homeSets + 1 else current.homeSets, + awaySets = if (homeWon) current.awaySets else current.awaySets + 1, + homePoints = 0, + awayPoints = 0, + currentSet = current.currentSet + 1, + setPartials = partials, + timeoutHome = false, + timeoutAway = false, + ) + schedulePush() + } + + private fun schedulePush() { + syncJob?.cancel() + syncJob = scope.launch { + syncMutex.withLock { + val id = sessionId ?: return@withLock + val local = _score.value + sessionCable.sendScoreUpdate(local) + runCatching { scoreRepository.syncScore(id, local) } + .onSuccess { remote -> + suppressRemoteUntilMs = System.currentTimeMillis() + 500 + if (remote != null) { + _score.value = remote + } + } + } + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt new file mode 100644 index 0000000..bdab095 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt @@ -0,0 +1,40 @@ +package com.matchlivetv.match_live_tv.domain + +enum class ScoringSide { + HOME, + AWAY, +} + +data class MatchScoringRules( + val setsToWin: Int, + val pointsPerSet: Int = 25, + val pointsDecidingSet: Int = 15, + val minPointLead: Int = 2, +) { + val maxSets: Int get() = (setsToWin * 2) - 1 + + fun pointsTarget(currentSet: Int): Int = + if (currentSet >= maxSets) pointsDecidingSet else pointsPerSet + + fun setWinnerFromPoints( + homePoints: Int, + awayPoints: Int, + currentSet: Int, + ): ScoringSide? { + val target = pointsTarget(currentSet) + val lead = minPointLead + if (homePoints >= target && homePoints - awayPoints >= lead) return ScoringSide.HOME + if (awayPoints >= target && awayPoints - homePoints >= lead) return ScoringSide.AWAY + return null + } + + fun matchWinner(homeSets: Int, awaySets: Int): ScoringSide? = when { + homeSets >= setsToWin -> ScoringSide.HOME + awaySets >= setsToWin -> ScoringSide.AWAY + else -> null + } + + companion object { + fun fromMatch(match: Match) = MatchScoringRules(setsToWin = match.setsToWin) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index 3808b82..048dfe7 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -16,11 +16,22 @@ data class Team( val id: String, val name: String, val sport: String, + val clubName: String? = null, val canStream: Boolean = true, val youtubeEnabled: Boolean = false, + val youtubeConnected: Boolean = false, + val youtubeSelectable: Boolean = false, + val youtubeChannelTitle: String? = null, + val youtubeTeamChannelTitle: String? = null, + val planName: String? = null, + val recordingsEnabled: Boolean = false, + val phoneDownloadEnabled: Boolean = false, val billingUrl: String? = null, val staffManageUrl: String? = null, -) +) { + val canUseYoutube: Boolean get() = youtubeEnabled + val isYoutubeReady: Boolean get() = youtubeSelectable +} data class Match( val id: String, @@ -29,9 +40,15 @@ data class Match( val opponentName: String, val location: String? = null, val scheduledAt: String? = null, + val setsToWin: Int = 3, + val category: String? = null, + val phase: String? = null, + val rosterNumbers: List = emptyList(), val activeSessionId: String? = null, val activeSessionStatus: String? = null, ) { + val hasActiveSession: Boolean get() = activeSessionId != null + val canResumeCamera: Boolean get() { val status = activeSessionStatus ?: return false @@ -49,9 +66,22 @@ data class StreamSession( val hlsPlaybackUrl: String? = null, val watchPageUrl: String? = null, val shareUrl: String? = null, + val youtubeWatchUrl: String? = null, + val youtubeReady: Boolean = false, + val privacyStatus: String = "public", val targetBitrate: Int = 2_500_000, val targetFps: Int = 30, val startedAt: String? = null, + val score: ScoreState? = null, ) { val isLive: Boolean get() = status == "live" || status == "reconnecting" + + val isPaused: Boolean get() = status == "paused" + + fun watchShareUrl(): String? = when { + platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl + !shareUrl.isNullOrBlank() -> shareUrl + !watchPageUrl.isNullOrBlank() -> watchPageUrl + else -> null + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt new file mode 100644 index 0000000..7a09989 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Recording.kt @@ -0,0 +1,22 @@ +package com.matchlivetv.match_live_tv.domain + +data class Recording( + val id: String, + val title: String, + val opponentName: String, + val teamName: String, + val status: String, + val statusLabel: String, + val recordedAt: String? = null, + val endedAt: String? = null, + val durationLabel: String? = null, + val viewsLabel: String? = null, + val replayUrl: String? = null, + val thumbnailUrl: String? = null, + val downloadEnabled: Boolean = false, + val youtubeWatchUrl: String? = null, + val expiresAt: String? = null, +) { + val isReady: Boolean get() = status == "ready" + val isProcessing: Boolean get() = status == "processing" +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt new file mode 100644 index 0000000..18821c7 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt @@ -0,0 +1,33 @@ +package com.matchlivetv.match_live_tv.domain + +data class SetPartial( + val set: Int, + val home: Int, + val away: Int, +) + +data class ScoreState( + val homeSets: Int = 0, + val awaySets: Int = 0, + val homePoints: Int = 0, + val awayPoints: Int = 0, + val currentSet: Int = 1, + val setPartials: List = emptyList(), + val timeoutHome: Boolean = false, + val timeoutAway: Boolean = false, +) { + fun cablePayload(): Map = mapOf( + "type" to "score_update", + "home_sets" to homeSets, + "away_sets" to awaySets, + "home_points" to homePoints, + "away_points" to awayPoints, + "current_set" to currentSet, + "set_partials" to setPartials.map { + mapOf("set" to it.set, "home" to it.home, "away" to it.away) + }, + ) + + fun progressKey(): Int = + homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt index be47fb9..59e86b4 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt @@ -7,7 +7,10 @@ data class BroadcastConfig( val videoBitrate: Int = 2_500_000, val audioBitrate: Int = 128_000, val fps: Int = 30, + val rotation: Int = 0, val portrait: Boolean = false, + val maxReconnectAttempts: Int = 10, + val reconnectDelayMs: Long = 5_000L, ) enum class BroadcastPhase { @@ -15,6 +18,7 @@ enum class BroadcastPhase { PREVIEW, CONNECTING, LIVE, + PAUSED, RECONNECTING, ERROR, } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt index 96b1c5a..b02168c 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt @@ -24,6 +24,10 @@ class LiveBroadcastCoordinator(context: Context) { engine.pauseForConfigurationChange() } + fun resumeAfterConfigurationChange() { + engine.resumeAfterConfigurationChange() + } + fun startService() { val intent = LiveBroadcastService.startIntent(appContext) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index d842fd5..2d503b5 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -4,14 +4,17 @@ import android.content.Context import android.media.MediaCodecInfo import android.os.Handler import android.os.Looper -import android.os.SystemClock import android.util.Log +import android.view.Surface +import android.view.WindowManager import com.pedro.common.ConnectChecker import com.pedro.library.generic.GenericStream import com.pedro.library.util.SensorRotationManager +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger /** - * Motore RTMP nativo Match Live TV — implementazione dedicata app Android (non Flutter). + * Motore RTMP nativo Match Live TV (RootEncoder / GenericStream). */ class LiveBroadcastEngine( private val appContext: Context, @@ -24,11 +27,29 @@ class LiveBroadcastEngine( private var phase = BroadcastPhase.IDLE private var surfaceSuspended = false private var rebindPending = false - private var lastCameraRotation = -1 + private var lastAppliedRotation = -1 + private var lastAppliedPortrait: Boolean? = null + private var pendingRotation: Int? = null + private var pendingIsPortrait: Boolean? = null private var sensorManager: SensorRotationManager? = null private var listener: ((BroadcastMetrics) -> Unit)? = null + private var streamStartPending = false + private var publishInProgress = false + private var currentBitrateKbps: Long = 0 + private var currentFps: Int = 0 + private val pausingIntentionally = AtomicBoolean(false) + private val reconnectAttempts = AtomicInteger(0) - private val rebindRunnable = Runnable { reattachPreviewIfNeeded() } + private val rebindRunnable = Runnable { + reattachPreviewIfNeeded() + tryStartPendingBroadcast() + } + + private val orientationApplyRunnable = Runnable { + val rotation = pendingRotation ?: return@Runnable + val isPortrait = pendingIsPortrait ?: return@Runnable + applyGlOrientation(rotation, isPortrait, "sensorOrientation") + } fun setMetricsListener(block: ((BroadcastMetrics) -> Unit)?) { listener = block @@ -47,10 +68,20 @@ class LiveBroadcastEngine( } else { ensurePreviewPrepared(view) } + tryStartPendingBroadcast() + } + } + + /** Dopo rotazione schermo: riallinea preview e orientamento encoder. */ + fun resumeAfterConfigurationChange() { + mainHandler.post { + if (phase == BroadcastPhase.IDLE) return@post + resetOrientationTracking() + applyInitialOrientation() + refreshOrientationSensor() } } - /** Chiamato da Activity.onConfigurationChanged prima di super. */ fun pauseForConfigurationChange() { mainHandler.post { if (phase == BroadcastPhase.IDLE) return@post @@ -72,6 +103,7 @@ class LiveBroadcastEngine( view.onSurfaceLost = { onPreviewSurfaceLost() } updatePipelinePreservationFlag() scheduleRebind() + tryStartPendingBroadcast() } } @@ -91,19 +123,86 @@ class LiveBroadcastEngine( fun startBroadcast(broadcastConfig: BroadcastConfig) { mainHandler.post { config = broadcastConfig - val stream = obtainStream() - resetEncoder(stream, broadcastConfig) - attachPreviewInternal() - applyOrientation(broadcastConfig.portrait, broadcastConfig.portrait) - startSensor() + reconnectAttempts.set(0) + streamStartPending = true + publishInProgress = false setPhase(BroadcastPhase.CONNECTING) - Log.i(TAG, "publish ${broadcastConfig.rtmpUrl}") - stream.startStream(broadcastConfig.rtmpUrl) + Log.i(TAG, "publish queued ${broadcastConfig.rtmpUrl}") + tryStartPendingBroadcast() } } + private fun tryStartPendingBroadcast() { + if (!streamStartPending || publishInProgress) return + val cfg = config ?: return + publishInProgress = true + waitForPreviewThenPublish(cfg, attempt = 0) + } + + private fun waitForPreviewThenPublish(cfg: BroadcastConfig, attempt: Int) { + if (!streamStartPending) { + publishInProgress = false + return + } + if (!isSurfaceUsable(previewView)) { + if (attempt >= 40) { + publishInProgress = false + streamStartPending = false + setPhase(BroadcastPhase.ERROR, "preview_not_ready") + return + } + mainHandler.postDelayed({ waitForPreviewThenPublish(cfg, attempt + 1) }, 50L) + return + } + + val stream = obtainStream(cfg) + if (!stream.isOnPreview) { + if (!preparePipeline(stream, cfg)) { + publishInProgress = false + streamStartPending = false + return + } + } + + if (!stream.isOnPreview) { + if (attempt >= 40) { + publishInProgress = false + streamStartPending = false + setPhase(BroadcastPhase.ERROR, "preview_not_ready") + return + } + mainHandler.postDelayed({ waitForPreviewThenPublish(cfg, attempt + 1) }, 50L) + return + } + + mainHandler.postDelayed({ + if (!streamStartPending) { + publishInProgress = false + return@postDelayed + } + val activeStream = genericStream + if (activeStream == null) { + publishInProgress = false + return@postDelayed + } + if (activeStream.isStreaming) { + streamStartPending = false + publishInProgress = false + setPhase(BroadcastPhase.LIVE) + return@postDelayed + } + setPhase(BroadcastPhase.CONNECTING) + Log.i(TAG, "publish ${cfg.rtmpUrl}") + activeStream.startStream(cfg.rtmpUrl) + streamStartPending = false + publishInProgress = false + }, PRE_PUBLISH_DELAY_MS) + } + fun stopBroadcast() { mainHandler.post { + streamStartPending = false + publishInProgress = false stopSensor() genericStream?.let { stream -> if (stream.isStreaming) stream.stopStream() @@ -112,10 +211,41 @@ class LiveBroadcastEngine( genericStream = null surfaceSuspended = false rebindPending = false + lastAppliedRotation = -1 + lastAppliedPortrait = null + currentBitrateKbps = 0 + currentFps = 0 + reconnectAttempts.set(0) setPhase(BroadcastPhase.IDLE) } } + fun pauseBroadcast() { + mainHandler.post { + streamStartPending = false + publishInProgress = false + pausingIntentionally.set(true) + genericStream?.let { stream -> + if (stream.isStreaming) stream.stopStream() + } + pausingIntentionally.set(false) + currentBitrateKbps = 0 + currentFps = 0 + attachPreviewInternal() + setPhase(BroadcastPhase.PAUSED) + } + } + + fun resumeBroadcast(broadcastConfig: BroadcastConfig) { + mainHandler.post { + config = broadcastConfig + reconnectAttempts.set(0) + streamStartPending = true + publishInProgress = false + mainHandler.postDelayed({ tryStartPendingBroadcast() }, 250L) + } + } + fun release() { mainHandler.post { stopBroadcast() @@ -123,57 +253,86 @@ class LiveBroadcastEngine( } } - private fun obtainStream(): GenericStream { + private fun obtainStream(cfg: BroadcastConfig): GenericStream { val existing = genericStream if (existing != null) return existing return GenericStream(appContext, this).also { stream -> stream.getGlInterface().autoHandleOrientation = false - stream.getStreamClient().setReTries(10) + stream.getStreamClient().setReTries(cfg.maxReconnectAttempts) genericStream = stream } } private fun ensurePreviewPrepared(view: LivePreviewView) { - val stream = obtainStream() + val stream = obtainStream(config ?: BroadcastConfig(rtmpUrl = "")) if (stream.isOnPreview) return - val cfg = config ?: BroadcastConfig(rtmpUrl = "") - resetEncoder(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" })) - attachPreviewInternal() + val cfg = config ?: BroadcastConfig(rtmpUrl = "rtmp://127.0.0.1/preview") + preparePipeline(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" })) setPhase(BroadcastPhase.PREVIEW) } - private fun resetEncoder(stream: GenericStream, cfg: BroadcastConfig) { + private fun preparePipeline(stream: GenericStream, cfg: BroadcastConfig): Boolean { if (stream.isStreaming) stream.stopStream() if (stream.isOnPreview) stream.stopPreview() - val rotation = if (cfg.portrait) 90 else 0 - val ok = stream.prepareVideo( - width = cfg.width, - height = cfg.height, - bitrate = cfg.videoBitrate, - fps = cfg.fps, - iFrameInterval = 1, - rotation = rotation, - profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, - level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, - ) && stream.prepareAudio(48_000, false, cfg.audioBitrate) - if (!ok) { + + val prepared = runCatching { + stream.prepareVideo( + width = cfg.width, + height = cfg.height, + bitrate = cfg.videoBitrate, + fps = cfg.fps, + iFrameInterval = 1, + rotation = cfg.rotation, + profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, + ) && stream.prepareAudio(48_000, false, cfg.audioBitrate) + }.getOrElse { + Log.w(TAG, "preparePipeline: ${it.message}") + false + } + + if (!prepared) { setPhase(BroadcastPhase.ERROR, "prepare_failed") + return false + } + + if (!attachPreviewInternal()) { + scheduleRebind() + return false + } + + resetOrientationTracking() + applyInitialOrientation() + startSensor() + return true + } + + /** Stessa convenzione di SensorRotationManager (0° = portrait naturale). */ + private fun orientationFromDisplayRotation(): Pair { + @Suppress("DEPRECATION") + val displayRotation = (appContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager) + .defaultDisplay.rotation + return when (displayRotation) { + Surface.ROTATION_90 -> 90 to false + Surface.ROTATION_180 -> 180 to true + Surface.ROTATION_270 -> 270 to false + else -> 0 to true } } - private fun attachPreviewInternal() { - val view = previewView ?: return - val stream = genericStream ?: return - if (!isSurfaceUsable(view)) { - scheduleRebind() - return - } - val surface = view.holder.surface ?: return - if (!surface.isValid) { - scheduleRebind() - return - } - runCatching { + private fun applyInitialOrientation() { + val (rotation, isPortrait) = orientationFromDisplayRotation() + applyGlOrientation(rotation, isPortrait, "initialOrientation") + } + + private fun attachPreviewInternal(): Boolean { + val view = previewView ?: return false + val stream = genericStream ?: return false + if (!isSurfaceUsable(view)) return false + val surface = view.holder.surface ?: return false + if (!surface.isValid) return false + + return runCatching { val gl = stream.getGlInterface() gl.setForceRender(false) if (stream.isOnPreview) gl.deAttachPreview() @@ -183,9 +342,10 @@ class LiveBroadcastEngine( gl.setForceRender(true, 30) surfaceSuspended = false rebindPending = false - }.onFailure { + true + }.getOrElse { Log.w(TAG, "attachPreviewInternal: ${it.message}") - scheduleRebind() + false } } @@ -200,8 +360,13 @@ class LiveBroadcastEngine( scheduleRebind() return } - attachPreviewInternal() - config?.let { applyOrientation(it.portrait, it.portrait) } + if (attachPreviewInternal()) { + applyInitialOrientation() + refreshOrientationSensor() + } else { + scheduleRebind() + } + tryStartPendingBroadcast() } private fun isSurfaceUsable(view: LivePreviewView?): Boolean { @@ -218,37 +383,64 @@ class LiveBroadcastEngine( previewView?.keepPipelineOnSurfaceLoss = keep } - private fun applyOrientation(isPortrait: Boolean, forcePortrait: Boolean) { + private fun applyGlOrientation(rotation: Int, isPortrait: Boolean, logLabel: String) { + if (surfaceSuspended) { + pendingRotation = rotation + pendingIsPortrait = isPortrait + return + } val stream = genericStream ?: return - if (surfaceSuspended) return runCatching { val gl = stream.getGlInterface() gl.setForceRender(false) gl.autoHandleOrientation = false gl.setIsPortrait(isPortrait) - gl.setCameraOrientation(if (forcePortrait) 90 else 0) + gl.setCameraOrientation(rotation) gl.setPreviewRotation(0) gl.setStreamRotation(0) gl.setForceRender(true, 30) + lastAppliedRotation = rotation + lastAppliedPortrait = isPortrait + pendingIsPortrait = isPortrait + Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase") + }.getOrElse { + Log.w(TAG, "$logLabel orientation failed: ${it.message}") } } private fun startSensor() { if (sensorManager != null) return - sensorManager = SensorRotationManager(appContext, true, true) { rotation, portrait -> + sensorManager = SensorRotationManager(appContext, true, true) { rotation, isPortrait -> mainHandler.post { - if (surfaceSuspended || phase == BroadcastPhase.IDLE) return@post - if (rotation == lastCameraRotation) return@post - lastCameraRotation = rotation - applyOrientation(portrait, portrait) + if (surfaceSuspended || phase == BroadcastPhase.IDLE || phase == BroadcastPhase.PAUSED) return@post + if (rotation == lastAppliedRotation && isPortrait == lastAppliedPortrait) return@post + pendingRotation = rotation + pendingIsPortrait = isPortrait + mainHandler.removeCallbacks(orientationApplyRunnable) + mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS) } }.also { it.start() } } + private fun refreshOrientationSensor() { + sensorManager?.stop() + sensorManager = null + resetOrientationTracking() + startSensor() + } + + private fun resetOrientationTracking() { + lastAppliedRotation = -1 + lastAppliedPortrait = null + pendingRotation = null + pendingIsPortrait = null + mainHandler.removeCallbacks(orientationApplyRunnable) + } + private fun stopSensor() { sensorManager?.stop() sensorManager = null - lastCameraRotation = -1 + resetOrientationTracking() } private fun setPhase(newPhase: BroadcastPhase, error: String? = null) { @@ -258,12 +450,16 @@ class LiveBroadcastEngine( } private fun emitMetrics(error: String? = null) { - val stream = genericStream + val cfg = config listener?.invoke( BroadcastMetrics( phase = phase, - bitrateKbps = 0, - fps = 0, + bitrateKbps = currentBitrateKbps, + fps = if (phase == BroadcastPhase.LIVE) { + currentFps.takeIf { it > 0 } ?: cfg?.fps ?: 0 + } else { + 0 + }, connected = phase == BroadcastPhase.LIVE, lastError = error, ), @@ -275,31 +471,72 @@ class LiveBroadcastEngine( } override fun onConnectionSuccess() { - mainHandler.post { setPhase(BroadcastPhase.LIVE) } + mainHandler.post { + reconnectAttempts.set(0) + setPhase(BroadcastPhase.LIVE) + } } override fun onConnectionFailed(reason: String) { - mainHandler.post { setPhase(BroadcastPhase.ERROR, reason) } + mainHandler.post { + if (pausingIntentionally.get()) return@post + Log.w(TAG, "onConnectionFailed reason=$reason phase=$phase attempts=${reconnectAttempts.get()}") + val stream = genericStream ?: return@post + val currentConfig = config ?: return@post + setPhase(BroadcastPhase.RECONNECTING, reason) + reconnectAttempts.incrementAndGet() + val retryScheduled = stream.getStreamClient().reTry( + currentConfig.reconnectDelayMs, + reason, + null, + ) + if (!retryScheduled) { + stream.stopStream() + setPhase(BroadcastPhase.ERROR, userFacingError(reason)) + } + } } - override fun onNewBitrate(bitrate: Long) = Unit + override fun onNewBitrate(bitrate: Long) { + mainHandler.post { + currentBitrateKbps = bitrate / 1000 + emitMetrics() + } + } override fun onDisconnect() { mainHandler.post { - if (phase == BroadcastPhase.LIVE) setPhase(BroadcastPhase.RECONNECTING) + if (pausingIntentionally.get()) return@post + if (phase == BroadcastPhase.LIVE || phase == BroadcastPhase.RECONNECTING) { + setPhase(BroadcastPhase.RECONNECTING) + } } } override fun onAuthError() { - mainHandler.post { setPhase(BroadcastPhase.ERROR, "auth_error") } + mainHandler.post { + genericStream?.stopStream() + setPhase(BroadcastPhase.ERROR, "Autenticazione RTMP fallita") + } } override fun onAuthSuccess() { - mainHandler.post { setPhase(BroadcastPhase.LIVE) } + mainHandler.post { + reconnectAttempts.set(0) + setPhase(BroadcastPhase.LIVE) + } + } + + private fun userFacingError(reason: String): String = when { + reason.contains("broken pipe", ignoreCase = true) -> + "Connessione persa — riprova a riavviare la diretta" + else -> reason } companion object { private const val TAG = "LiveBroadcastEngine" private const val SURFACE_DEBOUNCE_MS = 700L + private const val ORIENTATION_DEBOUNCE_MS = 120L + private const val PRE_PUBLISH_DELAY_MS = 350L } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt new file mode 100644 index 0000000..2e2b56f --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/archive/ArchiveScreen.kt @@ -0,0 +1,178 @@ +package com.matchlivetv.match_live_tv.ui.archive + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.domain.Recording +import com.matchlivetv.match_live_tv.domain.Team +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchiveScreen( + container: AppContainer, + onBack: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(true) } + var team by remember { mutableStateOf(null) } + var recordings by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + loading = true + runCatching { + val teams = container.matchRepository.fetchTeams() + val active = container.matchRepository.resolveActiveTeam(teams) + team = active + recordings = active?.let { container.matchRepository.fetchRecordings(it.id) }.orEmpty() + }.onFailure { error = it.message } + loading = false + } + + MatchScreenScaffold( + topBar = { + TopAppBar( + title = { Text("Replay") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MatchColors.Background, + titleContentColor = Color.White, + ), + ) + }, + ) { + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MatchColors.PrimaryRed) + } + error != null -> Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center) + } + team != null && !team!!.recordingsEnabled -> Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Replay disponibili con Premium Light o Full", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + "Light: 30 giorni · Full: 90 giorni", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + team?.billingUrl?.let { url -> + MatchPrimaryButton( + label = "SCOPRI PREMIUM", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + modifier = Modifier.padding(top = 16.dp), + ) + } + } + recordings.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + else -> LazyColumn(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(recordings, key = { it.id }) { recording -> + Card( + colors = CardDefaults.cardColors(containerColor = MatchColors.Surface), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + onClick = { + if (recording.isReady) { + recording.replayUrl?.let { url -> + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } + }, + ) { + Column(Modifier.padding(16.dp)) { + Text(recording.title, style = MaterialTheme.typography.titleMedium) + Text( + listOfNotNull( + recording.durationLabel, + recording.viewsLabel, + recording.statusLabel, + ).joinToString(" · "), + style = MaterialTheme.typography.bodyMedium, + ) + if (recording.downloadEnabled && team?.phoneDownloadEnabled == true) { + MatchPrimaryButton( + label = "SCARICA MP4", + onClick = { + scope.launch { + runCatching { + container.matchRepository.recordingDownloadUrl(recording.id) + }.onSuccess { url -> + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)), + ) + } + } + }, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + } + } + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index d253a1e..408b2ca 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -1,14 +1,25 @@ package com.matchlivetv.match_live_tv.ui.broadcast import android.view.ViewGroup -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -18,19 +29,31 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import com.matchlivetv.match_live_tv.core.DeviceTelemetry import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.MatchScoringRules import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.streaming.BroadcastConfig import com.matchlivetv.match_live_tv.streaming.BroadcastPhase import com.matchlivetv.match_live_tv.streaming.LivePreviewView +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge +import com.matchlivetv.match_live_tv.ui.permissions.rememberBroadcastPermissionsState import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BroadcastScreen( container: AppContainer, @@ -38,75 +61,326 @@ fun BroadcastScreen( onFinished: () -> Unit, ) { val scope = rememberCoroutineScope() + val context = LocalContext.current + val snackbar = remember { SnackbarHostState() } var session by remember { mutableStateOf(null) } + var match by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var pauseInFlight by remember { mutableStateOf(false) } + var resumeInFlight by remember { mutableStateOf(false) } val metrics by container.broadcastCoordinator.metrics.collectAsState() - val configuration = LocalConfiguration.current - val portrait = configuration.screenHeightDp > configuration.screenWidthDp - - LaunchedEffect(sessionId) { - runCatching { container.sessionRepository.fetchSession(sessionId) } - .onSuccess { loaded -> - session = loaded - val url = loaded.rtmpIngestUrl - if (url.isNullOrBlank()) { - error = "URL RTMP mancante" - return@onSuccess - } - container.broadcastCoordinator.startService() - container.broadcastCoordinator.engine.startBroadcast( - BroadcastConfig( - rtmpUrl = url, - width = if (portrait) 720 else 1280, - height = if (portrait) 1280 else 720, - portrait = portrait, - videoBitrate = loaded.targetBitrate, - fps = loaded.targetFps, - ), - ) - } - .onFailure { error = it.message ?: "Sessione non disponibile" } + val score by container.scoreController.score.collectAsState() + val cableConnected by container.sessionCable.connected.collectAsState() + val permissions = rememberBroadcastPermissionsState() + val dialogHost = rememberLiveScoreDialogHost() + val scoringRules = remember(match) { match?.let { MatchScoringRules.fromMatch(it) } } + val scoreActions = remember(scoringRules, dialogHost) { + scoringRules?.let { LiveScoreActions(it, container.scoreController, dialogHost) } } - DisposableEffect(Unit) { + fun broadcastConfig(loaded: StreamSession): BroadcastConfig = BroadcastConfig( + rtmpUrl = loaded.rtmpIngestUrl.orEmpty(), + width = 1280, + height = 720, + videoBitrate = loaded.targetBitrate, + fps = loaded.targetFps, + ) + + suspend fun stopStreamPermanently() { + runCatching { container.sessionRepository.stopSession(sessionId) } + container.sessionCable.disconnect() + container.broadcastCoordinator.engine.stopBroadcast() + container.broadcastCoordinator.stopService() + onFinished() + } + + suspend fun pauseStream() { + if (pauseInFlight) return + pauseInFlight = true + try { + val updated = container.sessionRepository.pauseSession(sessionId) + session = updated + container.broadcastCoordinator.engine.pauseBroadcast() + snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina") + } catch (e: Exception) { + snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}") + } finally { + pauseInFlight = false + } + } + + suspend fun resumeStream() { + if (resumeInFlight) return + resumeInFlight = true + try { + var updated = container.sessionRepository.resumeSession(sessionId) + session = updated + val url = updated.rtmpIngestUrl + if (!url.isNullOrBlank()) { + container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(updated)) + } + updated = container.sessionRepository.fetchSession(sessionId) + session = updated + snackbar.showSnackbar("Diretta ripresa — di nuovo in onda") + } catch (e: Exception) { + snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}") + } finally { + resumeInFlight = false + } + } + + suspend fun applyRemotePause() { + container.broadcastCoordinator.engine.pauseBroadcast() + session = session?.copy(status = "paused") + snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata") + } + + suspend fun applyRemoteResume() { + if (resumeInFlight) return + val current = session ?: return + val url = current.rtmpIngestUrl ?: return + resumeInFlight = true + try { + container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current)) + snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda") + } catch (e: Exception) { + snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}") + } finally { + resumeInFlight = false + } + } + + LaunchedEffect(sessionId, permissions.granted) { + if (!permissions.granted) return@LaunchedEffect + loading = true + error = null + runCatching { + val loaded = container.sessionRepository.fetchSession(sessionId) + val matchData = container.matchRepository.fetchMatch(loaded.matchId) + session = loaded + match = matchData + container.scoreController.bind(sessionId, loaded.score) + val token = container.authRepository.currentSession()?.accessToken + if (!token.isNullOrBlank()) { + container.sessionCable.onScoreUpdate = { remote -> + container.scoreController.applyRemote(remote) + } + container.sessionCable.onPauseStream = { + if (!pauseInFlight) scope.launch { applyRemotePause() } + } + container.sessionCable.onResumeStream = { + if (!resumeInFlight) scope.launch { applyRemoteResume() } + } + container.sessionCable.connect(sessionId, token, deviceRole = "camera") + } + container.broadcastCoordinator.startService() + val url = loaded.rtmpIngestUrl + if (url.isNullOrBlank()) { + error("URL RTMP mancante") + } else if (!loaded.isPaused) { + container.broadcastCoordinator.engine.startBroadcast(broadcastConfig(loaded)) + } + }.onFailure { + error = it.message ?: "Sessione non disponibile" + } + loading = false + } + + LaunchedEffect(sessionId) { + while (true) { + delay(10_000) + val current = session ?: continue + if (current.isPaused) continue + runCatching { + container.sessionRepository.postTelemetry( + sessionId = sessionId, + deviceRole = "camera", + batteryLevel = DeviceTelemetry.batteryLevel(context), + networkType = DeviceTelemetry.networkType(context), + currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 }, + targetBitrate = current.targetBitrate, + fps = metrics.fps.takeIf { it > 0 }, + ) + } + } + } + + DisposableEffect(sessionId) { onDispose { + container.sessionCable.onPauseStream = null + container.sessionCable.onResumeStream = null + container.sessionCable.disconnect() container.broadcastCoordinator.stopService() container.broadcastCoordinator.engine.stopBroadcast() } } - Column(Modifier.fillMaxSize().background(MatchColors.Background)) { - Box(Modifier.weight(1f).fillMaxWidth()) { - CameraPreviewPanel(container) - Text( - text = when (metrics.phase) { - BroadcastPhase.LIVE -> "IN DIRETTA" - BroadcastPhase.CONNECTING -> "CONNESSIONE…" - BroadcastPhase.RECONNECTING -> "RICONNESSIONE…" - BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE" - else -> "PREVIEW" - }, - color = if (metrics.phase == BroadcastPhase.LIVE) MatchColors.SuccessGreen else MatchColors.AccentYellow, - modifier = Modifier.padding(16.dp), - ) - } - error?.let { - Text(it, color = MatchColors.PrimaryRed, modifier = Modifier.padding(16.dp)) - } - Button( - onClick = { - scope.launch { - runCatching { container.sessionRepository.stopSession(sessionId) } - container.broadcastCoordinator.engine.stopBroadcast() - container.broadcastCoordinator.stopService() - onFinished() + val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED + val statusText = when { + isPaused -> "PAUSA" + metrics.phase == BroadcastPhase.LIVE -> "IN DIRETTA" + metrics.phase == BroadcastPhase.CONNECTING -> "CONNESSIONE…" + metrics.phase == BroadcastPhase.RECONNECTING -> "RICONNESSIONE…" + metrics.phase == BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE" + else -> "PREVIEW" + } + val statusColor = when { + isPaused -> MatchColors.AccentYellow + metrics.phase == BroadcastPhase.LIVE -> MatchColors.SuccessGreen + metrics.phase == BroadcastPhase.ERROR -> MatchColors.PrimaryRed + else -> MatchColors.AccentYellow + } + val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25 + + match?.let { currentMatch -> + ScoreDialogRouter( + host = dialogHost, + homeName = currentMatch.teamName, + awayName = currentMatch.opponentName, + ) + } + + MatchScreenScaffold( + topBar = { + Column { + TopAppBar( + title = { + Column { + Text("Diretta", style = MaterialTheme.typography.titleMedium) + Text( + if (cableConnected) "Tabellone collegato" else "Tabellone offline", + style = MaterialTheme.typography.labelSmall, + color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary, + ) + } + }, + navigationIcon = { + IconButton(onClick = onFinished) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Indietro", + tint = Color.White, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MatchColors.Background, + titleContentColor = Color.White, + ), + ) + SnackbarHost(hostState = snackbar) + } + }, + ) { + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + if (loading) { + CircularProgressIndicator( + color = MatchColors.PrimaryRed, + modifier = Modifier.align(Alignment.Center), + ) + } else { + CameraPreviewPanel(container) } - }, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Text("TERMINA DIRETTA") + MatchStatusBadge( + text = statusText, + textColor = statusColor, + backgroundColor = MatchColors.Background.copy(alpha = 0.75f), + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) + if (isPaused && !loading) { + MatchPrimaryButton( + label = "RIPRENDI DIRETTA", + onClick = { scope.launch { resumeStream() } }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + ) + } + } + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + error?.let { + Text( + it, + color = MatchColors.PrimaryRed, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + match?.let { currentMatch -> + scoreActions?.let { actions -> + LiveScoreControls( + homeName = currentMatch.teamName, + awayName = currentMatch.opponentName, + score = score, + pointsTarget = pointsTarget, + onPointHome = { + scope.launch { + container.scoreController.incrementHome() + actions.afterPointChange( + container.scoreController.score.value, + ) { stopStreamPermanently() } + } + }, + onPointAway = { + scope.launch { + container.scoreController.incrementAway() + actions.afterPointChange( + container.scoreController.score.value, + ) { stopStreamPermanently() } + } + }, + onMinusHome = { container.scoreController.decrementHome() }, + onMinusAway = { container.scoreController.decrementAway() }, + onCloseSet = { + scope.launch { + actions.requestCloseSet { stopStreamPermanently() } + } + }, + ) + } + } + if (!permissions.granted) { + Text( + "Consenti accesso a camera e microfono per andare in diretta", + color = MatchColors.AccentYellow, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + MatchPrimaryButton( + label = "CONCEDI PERMESSI", + onClick = permissions.request, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (isPaused) { + MatchPrimaryButton( + label = "RIPRENDI DIRETTA", + onClick = { scope.launch { resumeStream() } }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } else if (!loading && session != null) { + MatchSecondaryButton( + label = "PAUSA DIRETTA", + onClick = { scope.launch { pauseStream() } }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + MatchPrimaryButton( + label = "TERMINA DIRETTA", + onClick = { scope.launch { stopStreamPermanently() } }, + modifier = Modifier.padding(16.dp), + ) + } } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt new file mode 100644 index 0000000..389aa0a --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt @@ -0,0 +1,153 @@ +package com.matchlivetv.match_live_tv.ui.broadcast + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.matchlivetv.match_live_tv.data.scoring.ScoreController +import com.matchlivetv.match_live_tv.domain.MatchScoringRules +import com.matchlivetv.match_live_tv.domain.ScoreState +import com.matchlivetv.match_live_tv.domain.ScoringSide +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +enum class ScoreDialogKind { + SET_WON, + CLOSE_SET_ANYWAY, + MATCH_WON, +} + +data class ScoreDialogState( + val kind: ScoreDialogKind, + val winnerSide: ScoringSide? = null, + val homePoints: Int = 0, + val awayPoints: Int = 0, + val homeSets: Int = 0, + val awaySets: Int = 0, +) + +class LiveScoreDialogHost { + private var dialogOpen = false + var pendingDialog by mutableStateOf(null) + private set + private var pendingResult: ((Boolean) -> Unit)? = null + + suspend fun show(state: ScoreDialogState): Boolean = suspendCancellableCoroutine { cont -> + if (dialogOpen) { + cont.resume(false) + return@suspendCancellableCoroutine + } + dialogOpen = true + pendingDialog = state + pendingResult = { value -> + if (cont.isActive) cont.resume(value) + } + cont.invokeOnCancellation { + pendingDialog = null + dialogOpen = false + pendingResult = null + } + } + + fun resolve(result: Boolean) { + pendingDialog = null + dialogOpen = false + pendingResult?.invoke(result) + pendingResult = null + } +} + +@Composable +fun rememberLiveScoreDialogHost(): LiveScoreDialogHost = remember { LiveScoreDialogHost() } + +@Composable +fun ScoreDialogRouter( + host: LiveScoreDialogHost, + homeName: String, + awayName: String, +) { + val dialog = host.pendingDialog ?: return + when (dialog.kind) { + ScoreDialogKind.SET_WON -> { + val winner = dialog.winnerSide ?: return + SetWonDialog( + winnerName = scoringSideName(winner, homeName, awayName), + homePoints = dialog.homePoints, + awayPoints = dialog.awayPoints, + onDismiss = host::resolve, + ) + } + ScoreDialogKind.CLOSE_SET_ANYWAY -> { + CloseSetAnywayDialog(onDismiss = host::resolve) + } + ScoreDialogKind.MATCH_WON -> { + val winner = dialog.winnerSide ?: return + MatchWonDialog( + winnerName = scoringSideName(winner, homeName, awayName), + homeSets = dialog.homeSets, + awaySets = dialog.awaySets, + onDismiss = host::resolve, + ) + } + } +} + +class LiveScoreActions( + private val rules: MatchScoringRules, + private val scoreController: ScoreController, + private val dialogHost: LiveScoreDialogHost, +) { + suspend fun afterPointChange( + score: ScoreState, + onStopStream: suspend () -> Unit = {}, + ) { + val winner = rules.setWinnerFromPoints( + homePoints = score.homePoints, + awayPoints = score.awayPoints, + currentSet = score.currentSet, + ) ?: return + + val close = dialogHost.show( + ScoreDialogState( + kind = ScoreDialogKind.SET_WON, + winnerSide = winner, + homePoints = score.homePoints, + awayPoints = score.awayPoints, + ), + ) + if (close) { + scoreController.closeSet() + afterCloseSet(onStopStream) + } + } + + suspend fun requestCloseSet(onStopStream: suspend () -> Unit) { + val score = scoreController.score.value + val winner = rules.setWinnerFromPoints( + homePoints = score.homePoints, + awayPoints = score.awayPoints, + currentSet = score.currentSet, + ) + if (winner == null) { + val ok = dialogHost.show(ScoreDialogState(kind = ScoreDialogKind.CLOSE_SET_ANYWAY)) + if (!ok) return + } + scoreController.closeSet() + afterCloseSet(onStopStream) + } + + private suspend fun afterCloseSet(onStopStream: suspend () -> Unit) { + val score = scoreController.score.value + val winner = rules.matchWinner(score.homeSets, score.awaySets) ?: return + val stop = dialogHost.show( + ScoreDialogState( + kind = ScoreDialogKind.MATCH_WON, + winnerSide = winner, + homeSets = score.homeSets, + awaySets = score.awaySets, + ), + ) + if (stop) onStopStream() + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreControls.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreControls.kt new file mode 100644 index 0000000..6dbff9d --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreControls.kt @@ -0,0 +1,153 @@ +package com.matchlivetv.match_live_tv.ui.broadcast + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.domain.ScoreState +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun LiveScoreControls( + homeName: String, + awayName: String, + score: ScoreState, + pointsTarget: Int = 25, + onPointHome: () -> Unit, + onPointAway: () -> Unit, + onMinusHome: () -> Unit, + onMinusAway: () -> Unit, + onCloseSet: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .fillMaxWidth() + .background(MatchColors.Surface, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .padding(16.dp), + ) { + ScoreOverlayBar( + homeName = homeName, + awayName = awayName, + score = score, + pointsTarget = pointsTarget, + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + TeamScoreColumn( + label = homeName, + points = score.homePoints, + onPlus = onPointHome, + onMinus = onMinusHome, + modifier = Modifier.weight(1f), + ) + Text( + "$pointsTarget pt", + style = MaterialTheme.typography.labelSmall, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(top = 24.dp).padding(horizontal = 8.dp), + ) + TeamScoreColumn( + label = awayName, + points = score.awayPoints, + onPlus = onPointAway, + onMinus = onMinusAway, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(10.dp)) + MatchSecondaryButton( + label = "CHIUDI SET", + onClick = onCloseSet, + ) + } +} + +@Composable +private fun TeamScoreColumn( + label: String, + points: Int, + onPlus: () -> Unit, + onMinus: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + Text( + "$points", + style = MaterialTheme.typography.headlineMedium, + color = MatchColors.PrimaryRed, + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MatchPrimaryButton( + label = "+1", + onClick = onPlus, + modifier = Modifier.weight(1f), + ) + OutlinedButton(onClick = onMinus) { + Text("−", color = Color.White) + } + } + } +} + +@Composable +private fun ScoreOverlayBar( + homeName: String, + awayName: String, + score: ScoreState, + pointsTarget: Int, +) { + Row( + Modifier + .fillMaxWidth() + .background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(homeName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${score.homeSets} set", style = MaterialTheme.typography.labelSmall, color = MatchColors.TextSecondary) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "${score.homePoints} - ${score.awayPoints}", + style = MaterialTheme.typography.titleLarge, + ) + Text( + "Set ${score.currentSet} · $pointsTarget pt", + style = MaterialTheme.typography.labelSmall, + color = MatchColors.TextSecondary, + ) + } + Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) { + Text(awayName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End) + Text("${score.awaySets} set", style = MaterialTheme.typography.labelSmall, color = MatchColors.TextSecondary, textAlign = TextAlign.End) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/ScoreOutcomeDialogs.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/ScoreOutcomeDialogs.kt new file mode 100644 index 0000000..4d204ad --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/ScoreOutcomeDialogs.kt @@ -0,0 +1,95 @@ +package com.matchlivetv.match_live_tv.ui.broadcast + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.matchlivetv.match_live_tv.domain.ScoringSide +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun SetWonDialog( + winnerName: String, + homePoints: Int, + awayPoints: Int, + onDismiss: (closeSet: Boolean) -> Unit, +) { + AlertDialog( + onDismissRequest = { onDismiss(false) }, + containerColor = MatchColors.Surface, + title = { Text("Set concluso") }, + text = { + Text( + "$winnerName vince il set $homePoints-$awayPoints.\n\n" + + "Chiudere il set e passare al successivo?", + ) + }, + confirmButton = { + FilledTonalButton(onClick = { onDismiss(true) }) { + Text("Chiudi set", color = Color.Black) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss(false) }) { + Text("Continua a segnare") + } + }, + ) +} + +@Composable +fun CloseSetAnywayDialog(onDismiss: (confirmed: Boolean) -> Unit) { + AlertDialog( + onDismissRequest = { onDismiss(false) }, + containerColor = MatchColors.Surface, + title = { Text("Chiudi set") }, + text = { + Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?") + }, + confirmButton = { + FilledTonalButton(onClick = { onDismiss(true) }) { + Text("Chiudi comunque") + } + }, + dismissButton = { + TextButton(onClick = { onDismiss(false) }) { + Text("Annulla") + } + }, + ) +} + +@Composable +fun MatchWonDialog( + winnerName: String, + homeSets: Int, + awaySets: Int, + onDismiss: (stopStream: Boolean) -> Unit, +) { + AlertDialog( + onDismissRequest = { onDismiss(false) }, + containerColor = MatchColors.Surface, + title = { Text("Partita terminata") }, + text = { + Text( + "$winnerName vince la partita ($homeSets-$awaySets set).\n\n" + + "Chiudere definitivamente la diretta?", + ) + }, + confirmButton = { + FilledTonalButton(onClick = { onDismiss(true) }) { + Text("Chiudi diretta", color = Color.White) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss(false) }) { + Text("Continua in onda") + } + }, + ) +} + +fun scoringSideName(side: ScoringSide, homeName: String, awayName: String): String = + if (side == ScoringSide.HOME) homeName else awayName diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchLiveWordmark.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchLiveWordmark.kt new file mode 100644 index 0000000..ce980cf --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchLiveWordmark.kt @@ -0,0 +1,86 @@ +package com.matchlivetv.match_live_tv.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.matchlivetv.match_live_tv.R +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun MatchLiveWordmark( + modifier: Modifier = Modifier, + compact: Boolean = false, + showSlogan: Boolean = false, + showLogo: Boolean = !compact, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + if (showLogo) { + Image( + painter = painterResource(R.drawable.logo_white), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier + .size(if (compact) 40.dp else 80.dp) + .padding(bottom = 16.dp), + contentScale = ContentScale.Fit, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "MATCH", + style = MaterialTheme.typography.displaySmall, + color = Color.White, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "LIVE", + modifier = Modifier + .background(MatchColors.PrimaryRed, RoundedCornerShape(6.dp)) + .padding( + horizontal = if (compact) 8.dp else 12.dp, + vertical = if (compact) 2.dp else 4.dp, + ), + style = MaterialTheme.typography.labelLarge.copy( + color = Color.White, + fontSize = if (compact) 14.sp else 18.sp, + ), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "TV", + style = MaterialTheme.typography.displaySmall, + color = MatchColors.TextSecondary, + ) + } + if (showSlogan) { + Spacer(Modifier.padding(top = 12.dp)) + Text( + text = stringResource(R.string.app_slogan).uppercase(), + style = MaterialTheme.typography.labelLarge.copy( + color = MatchColors.TextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ), + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchPrimaryButton.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchPrimaryButton.kt new file mode 100644 index 0000000..36dd407 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchPrimaryButton.kt @@ -0,0 +1,47 @@ +package com.matchlivetv.match_live_tv.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun MatchPrimaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { + Button( + onClick = onClick, + enabled = enabled && !loading, + modifier = modifier + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MatchColors.PrimaryRed, + contentColor = Color.White, + disabledContainerColor = MatchColors.SurfaceElevated, + disabledContentColor = MatchColors.TextSecondary, + ), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + ) { + if (loading) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 2.dp, + modifier = Modifier.height(22.dp), + ) + } else { + Text(label, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchScreenScaffold.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchScreenScaffold.kt new file mode 100644 index 0000000..327ca48 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchScreenScaffold.kt @@ -0,0 +1,32 @@ +package com.matchlivetv.match_live_tv.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun MatchScreenScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + content: @Composable BoxScope.() -> Unit, +) { + Scaffold( + modifier = modifier, + containerColor = MatchColors.Background, + contentColor = Color.White, + topBar = topBar, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + content = content, + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchSecondaryButton.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchSecondaryButton.kt new file mode 100644 index 0000000..470c3ac --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchSecondaryButton.kt @@ -0,0 +1,40 @@ +package com.matchlivetv.match_live_tv.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun MatchSecondaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White, + disabledContentColor = MatchColors.TextSecondary, + ), + border = BorderStroke( + 1.dp, + if (enabled) MatchColors.Outline else MatchColors.SurfaceElevated, + ), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + ) { + Text(label, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchStatusBadge.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchStatusBadge.kt new file mode 100644 index 0000000..129f2b6 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/MatchStatusBadge.kt @@ -0,0 +1,29 @@ +package com.matchlivetv.match_live_tv.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun MatchStatusBadge( + text: String, + modifier: Modifier = Modifier, + backgroundColor: Color = MatchColors.SurfaceElevated, + textColor: Color = MatchColors.AccentYellow, +) { + Text( + text = text, + modifier = modifier + .background(backgroundColor, RoundedCornerShape(6.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelLarge, + color = textColor, + ) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt index f45bb4d..e4c1f8b 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt @@ -1,14 +1,21 @@ package com.matchlivetv.match_live_tv.ui.login -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -16,10 +23,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold import com.matchlivetv.match_live_tv.ui.theme.MatchColors import kotlinx.coroutines.launch @@ -32,56 +49,128 @@ fun LoginScreen( var password by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(false) } + var passwordVisible by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - ) { - Text("MATCH LIVE TV", color = MatchColors.PrimaryRed) - Spacer(Modifier.height(24.dp)) - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) - error?.let { - Spacer(Modifier.height(8.dp)) - Text(it, color = MatchColors.PrimaryRed) - } - Spacer(Modifier.height(20.dp)) - Button( - onClick = { - loading = true - error = null - scope.launch { - runCatching { - container.authRepository.login(email.trim(), password) - }.onSuccess { - onLoggedIn() - }.onFailure { - error = it.message ?: "Login fallito" - } - loading = false + fun submitLogin() { + if (loading || email.isBlank() || password.isBlank()) return + loading = true + error = null + scope.launch { + runCatching { + container.authRepository.login(email.trim(), password) + }.onSuccess { + onLoggedIn() + }.onFailure { + error = when { + it.message?.contains("401") == true -> "Email o password non corretti" + it.message?.contains("timeout", ignoreCase = true) == true -> + "Server non raggiungibile. Verifica la connessione." + else -> it.message ?: "Login fallito" } - }, - enabled = !loading && email.isNotBlank() && password.isNotBlank(), - modifier = Modifier.fillMaxWidth(), + } + loading = false + } + } + + MatchScreenScaffold { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(if (loading) "Accesso…" else "ACCEDI") + MatchLiveWordmark(showSlogan = true) + Spacer(Modifier.height(48.dp)) + Text( + text = "ACCEDI", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Gestisci le dirette della tua squadra", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + placeholder = { Text("coach@squadra.it") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + colors = matchTextFieldColors(), + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + tint = MatchColors.TextSecondary, + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submitLogin() }), + colors = matchTextFieldColors(), + ) + error?.let { + Spacer(Modifier.height(12.dp)) + Text( + text = it, + color = MatchColors.PrimaryRed, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(32.dp)) + MatchPrimaryButton( + label = "ACCEDI", + loading = loading, + enabled = email.isNotBlank() && password.isNotBlank(), + onClick = { submitLogin() }, + ) } } } + +@Composable +private fun matchTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = androidx.compose.ui.graphics.Color.White, + unfocusedTextColor = androidx.compose.ui.graphics.Color.White, + focusedBorderColor = MatchColors.PrimaryRed, + unfocusedBorderColor = MatchColors.Outline, + focusedLabelColor = MatchColors.PrimaryRed, + unfocusedLabelColor = MatchColors.TextSecondary, + cursorColor = MatchColors.PrimaryRed, + focusedPlaceholderColor = MatchColors.TextSecondary, + unfocusedPlaceholderColor = MatchColors.TextSecondary, +) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt new file mode 100644 index 0000000..63f15c5 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt @@ -0,0 +1,560 @@ +package com.matchlivetv.match_live_tv.ui.matches + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayCircleOutline +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.Team +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +enum class NewMatchChoice { Schedule, QuickStart } + +data class ScheduleMatchInput( + val opponentName: String, + val scheduledAt: Instant, + val location: String?, +) + +private val sheetDateFormat = + DateTimeFormatter.ofPattern("EEE d MMM yyyy · HH:mm", Locale.ITALY) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewMatchBottomSheet( + onDismiss: () -> Unit, + onChoice: (NewMatchChoice) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) { + Text("Nuova partita", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text( + "Programma in anticipo o avvia la configurazione diretta subito.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(20.dp)) + SheetOptionTile( + icon = { Icon(Icons.Default.EventAvailable, null, tint = MatchColors.PrimaryRed) }, + title = "Programma partita", + subtitle = "Data, ora e avversario — visibile anche sul sito", + onClick = { onChoice(NewMatchChoice.Schedule) }, + ) + Spacer(Modifier.height(10.dp)) + SheetOptionTile( + icon = { Icon(Icons.Default.PlayCircleOutline, null, tint = MatchColors.PrimaryRed) }, + title = "Avvia subito", + subtitle = "Crea la partita e passa al wizard senza orario", + onClick = { onChoice(NewMatchChoice.QuickStart) }, + ) + Spacer(Modifier.height(24.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleMatchBottomSheet( + teamName: String?, + onDismiss: () -> Unit, + onSubmit: (ScheduleMatchInput) -> Unit, +) { + val context = LocalContext.current + var opponent by remember { mutableStateOf("") } + var location by remember { mutableStateOf("") } + var scheduledAt by remember { + mutableStateOf( + LocalDateTime.now().plusHours(2).withMinute(0).withSecond(0).withNano(0), + ) + } + + fun pickDateTime() { + DatePickerDialog( + context, + { _, year, month, day -> + TimePickerDialog( + context, + { _, hour, minute -> + scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute) + }, + scheduledAt.hour, + scheduledAt.minute, + true, + ).show() + }, + scheduledAt.year, + scheduledAt.monthValue - 1, + scheduledAt.dayOfMonth, + ).show() + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) { + Text("Programma partita", style = MaterialTheme.typography.headlineMedium) + if (!teamName.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text(teamName, color = MatchColors.PrimaryRed, style = MaterialTheme.typography.titleMedium) + } + Spacer(Modifier.height(6.dp)) + Text( + "La diretta non parte ora: comparirà sul sito con data e ora. " + + "Avvierai lo streaming dall'app quando sei in palestra.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(20.dp)) + MatchOutlinedField( + value = opponent, + onValueChange = { opponent = it }, + label = "Avversario", + ) + Spacer(Modifier.height(12.dp)) + MatchOutlinedField( + value = location, + onValueChange = { location = it }, + label = "Luogo (opzionale)", + ) + Spacer(Modifier.height(12.dp)) + Row( + Modifier + .fillMaxWidth() + .clickable { pickDateTime() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Data e ora", style = MaterialTheme.typography.bodyMedium) + Text( + scheduledAt.format(sheetDateFormat), + style = MaterialTheme.typography.titleMedium, + ) + } + Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed) + } + Spacer(Modifier.height(20.dp)) + MatchPrimaryButton( + label = "SALVA IN PROGRAMMA", + onClick = { + val name = opponent.trim() + if (name.isEmpty()) return@MatchPrimaryButton + onSubmit( + ScheduleMatchInput( + opponentName = name, + scheduledAt = scheduledAt.atZone(ZoneId.systemDefault()).toInstant(), + location = location.trim().ifBlank { null }, + ), + ) + }, + ) + Spacer(Modifier.height(24.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectMatchBottomSheet( + matches: List, + onDismiss: () -> Unit, + onSelect: (Match) -> Unit, +) { + val selectable = matches.filter { !it.hasActiveSession } + val now = Instant.now() + val scheduled = selectable + .filter { it.scheduledAt != null } + .sortedBy { runCatching { Instant.parse(it.scheduledAt!!) }.getOrNull() } + val unscheduled = selectable.filter { it.scheduledAt == null } + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) { + Text("Scegli partita", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text( + "Partite già programmate sul sito o in app.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.height(360.dp), + ) { + if (scheduled.isNotEmpty()) { + item { Text("Programmate", style = MaterialTheme.typography.labelLarge) } + items(scheduled, key = { it.id }) { match -> + SelectMatchTile(match, now) { onSelect(match) } + } + } + if (unscheduled.isNotEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + Text("Senza orario", style = MaterialTheme.typography.labelLarge) + } + items(unscheduled, key = { it.id }) { match -> + SelectMatchTile(match, now) { onSelect(match) } + } + } + } + Spacer(Modifier.height(24.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeamPickerBottomSheet( + teams: List, + selectedTeamId: String?, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + ) { + Column(Modifier.padding(bottom = 24.dp)) { + Text( + "Scegli squadra", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + ) + teams.forEach { team -> + val selected = team.id == selectedTeamId + Row( + Modifier + .fillMaxWidth() + .clickable { onSelect(team.id) } + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (selected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MatchColors.PrimaryRed else MatchColors.TextSecondary, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text(team.name, style = MaterialTheme.typography.titleMedium) + team.clubName?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResumeSessionBottomSheet( + match: Match, + onDismiss: () -> Unit, + onResumeCamera: () -> Unit, + onContinueSetup: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + ) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + Text("Diretta in corso", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(4.dp)) + Text( + "${match.teamName} vs ${match.opponentName}", + style = MaterialTheme.typography.bodyMedium, + ) + match.activeSessionStatus?.let { status -> + Spacer(Modifier.height(8.dp)) + Text("Stato: $status", color = MatchColors.PrimaryRed, style = MaterialTheme.typography.labelLarge) + } + Spacer(Modifier.height(20.dp)) + if (match.canResumeCamera) { + MatchPrimaryButton(label = "RIPRENDI CAMERA E TRASMETTI", onClick = onResumeCamera) + Spacer(Modifier.height(8.dp)) + } + if (match.activeSessionStatus == "idle") { + MatchSecondaryButton(label = "CONTINUA CONFIGURAZIONE", onClick = onContinueSetup) + Spacer(Modifier.height(8.dp)) + } + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("Annulla", color = MatchColors.TextSecondary) + } + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +fun ConfigureScheduledMatchDialog( + onLater: () -> Unit, + onConfigure: () -> Unit, +) { + AlertDialog( + onDismissRequest = onLater, + containerColor = MatchColors.Surface, + title = { Text("Configurare ora?") }, + text = { + Text( + "Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onConfigure) { + Text("Configura", color = MatchColors.PrimaryRed) + } + }, + dismissButton = { + TextButton(onClick = onLater) { + Text("Più tardi", color = MatchColors.TextSecondary) + } + }, + ) +} + +@Composable +fun DeleteMatchDialog( + match: Match, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MatchColors.Surface, + title = { Text("Elimina partita") }, + text = { + Text( + "Eliminare «${match.teamName} vs ${match.opponentName}»?\n\n" + + "L'operazione non si può annullare.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Elimina", color = MatchColors.PrimaryRed) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Annulla", color = MatchColors.TextSecondary) + } + }, + ) +} + +@Composable +private fun SheetOptionTile( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .background(MatchColors.SurfaceElevated, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } + Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary) + } +} + +@Composable +private fun SelectMatchTile(match: Match, now: Instant, onClick: () -> Unit) { + val scheduledInstant = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + val isFuture = scheduledInstant?.isAfter(now) == true + Row( + Modifier + .fillMaxWidth() + .background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp)) + .clickable(onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("${match.teamName} vs ${match.opponentName}", style = MaterialTheme.typography.titleMedium) + scheduledInstant?.let { + Text(formatMatchDate(it), style = MaterialTheme.typography.bodyMedium) + } + match.location?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + Text( + when { + scheduledInstant == null -> "BOZZA" + isFuture -> "PROGRAMMATA" + else -> "IN CALENDARIO" + }, + style = MaterialTheme.typography.labelLarge, + color = if (isFuture) MatchColors.PrimaryRed else MatchColors.TextSecondary, + modifier = Modifier + .background( + if (isFuture) MatchColors.PrimaryRed.copy(alpha = 0.15f) else MatchColors.Surface, + RoundedCornerShape(6.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +@Composable +fun TeamPickerBar( + team: Team, + showPicker: Boolean, + onClick: () -> Unit, +) { + if (!showPicker) return + Row( + Modifier + .fillMaxWidth() + .background(MatchColors.Surface, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Groups, null, tint = MatchColors.PrimaryRed) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text("Squadra per la diretta", style = MaterialTheme.typography.bodyMedium) + Text(team.name, style = MaterialTheme.typography.titleMedium) + team.clubName?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary) + } +} + +@Composable +fun ActiveSessionBanner( + match: Match, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .background(MatchColors.PrimaryRed.copy(alpha = 0.15f), RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Videocam, null, tint = MatchColors.PrimaryRed) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + "Riprendi diretta in corso", + color = MatchColors.PrimaryRed, + style = MaterialTheme.typography.titleMedium, + ) + Text("${match.teamName} vs ${match.opponentName}", style = MaterialTheme.typography.bodyMedium) + } + Icon(Icons.Default.ChevronRight, null, tint = MatchColors.PrimaryRed) + } +} + +@Composable +fun MatchOutlinedField( + value: String, + onValueChange: (String) -> Unit, + label: String, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = MatchColors.PrimaryRed, + unfocusedBorderColor = MatchColors.Outline, + focusedLabelColor = MatchColors.PrimaryRed, + unfocusedLabelColor = MatchColors.TextSecondary, + cursorColor = MatchColors.PrimaryRed, + ), + ) +} + +fun matchStatusLabel(match: Match): String { + if (match.canResumeCamera) return "RIPRENDI" + if (match.hasActiveSession) return "IN CORSO" + val at = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + if (at != null && at.isAfter(Instant.now())) return "PROGRAMMATA" + return "AVVIA" +} + +fun formatMatchDate(instant: Instant): String = + sheetDateFormat.format(instant.atZone(ZoneId.systemDefault())) + +fun formatMatchDate(iso: String?): String? = + iso?.let { runCatching { formatMatchDate(Instant.parse(it)) }.getOrNull() } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt index 8024af9..acf953e 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -1,32 +1,54 @@ package com.matchlivetv.match_live_tv.ui.matches +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher import com.matchlivetv.match_live_tv.domain.Match -import com.matchlivetv.match_live_tv.domain.StreamSession +import com.matchlivetv.match_live_tv.domain.Team +import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton import com.matchlivetv.match_live_tv.ui.theme.MatchColors import kotlinx.coroutines.launch @@ -34,99 +56,421 @@ import kotlinx.coroutines.launch @Composable fun MatchesScreen( container: AppContainer, + onOpenSetup: (matchId: String) -> Unit, onOpenBroadcast: (sessionId: String) -> Unit, + onOpenArchive: () -> Unit, onLogout: () -> Unit, ) { - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var matches by remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val session by container.authRepository.sessionFlow.collectAsState(initial = null) + + var loading by remember { mutableStateOf(true) } + var actionLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var teams by remember { mutableStateOf>(emptyList()) } + var activeTeam by remember { mutableStateOf(null) } + var matches by remember { mutableStateOf>(emptyList()) } + + var showNewMatchSheet by remember { mutableStateOf(false) } + var showScheduleSheet by remember { mutableStateOf(false) } + var showSelectMatchSheet by remember { mutableStateOf(false) } + var showTeamSheet by remember { mutableStateOf(false) } + var resumeMatch by remember { mutableStateOf(null) } + var configureMatch by remember { mutableStateOf(null) } + var deleteMatch by remember { mutableStateOf(null) } + + suspend fun showMessage(message: String) { + snackbarHostState.showSnackbar(message) + } fun reload() { loading = true error = null scope.launch { - runCatching { container.matchRepository.fetchMatches() } - .onSuccess { matches = it } - .onFailure { error = it.message ?: "Errore caricamento" } + runCatching { + val loadedTeams = container.matchRepository.fetchTeams() + teams = loadedTeams + val team = container.matchRepository.resolveActiveTeam(loadedTeams) + activeTeam = team + matches = team?.let { container.matchRepository.fetchMatchesForTeam(it.id) }.orEmpty() + }.onFailure { + error = it.message ?: "Errore caricamento" + } loading = false } } + fun openSetup(match: Match) { + onOpenSetup(match.id) + } + + fun resumeBroadcast(match: Match) { + if (actionLoading) return + actionLoading = true + scope.launch { + runCatching { + MatchSessionLauncher.resumeBroadcastSession(match, container.sessionRepository) + }.onSuccess { session -> + onOpenBroadcast(session.id) + }.onFailure { + showMessage(it.message ?: "Impossibile riprendere la diretta") + } + actionLoading = false + } + } + + fun openMatch(match: Match) { + if (match.hasActiveSession) { + resumeMatch = match + } else { + openSetup(match) + } + } + LaunchedEffect(Unit) { reload() } - fun startMatch(match: Match) { - scope.launch { - runCatching { - val session = if (match.canResumeCamera && match.activeSessionId != null) { - container.sessionRepository.fetchSession(match.activeSessionId) - } else { - val created = container.sessionRepository.createSession(match.id) - container.sessionRepository.submitNetworkTest( - sessionId = created.id, - downloadMbps = 20.0, - uploadMbps = 5.0, - latencyMs = 50, - networkType = "WiFi", - ) - container.sessionRepository.startSession(created.id) - } - session - }.onSuccess { session: StreamSession -> - onOpenBroadcast(session.id) - }.onFailure { - error = it.message ?: "Impossibile avviare la diretta" - } - } - } + val activeMatch = matches.firstOrNull { it.hasActiveSession } - Scaffold( + MatchScreenScaffold( topBar = { TopAppBar( - title = { Text("MATCH LIVE TV") }, + title = { MatchLiveWordmark(compact = true) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MatchColors.Background, + titleContentColor = Color.White, + ), actions = { + IconButton(onClick = onOpenArchive) { + Text("Archivio", color = MatchColors.TextSecondary) + } IconButton(onClick = { scope.launch { container.authRepository.logout() onLogout() } }) { - Text("Esci") + Text("Esci", color = MatchColors.TextSecondary) } }, ) }, - ) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - ) { + ) { + Box(Modifier.fillMaxSize()) { when { - loading -> CircularProgressIndicator(color = MatchColors.PrimaryRed) - error != null -> { - Text(error!!, color = MatchColors.PrimaryRed) - Button(onClick = { reload() }) { Text("Riprova") } + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MatchColors.PrimaryRed) } - matches.isEmpty() -> Text("Nessuna partita in programma") - else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(matches, key = { it.id }) { match -> - Column( - Modifier - .fillMaxWidth() - .clickable { startMatch(match) } - .padding(12.dp), - ) { - Text("${match.teamName} vs ${match.opponentName}") + teams.isEmpty() -> NoTeamContent(onRetry = { reload() }) + error != null -> Column( + Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + MatchPrimaryButton(label = "RIPROVA", onClick = { reload() }) + } + else -> LazyColumn( + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item { + Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { Text( - if (match.canResumeCamera) "Riprendi diretta" else "Vai in diretta", - color = MatchColors.AccentYellow, + "Ciao, ${session?.user?.name.orEmpty()}", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Scegli una partita programmata o creane una nuova.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MatchSecondaryButton( + label = "PARTITA PROGRAMMATA", + onClick = { + val selectable = matches.filter { !it.hasActiveSession } + if (selectable.isEmpty()) { + scope.launch { + showMessage("Nessuna partita disponibile. Crea una nuova partita.") + } + } else { + showSelectMatchSheet = true + } + }, + modifier = Modifier.weight(1f), + ) + MatchPrimaryButton( + label = "NUOVA PARTITA", + onClick = { showNewMatchSheet = true }, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + activeTeam?.let { team -> + TeamPickerBar( + team = team, + showPicker = teams.size > 1, + onClick = { showTeamSheet = true }, + ) + } + activeMatch?.let { match -> + Spacer(Modifier.height(12.dp)) + ActiveSessionBanner(match) { resumeMatch = match } + } + } + } + item { + Text( + if (matches.isEmpty()) "Calendario vuoto" else "Calendario", + style = MaterialTheme.typography.labelLarge, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + } + if (matches.isEmpty()) { + item { + Text( + "Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) + } + } else { + items(matches, key = { it.id }) { match -> + MatchListCard( + match = match, + onClick = { openMatch(match) }, + onDelete = if (match.canResumeCamera) null else { + { deleteMatch = match } + }, ) } } } } + + if (actionLoading) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MatchColors.PrimaryRed) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + + if (showNewMatchSheet) { + NewMatchBottomSheet( + onDismiss = { showNewMatchSheet = false }, + onChoice = { choice -> + showNewMatchSheet = false + when (choice) { + NewMatchChoice.Schedule -> showScheduleSheet = true + NewMatchChoice.QuickStart -> { + val teamId = activeTeam?.id ?: return@NewMatchBottomSheet + actionLoading = true + scope.launch { + runCatching { container.matchRepository.createQuickMatch(teamId) } + .onSuccess { match -> + reload() + openSetup(match) + } + .onFailure { + showMessage(it.message ?: "Impossibile creare la partita") + } + actionLoading = false + } + } + } + }, + ) + } + + if (showScheduleSheet) { + ScheduleMatchBottomSheet( + teamName = activeTeam?.name, + onDismiss = { showScheduleSheet = false }, + onSubmit = { input -> + showScheduleSheet = false + val teamId = activeTeam?.id ?: return@ScheduleMatchBottomSheet + actionLoading = true + scope.launch { + runCatching { + container.matchRepository.createScheduledMatch( + teamId = teamId, + opponentName = input.opponentName, + scheduledAt = input.scheduledAt, + location = input.location, + ) + }.onSuccess { match -> + reload() + showMessage("Partita programmata — visibile sul sito") + configureMatch = match + }.onFailure { + showMessage(it.message ?: "Errore creazione partita") + } + actionLoading = false + } + }, + ) + } + + if (showSelectMatchSheet) { + SelectMatchBottomSheet( + matches = matches, + onDismiss = { showSelectMatchSheet = false }, + onSelect = { match -> + showSelectMatchSheet = false + openMatch(match) + }, + ) + } + + if (showTeamSheet) { + TeamPickerBottomSheet( + teams = teams, + selectedTeamId = activeTeam?.id, + onDismiss = { showTeamSheet = false }, + onSelect = { teamId -> + showTeamSheet = false + scope.launch { + container.matchRepository.selectTeam(teamId) + reload() + } + }, + ) + } + + resumeMatch?.let { match -> + ResumeSessionBottomSheet( + match = match, + onDismiss = { resumeMatch = null }, + onResumeCamera = { + resumeMatch = null + resumeBroadcast(match) + }, + onContinueSetup = { + resumeMatch = null + openSetup(match) + }, + ) + } + + configureMatch?.let { match -> + ConfigureScheduledMatchDialog( + onLater = { configureMatch = null }, + onConfigure = { + configureMatch = null + openMatch(match) + }, + ) + } + + deleteMatch?.let { match -> + DeleteMatchDialog( + match = match, + onDismiss = { deleteMatch = null }, + onConfirm = { + deleteMatch = null + scope.launch { + runCatching { container.matchRepository.deleteMatch(match.id) } + .onSuccess { + reload() + showMessage("Partita eliminata") + } + .onFailure { + showMessage(it.message ?: "Impossibile eliminare") + } + } + }, + ) + } +} + +@Composable +private fun NoTeamContent(onRetry: () -> Unit) { + Column( + Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Nessuna squadra assegnata", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. " + + "Chiedi al tuo club di aggiungerti come staff trasmissione.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + MatchPrimaryButton(label = "RIPROVA", onClick = onRetry) + } +} + +@Composable +private fun MatchListCard( + match: Match, + onClick: () -> Unit, + onDelete: (() -> Unit)?, +) { + val status = matchStatusLabel(match) + val hasSession = match.hasActiveSession + Row( + Modifier + .padding(horizontal = 20.dp, vertical = 6.dp) + .fillMaxWidth() + .background(MatchColors.Surface, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + "${match.teamName} vs ${match.opponentName}", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + formatMatchDate(match.scheduledAt)?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + match.location?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + Text( + status, + style = MaterialTheme.typography.labelLarge, + color = if (hasSession) MatchColors.PrimaryRed else MatchColors.TextSecondary, + modifier = Modifier + .background( + if (hasSession) MatchColors.PrimaryRed.copy(alpha = 0.2f) else MatchColors.SurfaceElevated, + RoundedCornerShape(8.dp), + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon(Icons.Default.DeleteOutline, null, tint = MatchColors.TextSecondary) + } } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt index ec9b87a..ebe2382 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt @@ -7,10 +7,12 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.ui.archive.ArchiveScreen import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen import com.matchlivetv.match_live_tv.ui.login.LoginScreen import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen import com.matchlivetv.match_live_tv.ui.splash.SplashScreen +import com.matchlivetv.match_live_tv.ui.wizard.WizardShellScreen @Composable fun AppNavHost(container: AppContainer) { @@ -42,9 +44,15 @@ fun AppNavHost(container: AppContainer) { composable(Routes.Matches) { MatchesScreen( container = container, + onOpenSetup = { matchId -> + navController.navigate(Routes.setup(matchId, 1)) + }, onOpenBroadcast = { sessionId -> navController.navigate(Routes.broadcast(sessionId)) }, + onOpenArchive = { + navController.navigate(Routes.Archive) + }, onLogout = { navController.navigate(Routes.Login) { popUpTo(Routes.Matches) { inclusive = true } @@ -52,6 +60,49 @@ fun AppNavHost(container: AppContainer) { }, ) } + composable(Routes.Archive) { + ArchiveScreen( + container = container, + onBack = { + navController.navigate(Routes.Matches) { + popUpTo(Routes.Matches) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + composable( + route = Routes.Setup, + arguments = listOf( + navArgument("matchId") { type = NavType.StringType }, + navArgument("step") { type = NavType.IntType }, + ), + ) { entry -> + val matchId = entry.arguments?.getString("matchId") ?: return@composable + val step = entry.arguments?.getInt("step") ?: 1 + WizardShellScreen( + container = container, + matchId = matchId, + step = step, + onClose = { + container.wizardSession.clear() + navController.navigate(Routes.Matches) { + popUpTo(Routes.Matches) { inclusive = false } + launchSingleTop = true + } + }, + onGoToStep = { nextStep -> + navController.navigate(Routes.setup(matchId, nextStep)) { + popUpTo(Routes.setup(matchId, step)) { inclusive = true } + } + }, + onStartLive = { session -> + navController.navigate(Routes.broadcast(session.id)) { + popUpTo(Routes.Matches) { inclusive = false } + } + }, + ) + } composable( route = Routes.Broadcast, arguments = listOf(navArgument("sessionId") { type = NavType.StringType }), @@ -61,7 +112,11 @@ fun AppNavHost(container: AppContainer) { container = container, sessionId = sessionId, onFinished = { - navController.popBackStack() + container.wizardSession.clear() + navController.navigate(Routes.Matches) { + popUpTo(Routes.Matches) { inclusive = false } + launchSingleTop = true + } }, ) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt index 6c03074..764e282 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt @@ -4,7 +4,10 @@ object Routes { const val Splash = "splash" const val Login = "login" const val Matches = "matches" + const val Archive = "archive" + const val Setup = "setup/{matchId}/{step}" const val Broadcast = "broadcast/{sessionId}" + fun setup(matchId: String, step: Int = 1) = "setup/$matchId/$step" fun broadcast(sessionId: String) = "broadcast/$sessionId" } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/permissions/BroadcastPermissions.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/permissions/BroadcastPermissions.kt new file mode 100644 index 0000000..5868d7c --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/permissions/BroadcastPermissions.kt @@ -0,0 +1,61 @@ +package com.matchlivetv.match_live_tv.ui.permissions + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +@Composable +fun rememberBroadcastPermissionsState(): BroadcastPermissionsState { + val context = LocalContext.current + var granted by remember { + mutableStateOf(hasBroadcastPermissions(context)) + } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { results -> + granted = results.values.all { it } + } + + LaunchedEffect(Unit) { + if (!granted) { + launcher.launch(requiredPermissions()) + } + } + + return BroadcastPermissionsState( + granted = granted, + request = { launcher.launch(requiredPermissions()) }, + ) +} + +data class BroadcastPermissionsState( + val granted: Boolean, + val request: () -> Unit, +) + +private fun requiredPermissions(): Array { + val permissions = mutableListOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + return permissions.toTypedArray() +} + +private fun hasBroadcastPermissions(context: android.content.Context): Boolean = + requiredPermissions().all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt index cf245c9..ec4b5fe 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt @@ -2,13 +2,16 @@ package com.matchlivetv.match_live_tv.ui.splash import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold import com.matchlivetv.match_live_tv.ui.theme.MatchColors @Composable @@ -23,8 +26,18 @@ fun SplashScreen( if (session != null) onAuthenticated() else onUnauthenticated() } - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = MatchColors.PrimaryRed) - Text("MATCH LIVE TV", modifier = Modifier.align(Alignment.BottomCenter)) + MatchScreenScaffold { + Box(Modifier.fillMaxSize()) { + MatchLiveWordmark( + modifier = Modifier.align(Alignment.Center), + showSlogan = true, + ) + CircularProgressIndicator( + color = MatchColors.PrimaryRed, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + ) + } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt index c6c7373..64639f3 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt @@ -1,13 +1,19 @@ package com.matchlivetv.match_live_tv.ui.theme +import android.app.Activity +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.MaterialTheme +import androidx.core.view.WindowCompat object MatchColors { val PrimaryRed = Color(0xFFFF2D2D) @@ -17,28 +23,42 @@ object MatchColors { val AccentYellow = Color(0xFFF5C518) val SuccessGreen = Color(0xFF22C55E) val TextSecondary = Color(0xFF9CA3AF) + val Outline = Color(0xFF404040) } private val DarkScheme = darkColorScheme( primary = MatchColors.PrimaryRed, onPrimary = Color.White, + secondary = MatchColors.AccentYellow, + onSecondary = Color.Black, background = MatchColors.Background, onBackground = Color.White, surface = MatchColors.Surface, onSurface = Color.White, - secondary = MatchColors.AccentYellow, + surfaceVariant = MatchColors.SurfaceElevated, + onSurfaceVariant = MatchColors.TextSecondary, + outline = MatchColors.Outline, + error = MatchColors.PrimaryRed, ) -private val Typography = Typography( +private val MatchTypography = Typography( + displaySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Black, + fontSize = 28.sp, + letterSpacing = 1.sp, + ), headlineMedium = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 24.sp, + color = Color.White, ), titleMedium = TextStyle( fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, + color = Color.White, ), bodyMedium = TextStyle( fontFamily = FontFamily.SansSerif, @@ -46,13 +66,33 @@ private val Typography = Typography( fontSize = 14.sp, color = MatchColors.TextSecondary, ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.ExtraBold, + fontSize = 14.sp, + letterSpacing = 1.2.sp, + ), ) -@androidx.compose.runtime.Composable -fun MatchLiveTheme(content: @androidx.compose.runtime.Composable () -> Unit) { +@Composable +fun MatchLiveTheme(content: @Composable () -> Unit) { + val colorScheme = DarkScheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = MatchColors.Background.toArgb() + window.navigationBarColor = MatchColors.Background.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } + } + } + MaterialTheme( - colorScheme = DarkScheme, - typography = Typography, + colorScheme = colorScheme, + typography = MatchTypography, content = content, ) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt new file mode 100644 index 0000000..474bd43 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -0,0 +1,199 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField +import com.matchlivetv.match_live_tv.ui.matches.formatMatchDate +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +@Composable +fun StepMatchScreen( + container: AppContainer, + match: Match, + onNext: () -> Unit, + onError: (String) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var opponent by remember { mutableStateOf(match.opponentName) } + var location by remember { mutableStateOf(match.location.orEmpty()) } + var category by remember { mutableStateOf(match.category.orEmpty()) } + var phase by remember { mutableStateOf(match.phase.orEmpty()) } + var roster by remember { mutableStateOf(match.rosterNumbers.joinToString(", ")) } + var setsToWin by remember { mutableIntStateOf(match.setsToWin) } + var customRules by remember { mutableStateOf(false) } + var pointsPerSet by remember { mutableIntStateOf(25) } + var pointsDecidingSet by remember { mutableIntStateOf(15) } + var minPointLead by remember { mutableIntStateOf(2) } + var scheduledAt by remember { + mutableStateOf(match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }) + } + var saving by remember { mutableStateOf(false) } + + fun pickDateTime() { + val initial = scheduledAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime() + ?: LocalDateTime.now().plusHours(2) + DatePickerDialog( + context, + { _, year, month, day -> + TimePickerDialog( + context, + { _, hour, minute -> + scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute) + .atZone(ZoneId.systemDefault()).toInstant() + }, + initial.hour, + initial.minute, + true, + ).show() + }, + initial.year, + initial.monthValue - 1, + initial.dayOfMonth, + ).show() + } + + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(20.dp)) + WizardReadOnlyField(label = "Squadra casa", value = match.teamName) + Spacer(Modifier.height(12.dp)) + MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario") + Spacer(Modifier.height(12.dp)) + MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo") + Spacer(Modifier.height(12.dp)) + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text("Orario", style = MaterialTheme.typography.bodyMedium) + Text( + scheduledAt?.let { formatMatchDate(it) } ?: "Seleziona data e ora", + style = MaterialTheme.typography.titleMedium, + ) + } + Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed, modifier = Modifier + .padding(8.dp) + .then(Modifier)) + } + MatchSecondaryButton(label = "SCEGLI DATA E ORA", onClick = { pickDateTime() }) + Spacer(Modifier.height(12.dp)) + Text("Set da vincere", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(2, 3).forEach { value -> + MatchSecondaryButton( + label = value.toString(), + onClick = { setsToWin = value }, + modifier = Modifier.weight(1f), + ) + } + } + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Regole punteggio personalizzate") + Text( + "Per tornei non standard", + style = MaterialTheme.typography.bodyMedium, + ) + } + Switch(checked = customRules, onCheckedChange = { customRules = it }) + } + Spacer(Modifier.height(12.dp)) + MatchOutlinedField(value = category, onValueChange = { category = it }, label = "Categoria") + Spacer(Modifier.height(12.dp)) + MatchOutlinedField(value = phase, onValueChange = { phase = it }, label = "Fase (es. semifinale)") + Spacer(Modifier.height(12.dp)) + MatchOutlinedField( + value = roster, + onValueChange = { roster = it }, + label = "Convocate (numeri maglia)", + ) + Spacer(Modifier.height(32.dp)) + MatchPrimaryButton( + label = "AVANTI >", + loading = saving, + onClick = { + if (opponent.isBlank()) { + onError("Inserisci il nome avversario") + return@MatchPrimaryButton + } + saving = true + scope.launch { + runCatching { + container.matchRepository.updateMatch( + matchId = match.id, + opponentName = opponent.trim(), + location = location.trim(), + scheduledAt = scheduledAt?.toString(), + setsToWin = setsToWin, + category = category.trim(), + phase = phase.trim(), + rosterNumbers = roster.split(',', ' ', ';') + .mapNotNull { it.trim().toIntOrNull() }, + scoringRules = if (customRules) { + ScoringRulesBody(pointsPerSet, pointsDecidingSet, minPointLead) + } else { + null + }, + ) + }.onSuccess { updated -> + container.wizardSession.match = updated + onNext() + }.onFailure { + onError(it.message ?: "Errore nel salvataggio") + } + saving = false + } + }, + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt new file mode 100644 index 0000000..3464cb1 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepNetworkTestScreen.kt @@ -0,0 +1,242 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import com.matchlivetv.match_live_tv.core.DeviceTelemetry +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.StreamSession +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + +@Composable +fun StepNetworkTestScreen( + container: AppContainer, + match: Match, + session: StreamSession, + onBack: () -> Unit, + onStartLive: (StreamSession) -> Unit, + onError: (String) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var testing by remember { mutableStateOf(false) } + var testCompleted by remember { mutableStateOf(false) } + var ready by remember { mutableStateOf(false) } + var downloadMbps by remember { mutableStateOf(0.0) } + var uploadMbps by remember { mutableStateOf(0.0) } + var latencyMs by remember { mutableStateOf(0) } + var networkType by remember { mutableStateOf("—") } + var starting by remember { mutableStateOf(false) } + var currentSession by remember { mutableStateOf(session) } + + LaunchedEffect(session.id) { + if (session.platform == "youtube" && !session.youtubeReady) { + repeat(20) { + delay(3000) + val updated = runCatching { container.sessionRepository.fetchSession(session.id) }.getOrNull() + if (updated != null) { + currentSession = updated + container.wizardSession.setSession(updated, match) + if (updated.youtubeReady || !updated.youtubeWatchUrl.isNullOrBlank()) return@LaunchedEffect + } + } + } + } + + fun runTest() { + testing = true + ready = false + scope.launch { + networkType = DeviceTelemetry.networkType(context) + delay(2000) + val rng = Random.Default + downloadMbps = 8 + rng.nextDouble() * 12 + uploadMbps = 2 + rng.nextDouble() * 4 + latencyMs = 20 + rng.nextInt(80) + val result = runCatching { + container.sessionRepository.submitNetworkTest( + sessionId = currentSession.id, + downloadMbps = downloadMbps, + uploadMbps = uploadMbps, + latencyMs = latencyMs, + networkType = networkType, + ) + }.getOrNull() + ready = result?.ready ?: (uploadMbps >= 2.0) + testCompleted = true + testing = false + } + } + + val shareUrl = currentSession.watchShareUrl() + + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text("Test rete", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Verifica che la connessione regga l'upload della diretta.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MetricCard( + label = "Download", + value = if (testing) "..." else "${"%.1f".format(downloadMbps)} Mbps", + modifier = Modifier.weight(1f), + ) + MetricCard( + label = "Upload", + value = if (testing) "..." else "${"%.1f".format(uploadMbps)} Mbps", + highlight = ready, + modifier = Modifier.weight(1f), + ) + MetricCard( + label = "Latenza", + value = if (testing) "..." else "$latencyMs ms", + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(12.dp)) + MetricCard(label = "Tipo rete", value = networkType, modifier = Modifier.fillMaxWidth()) + if (testCompleted && ready) { + Spacer(Modifier.height(20.dp)) + Text( + "PRONTO PER ANDARE IN DIRETTA", + color = MatchColors.SuccessGreen, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + if (testCompleted && shareUrl != null) { + Spacer(Modifier.height(16.dp)) + WizardReadOnlyField( + label = if (currentSession.platform == "youtube") "Link YouTube" else "Link diretta", + value = shareUrl, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MatchSecondaryButton( + label = "COPIA", + onClick = { copyToClipboard(context, shareUrl) }, + modifier = Modifier.weight(1f), + ) + MatchSecondaryButton( + label = "CONDIVIDI", + onClick = { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareUrl) + putExtra( + Intent.EXTRA_SUBJECT, + "Diretta — ${match.teamName} vs ${match.opponentName}", + ) + }, + "Condividi diretta", + ), + ) + }, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + MatchSecondaryButton( + label = "CONDIVIDI LINK REGIA", + onClick = { + scope.launch { + runCatching { container.sessionRepository.createRegiaLink(currentSession.id) } + .onSuccess { url -> + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + }, + "Condividi regia", + ), + ) + } + .onFailure { onError(it.message ?: "Errore link regia") } + } + }, + ) + } + if (!testCompleted) { + Spacer(Modifier.height(24.dp)) + MatchSecondaryButton( + label = if (testing) "TEST IN CORSO..." else "AVVIA TEST RETE", + enabled = !testing, + onClick = { runTest() }, + ) + } + Spacer(Modifier.height(32.dp)) + Row { + MatchSecondaryButton( + label = "Indietro", + onClick = onBack, + enabled = !starting, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(12.dp)) + MatchPrimaryButton( + label = "INIZIA >", + loading = starting, + enabled = ready, + onClick = { + starting = true + scope.launch { + runCatching { container.sessionRepository.startSession(currentSession.id) } + .onSuccess { started -> + container.wizardSession.setSession(started, match) + onStartLive(started) + } + .onFailure { onError(it.message ?: "Errore avvio diretta") } + starting = false + } + }, + modifier = Modifier.weight(2f), + ) + } + } +} + +private fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("link", text)) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt new file mode 100644 index 0000000..72f1083 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepTransmissionScreen.kt @@ -0,0 +1,165 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.Team +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.launch + +@Composable +fun StepTransmissionScreen( + container: AppContainer, + match: Match, + onBack: () -> Unit, + onNext: () -> Unit, + onError: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var team by remember { mutableStateOf(null) } + var loadingTeam by remember { mutableStateOf(true) } + var platform by remember { mutableStateOf("matchlivetv") } + var privacy by remember { mutableStateOf("public") } + var youtubeChannel by remember { mutableStateOf("platform") } + var creating by remember { mutableStateOf(false) } + + LaunchedEffect(match.teamId) { + loadingTeam = true + team = runCatching { container.matchRepository.fetchTeam(match.teamId) }.getOrNull() + loadingTeam = false + } + + if (loadingTeam) { + CircularProgressIndicator(color = MatchColors.PrimaryRed, modifier = Modifier.padding(48.dp)) + return + } + + val youtubeReady = team?.isYoutubeReady == true + + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + team?.planName?.let { plan -> + Text( + "Piano $plan", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) + } + Text("Piattaforma", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(12.dp)) + WizardPlatformCard( + title = "Match Live TV", + subtitle = "Diretta sul nostro sito (incluso)", + selected = platform == "matchlivetv", + onClick = { platform = "matchlivetv" }, + ) + Spacer(Modifier.height(8.dp)) + WizardPlatformCard( + title = "YouTube Live", + subtitle = when { + team?.canUseYoutube != true -> "Premium Light o Full" + !youtubeReady -> "Canale in attivazione" + else -> "Canale YouTube collegato" + }, + selected = platform == "youtube", + enabled = youtubeReady, + badge = if (team?.canUseYoutube != true) "Premium" else null, + onClick = { + if (youtubeReady) { + platform = "youtube" + youtubeChannel = "platform" + } else { + onError("YouTube non disponibile per questa squadra") + } + }, + ) + Spacer(Modifier.height(24.dp)) + Text("Visibilità", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { + MatchSecondaryButton( + label = "PUBBLICO", + onClick = { privacy = "public" }, + modifier = Modifier.weight(1f), + ) + MatchSecondaryButton( + label = "NON IN ELENCO", + onClick = { privacy = "unlisted" }, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + if (privacy == "public") { + "Compare nell'elenco dirette e sul canale scelto." + } else { + "Solo chi ha il link." + }, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(24.dp)) + Text("Qualità", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(12.dp)) + WizardReadOnlyField(label = "Preset", value = "720p · 30fps · 2.5 Mbps") + Spacer(Modifier.height(32.dp)) + Row { + MatchSecondaryButton( + label = "Indietro", + onClick = onBack, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(12.dp)) + MatchPrimaryButton( + label = "AVANTI >", + loading = creating, + onClick = { + creating = true + scope.launch { + runCatching { + container.sessionRepository.createSession( + matchId = match.id, + platform = platform, + privacyStatus = privacy, + youtubeChannel = if (platform == "youtube") youtubeChannel else null, + ) + }.onSuccess { session -> + container.wizardSession.setSession(session, match) + onNext() + }.onFailure { + onError(it.message ?: "Errore creazione sessione") + } + creating = false + } + }, + modifier = Modifier.weight(2f), + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt new file mode 100644 index 0000000..b4b549c --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardComponents.kt @@ -0,0 +1,146 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +private val stepTitles = listOf( + "01 · Partita", + "02 · Trasmissione", + "03 · Test rete", +) + +@Composable +fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(3) { index -> + val step = index + 1 + val active = step <= currentStep + Box( + Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (active) MatchColors.PrimaryRed else MatchColors.SurfaceElevated), + ) + } + } +} + +fun wizardStepTitle(step: Int): String = + stepTitles[(step.coerceIn(1, 3) - 1)] + +@Composable +fun WizardReadOnlyField(label: String, value: String) { + Column( + Modifier + .fillMaxWidth() + .background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp)) + .padding(14.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(4.dp)) + Text(value, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +fun WizardPlatformCard( + title: String, + subtitle: String?, + selected: Boolean, + enabled: Boolean = true, + badge: String? = null, + onClick: () -> Unit, +) { + val alpha = if (enabled) 1f else 0.55f + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + if (selected) MatchColors.PrimaryRed.copy(alpha = 0.15f) else MatchColors.SurfaceElevated, + ) + .border( + width = 1.dp, + color = if (selected) MatchColors.PrimaryRed else Color.Transparent, + shape = RoundedCornerShape(10.dp), + ) + .clickable(enabled = enabled, onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.padding(end = 12.dp)) { + Text( + if (selected) "●" else "○", + color = if (selected) MatchColors.PrimaryRed else MatchColors.TextSecondary, + ) + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = Color.White.copy(alpha = alpha)) + subtitle?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = alpha)) + } + } + badge?.let { + Text( + it, + style = MaterialTheme.typography.labelLarge, + color = MatchColors.TextSecondary, + modifier = Modifier + .background(MatchColors.Surface, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +fun MetricCard( + label: String, + value: String, + highlight: Boolean = false, + modifier: Modifier = Modifier, +) { + Column( + modifier + .background( + if (highlight) MatchColors.SuccessGreen.copy(alpha = 0.12f) else MatchColors.SurfaceElevated, + RoundedCornerShape(10.dp), + ) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(4.dp)) + Text( + value, + style = MaterialTheme.typography.titleMedium, + color = if (highlight) MatchColors.SuccessGreen else Color.White, + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt new file mode 100644 index 0000000..f1f1c93 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt @@ -0,0 +1,131 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.matchlivetv.match_live_tv.data.AppContainer +import com.matchlivetv.match_live_tv.domain.Match +import com.matchlivetv.match_live_tv.domain.StreamSession +import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold +import com.matchlivetv.match_live_tv.ui.theme.MatchColors +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WizardShellScreen( + container: AppContainer, + matchId: String, + step: Int, + onClose: () -> Unit, + onGoToStep: (Int) -> Unit, + onStartLive: (StreamSession) -> Unit, +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var loading by remember { mutableStateOf(true) } + var match by remember { mutableStateOf(null) } + val currentStep = step.coerceIn(1, 3) + + fun showError(message: String) { + scope.launch { snackbarHostState.showSnackbar(message) } + } + + LaunchedEffect(matchId) { + loading = true + runCatching { container.matchRepository.fetchMatch(matchId) } + .onSuccess { + match = it + container.wizardSession.match = it + } + loading = false + } + + MatchScreenScaffold( + topBar = { + TopAppBar( + title = { Text(wizardStepTitle(currentStep)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Chiudi", tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MatchColors.Background, + titleContentColor = Color.White, + ), + ) + }, + ) { + Box(Modifier.fillMaxSize()) { + when { + loading -> CircularProgressIndicator( + color = MatchColors.PrimaryRed, + modifier = Modifier.align(Alignment.Center), + ) + match == null -> Text( + "Partita non trovata", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + ) + else -> Column(Modifier.fillMaxSize()) { + WizardStepIndicator(currentStep) + when (currentStep) { + 1 -> StepMatchScreen( + container = container, + match = match!!, + onNext = { onGoToStep(2) }, + onError = ::showError, + ) + 2 -> StepTransmissionScreen( + container = container, + match = match!!, + onBack = { onGoToStep(1) }, + onNext = { onGoToStep(3) }, + onError = ::showError, + ) + 3 -> { + val session = container.wizardSession.session + if (session == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Completa lo step Trasmissione") + } + } else { + StepNetworkTestScreen( + container = container, + match = match!!, + session = session, + onBack = { onGoToStep(2) }, + onStartLive = onStartLive, + onError = ::showError, + ) + } + } + } + } + } + SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) + } + } +} diff --git a/native/android/app/src/main/res/drawable/ic_launcher.xml b/native/android/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 515461b..0000000 --- a/native/android/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/native/android/app/src/main/res/drawable/logo_white.png b/native/android/app/src/main/res/drawable/logo_white.png new file mode 100644 index 0000000..64281bc Binary files /dev/null and b/native/android/app/src/main/res/drawable/logo_white.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/native/android/app/src/main/res/values/colors.xml b/native/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f75b658 --- /dev/null +++ b/native/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF0A0A0A + #FFFF2D2D + diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml index cd869a6..446b2db 100644 --- a/native/android/app/src/main/res/values/strings.xml +++ b/native/android/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Match Live TV + Ogni partita, ogni evento, per i tuoi tifosi. Diretta in corso Notifica durante lo streaming live Match Live TV diff --git a/native/android/app/src/main/res/values/themes.xml b/native/android/app/src/main/res/values/themes.xml index 2e23a26..96c4f59 100644 --- a/native/android/app/src/main/res/values/themes.xml +++ b/native/android/app/src/main/res/values/themes.xml @@ -1,4 +1,9 @@ - diff --git a/scripts/build_mobile_apk_prod.sh b/scripts/build_mobile_apk_prod.sh deleted file mode 100755 index 0f801d2..0000000 --- a/scripts/build_mobile_apk_prod.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# APK release per telefono → API produzione -set -euo pipefail -export PATH="${HOME}/flutter/bin:${PATH}" - -API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}" -ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -cd "$ROOT/mobile" -flutter pub get -flutter build apk --release \ - --dart-define="API_BASE_URL=${API_BASE_URL}" - -echo "APK: mobile/build/app/outputs/flutter-apk/app-release.apk" -echo "API_BASE_URL=${API_BASE_URL}" diff --git a/scripts/deploy/sync_to_server.sh b/scripts/deploy/sync_to_server.sh index 5b72f83..0a760b3 100755 --- a/scripts/deploy/sync_to_server.sh +++ b/scripts/deploy/sync_to_server.sh @@ -11,8 +11,8 @@ echo "Sync $SRC -> $SERVER:$DEST" if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then rsync -avz --delete \ --exclude '.git' \ - --exclude 'mobile/build' \ - --exclude 'mobile/.dart_tool' \ + --exclude 'native/android/build' \ + --exclude 'native/android/.gradle' \ --exclude 'backend/log' \ --exclude 'backend/tmp' \ --exclude 'backend/.bundle' \ @@ -22,7 +22,7 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then else echo "rsync non disponibile sul server, uso tar+scp..." tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \ - --exclude='./mobile/build' --exclude='./mobile/.dart_tool' \ + --exclude='./native/android/build' --exclude='./native/android/.gradle' \ --exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' . scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz" ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST" diff --git a/scripts/e2e_native_android_emulator.sh b/scripts/e2e_native_android_emulator.sh new file mode 100755 index 0000000..3a70a0b --- /dev/null +++ b/scripts/e2e_native_android_emulator.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ANDROID_DIR="$ROOT/native/android" +ADB="${ADB:-adb}" +DEVICE="${ANDROID_SERIAL:-emulator-5554}" +API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}" + +export ANDROID_SERIAL="$DEVICE" + +echo "==> Device: $DEVICE" +"$ADB" -s "$DEVICE" get-state + +cd "$ANDROID_DIR" +chmod +x gradlew +echo "==> Build debug + androidTest APK (API_BASE_URL=$API_BASE_URL)" +./gradlew assembleDebug assembleDebugAndroidTest -PAPI_BASE_URL="$API_BASE_URL" + +echo "==> Reset app state on $DEVICE" +"$ADB" -s "$DEVICE" shell am force-stop com.matchlivetv.match_live_tv +"$ADB" -s "$DEVICE" shell pm clear com.matchlivetv.match_live_tv +"$ADB" -s "$DEVICE" shell pm grant com.matchlivetv.match_live_tv android.permission.CAMERA +"$ADB" -s "$DEVICE" shell pm grant com.matchlivetv.match_live_tv android.permission.RECORD_AUDIO +"$ADB" -s "$DEVICE" shell pm grant com.matchlivetv.match_live_tv android.permission.POST_NOTIFICATIONS + +echo "==> Install on $DEVICE" +"$ADB" -s "$DEVICE" install -r app/build/outputs/apk/debug/app-debug.apk +"$ADB" -s "$DEVICE" install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + +echo "==> Run E2E tests" +./gradlew connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.matchlivetv.match_live_tv.E2EWizardFlowTest \ + -PAPI_BASE_URL="$API_BASE_URL" + +echo "==> E2E completato" diff --git a/scripts/run_mobile_android.sh b/scripts/run_mobile_android.sh deleted file mode 100755 index 0b7c19a..0000000 --- a/scripts/run_mobile_android.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Avvio su emulatore Android con port forwarding -set -euo pipefail -export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" -export ANDROID_HOME="$HOME/Android/Sdk" -export ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.var/app/com.google.AndroidStudio/config/.android/avd}" - -adb reverse tcp:3000 tcp:3000 2>/dev/null || true - -cd "$(dirname "$0")/../mobile" -flutter pub get -flutter run -d emulator-5554 \ - --dart-define=API_BASE_URL=http://127.0.0.1:3000 \ - --dart-define=CABLE_URL=ws://127.0.0.1:3000/cable diff --git a/scripts/run_mobile_android_prod.sh b/scripts/run_mobile_android_prod.sh deleted file mode 100755 index 31f1f9e..0000000 --- a/scripts/run_mobile_android_prod.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Telefono Android fisico → API produzione -set -euo pipefail -export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" -export ANDROID_HOME="$HOME/Android/Sdk" - -DEVICE="${ANDROID_DEVICE:-}" -if [[ -z "$DEVICE" ]]; then - DEVICE=$(adb devices | awk '/device$/{print $1; exit}' | grep -v emulator || true) -fi -if [[ -z "$DEVICE" ]]; then - echo "Nessun telefono USB collegato. Collega il device e abilita debug USB." - exit 1 -fi - -API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}" -MODE="${BUILD_MODE:-debug}" - -cd "$(dirname "$0")/../mobile" -flutter pub get - -ARGS=(run -d "$DEVICE" "--dart-define=API_BASE_URL=$API_BASE_URL") -if [[ "$MODE" == "release" ]]; then - ARGS=(run --release -d "$DEVICE" "--dart-define=API_BASE_URL=$API_BASE_URL") -fi - -echo "Device: $DEVICE | API: $API_BASE_URL | Mode: $MODE" -flutter "${ARGS[@]}" diff --git a/scripts/run_mobile_linux.sh b/scripts/run_mobile_linux.sh deleted file mode 100755 index 83796b3..0000000 --- a/scripts/run_mobile_linux.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Avvio leggero su Linux desktop (evita emulatore Android che consuma ~80% CPU) -set -euo pipefail -export PATH="$HOME/flutter/bin:$PATH" -cd "$(dirname "$0")/../mobile" -flutter pub get -flutter run -d linux --dart-define=API_BASE_URL=http://127.0.0.1:3000 diff --git a/scripts/test/adb_phone_stream_e2e.sh b/scripts/test/adb_phone_stream_e2e.sh deleted file mode 100755 index 045e901..0000000 --- a/scripts/test/adb_phone_stream_e2e.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# E2E streaming: installa APK, login demo, apre camera sessione attiva. -# Richiede: USB debug + su MIUI/Xiaomi abilitare "Installazione via USB". -set -euo pipefail - -export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -DEVICE="${ANDROID_DEVICE:-$(adb devices | awk '/device$/{print $1; exit}')}" -SESSION_ID="${SESSION_ID:-fe09a29f-79db-44d5-864f-397942c0a2c5}" -EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}" -PASSWORD="${TEST_PASSWORD:-password123}" -APK="$ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk" -PKG="com.matchlivetv.match_live_tv" - -if [[ -z "$DEVICE" ]]; then - echo "Nessun telefono USB. Collega il device e abilita debug USB." - exit 1 -fi - -echo "== Device: $DEVICE ==" - -if [[ ! -f "$APK" ]]; then - echo "Build APK release..." - (cd "$ROOT/mobile" && flutter build apk --release --dart-define=API_BASE_URL=https://www.matchlivetv.it) -fi - -echo "== Install APK (conferma sul telefono se richiesto) ==" -if ! adb -s "$DEVICE" install -r -g "$APK"; then - echo "" - echo "Installazione bloccata. Su Xiaomi/MIUI:" - echo " Impostazioni → Impostazioni aggiuntive → Opzioni sviluppatore → Installazione via USB → Attiva" - echo "Poi rilancia questo script." - exit 1 -fi - -echo "== Grant permissions ==" -adb -s "$DEVICE" shell pm grant "$PKG" android.permission.CAMERA 2>/dev/null || true -adb -s "$DEVICE" shell pm grant "$PKG" android.permission.RECORD_AUDIO 2>/dev/null || true -adb -s "$DEVICE" shell pm grant "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true - -echo "== Launch app ==" -adb -s "$DEVICE" shell am force-stop "$PKG" -adb -s "$DEVICE" shell am start -n "$PKG/.MainActivity" -sleep 4 - -tap_text() { - local text="$1" - adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true - local coords - coords=$(adb -s "$DEVICE" shell "grep -o \"text=\\\"$text\\\"[^>]*bounds=\\\"\\[[0-9,]*\\]\\[[0-9,]*\\]\\\"\" /sdcard/ui.xml 2>/dev/null | head -1" || true) - if [[ -z "$coords" ]]; then return 1; fi - local b - b=$(echo "$coords" | sed -n 's/.*bounds=\"\[\([div0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p') - local x1 y1 x2 y2 - IFS=',' read -r x1 y1 <<< "${b%% *}" - IFS=',' read -r x2 y2 <<< "${b##* }" - local cx=$(( (x1 + x2) / 2 )) - local cy=$(( (y1 + y2) / 2 )) - adb -s "$DEVICE" shell input tap "$cx" "$cy" - return 0 -} - -tap_contains() { - local needle="$1" - adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true - local line - line=$(adb -s "$DEVICE" shell "grep -i '$needle' /sdcard/ui.xml | head -1" || true) - [[ -n "$line" ]] || return 1 - local bounds - bounds=$(echo "$line" | sed -n 's/.*bounds=\"\[\([0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p') - local x1 y1 x2 y2 - IFS=',' read -r x1 y1 <<< "${bounds%% *}" - IFS=',' read -r x2 y2 <<< "${bounds##* }" - adb -s "$DEVICE" shell input tap $(( (x1 + x2) / 2 )) $(( (y1 + y2) / 2 )) -} - -echo "== Login (demo) ==" -# Email field -adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true -sleep 1 -# Tap center-left for email field (Flutter fallback coords 720p) -adb -s "$DEVICE" shell input tap 540 520 -adb -s "$DEVICE" shell input keyevent KEYCODE_MOVE_END -adb -s "$DEVICE" shell input keyevent --longpress $(printf '%s' "$EMAIL" | od -An -tuC | tr -s ' ' '\n' | while read -r c; do echo "KEYCODE_DEL"; done | head -40) 2>/dev/null || true -adb -s "$DEVICE" shell input text "${EMAIL//@/%40}" -sleep 1 -tap_contains "Accedi" || tap_contains "Entra" || adb -s "$DEVICE" shell input tap 540 900 -sleep 5 - -echo "== Open camera for session $SESSION_ID ==" -# Deep link via am start with route — apri wizard/camera se già loggato -adb -s "$DEVICE" shell am start -a android.intent.action.VIEW \ - -d "https://www.matchlivetv.it/live/$SESSION_ID" "$PKG" 2>/dev/null || true -sleep 3 -tap_contains "Riprendi" || tap_contains "RIPRENDI" || tap_contains "camera" || true -sleep 2 - -echo "== Session status ==" -curl -sS "https://www.matchlivetv.it/live/$SESSION_ID/status.json" | python3 -m json.tool | head -20 - -echo "" -echo "Apri nel browser: https://www.matchlivetv.it/live/$SESSION_ID" -echo "Controlla bitrate/fps dispositivo via API sessions."