Completa app Android nativa e rimuove il client Flutter.
Il monorepo punta a native/android (login, wizard, diretta RTMP, tabellone WebSocket, pausa/telemetria) con encode 16:9 e orientamento via sensore; eliminati mobile/ e gli script Flutter obsoleti. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
34
native/android/app/proguard-rules.pro
vendored
34
native/android/app/proguard-rules.pro
vendored
@@ -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.* <methods>;
|
||||
}
|
||||
|
||||
-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.** {
|
||||
<init>(...);
|
||||
}
|
||||
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keep @com.squareup.moshi.JsonQualifier interface *
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.FromJson <methods>;
|
||||
@com.squareup.moshi.ToJson <methods>;
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn kotlin.Unit
|
||||
-dontwarn retrofit2.KotlinExtensions
|
||||
-dontwarn retrofit2.KotlinExtensions$*
|
||||
-dontwarn okhttp3.internal.platform.ConscryptPlatform
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
@@ -16,7 +17,7 @@
|
||||
<application
|
||||
android:name=".MatchLiveTvApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MatchLiveTv"
|
||||
|
||||
@@ -15,6 +15,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
container.broadcastCoordinator.pauseForConfigurationChange()
|
||||
super.onConfigurationChanged(newConfig)
|
||||
container.broadcastCoordinator.resumeAfterConfigurationChange()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
|
||||
object DeviceTelemetry {
|
||||
fun batteryLevel(context: Context): Int {
|
||||
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
?: return 100
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
if (level < 0 || scale <= 0) return 100
|
||||
return ((level * 100f) / scale).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
fun networkType(context: Context): String = runCatching {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return "Sconosciuto"
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return "Sconosciuto"
|
||||
when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "4G"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
|
||||
else -> "Sconosciuto"
|
||||
}
|
||||
}.getOrDefault("Sconosciuto")
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>? = 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<SetPartialDto>? = 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<SetPartialDto>,
|
||||
)
|
||||
|
||||
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<String, String>)
|
||||
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<Int>? = 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<Int>? = 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,
|
||||
)
|
||||
|
||||
@@ -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<TeamDto>
|
||||
|
||||
@GET("teams/{teamId}/recordings")
|
||||
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
|
||||
|
||||
@GET("teams/{teamId}/matches")
|
||||
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
|
||||
|
||||
@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<String, String>
|
||||
}
|
||||
|
||||
@@ -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<String, Any?>) {}
|
||||
}
|
||||
|
||||
private val envelopeAdapter: JsonAdapter<Map<String, Any?>> =
|
||||
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<String, String>,
|
||||
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<String, Any?>) {
|
||||
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<String, Any?>) {
|
||||
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<String, Any?>)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageToMap(json: JSONObject): Map<String, Any?> {
|
||||
val map = mutableMapOf<String, Any?>()
|
||||
json.keys().forEach { key ->
|
||||
map[key] = json.get(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ActionCableClient"
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = _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<String, Any?>) {
|
||||
dispatch(payload)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendScoreUpdate(score: ScoreState) {
|
||||
client?.performReceive(score.cablePayload())
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
client?.disconnect()
|
||||
client = null
|
||||
_connected.value = false
|
||||
}
|
||||
|
||||
private fun dispatch(data: Map<String, Any?>) {
|
||||
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<String, Any?>): ScoreState {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val partialsRaw = data["set_partials"] as? List<Map<String, Any?>> ?: 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Team> =
|
||||
api.teams().map { it.toDomain() }
|
||||
|
||||
suspend fun fetchMatches(): List<Match> {
|
||||
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>): 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<Match> =
|
||||
api.matches(teamId)
|
||||
.map { it.toDomain() }
|
||||
.sortedWith(matchComparator)
|
||||
|
||||
suspend fun fetchMatches(): List<Match> {
|
||||
val teams = fetchTeams()
|
||||
val team = resolveActiveTeam(teams) ?: return emptyList()
|
||||
return fetchMatchesForTeam(team.id)
|
||||
}
|
||||
|
||||
suspend fun fetchRecordings(teamId: String): List<Recording> =
|
||||
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<Int>,
|
||||
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<Match, Instant?>(nullsLast()) { match ->
|
||||
match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
}.thenByDescending { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScoreState> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Int> = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<SetPartial> = emptyList(),
|
||||
val timeoutHome: Boolean = false,
|
||||
val timeoutAway: Boolean = false,
|
||||
) {
|
||||
fun cablePayload(): Map<String, Any?> = 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Int, Boolean> {
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Team?>(null) }
|
||||
var recordings by remember { mutableStateOf<List<Recording>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StreamSession?>(null) }
|
||||
var match by remember { mutableStateOf<Match?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScoreDialogState?>(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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<String?>(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,
|
||||
)
|
||||
|
||||
@@ -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<Match>,
|
||||
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<Team>,
|
||||
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() }
|
||||
@@ -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<String?>(null) }
|
||||
var matches by remember { mutableStateOf<List<Match>>(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<String?>(null) }
|
||||
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
|
||||
var activeTeam by remember { mutableStateOf<Team?>(null) }
|
||||
var matches by remember { mutableStateOf<List<Match>>(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<Match?>(null) }
|
||||
var configureMatch by remember { mutableStateOf<Match?>(null) }
|
||||
var deleteMatch by remember { mutableStateOf<Match?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<Team?>(null) }
|
||||
var loadingTeam by remember { mutableStateOf(true) }
|
||||
var platform by remember { mutableStateOf("matchlivetv") }
|
||||
var privacy by remember { mutableStateOf("public") }
|
||||
var youtubeChannel by remember { mutableStateOf<String?>("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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Match?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FF2D2D"
|
||||
android:pathData="M54,8 L100,100 H8 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,28 L78,88 H30 Z" />
|
||||
</vector>
|
||||
BIN
native/android/app/src/main/res/drawable/logo_white.png
Normal file
BIN
native/android/app/src/main/res/drawable/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
native/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
native/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
native/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
native/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
native/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
5
native/android/app/src/main/res/values/colors.xml
Normal file
5
native/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="background">#FF0A0A0A</color>
|
||||
<color name="primary_red">#FFFF2D2D</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Ogni partita, ogni evento, per i tuoi tifosi.</string>
|
||||
<string name="streaming_notification_channel">Diretta in corso</string>
|
||||
<string name="streaming_notification_channel_desc">Notifica durante lo streaming live</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/background</item>
|
||||
<item name="android:statusBarColor">@color/background</item>
|
||||
<item name="android:navigationBarColor">@color/background</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user