Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.

Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 15:30:49 +02:00
co-authored by Cursor
parent e502844b88
commit 5b723bf844
81 changed files with 2177 additions and 38 deletions
@@ -0,0 +1,18 @@
package com.matchlivetv.match_live_tv.core
import android.content.Context
class AnnouncementStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences("announcements", Context.MODE_PRIVATE)
fun dismissedIds(): Set<String> =
prefs.getStringSet(KEY_DISMISSED, emptySet())?.toSet().orEmpty()
fun dismiss(id: String) {
prefs.edit().putStringSet(KEY_DISMISSED, dismissedIds() + id).apply()
}
companion object {
private const val KEY_DISMISSED = "dismissed_ids"
}
}
@@ -1,9 +1,11 @@
package com.matchlivetv.match_live_tv.data
import android.content.Context
import com.matchlivetv.match_live_tv.core.AnnouncementStore
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
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
@@ -86,6 +88,14 @@ class AppContainer(context: Context) {
val matchRepository = MatchRepository(api, tokenStore, appContext)
val announcementStore = AnnouncementStore(appContext)
suspend fun visibleAnnouncements(): List<AnnouncementDto> {
val dismissed = announcementStore.dismissedIds()
return runCatching { api.announcements() }.getOrElse { emptyList() }
.filter { it.id !in dismissed }
}
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
@@ -414,3 +414,14 @@ data class TelemetryRequest(
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
)
data class AnnouncementDto(
val id: String,
val kind: String,
val severity: String = "info",
val title: String,
val body: String,
val dismissible: Boolean = true,
@Json(name = "action_url") val actionUrl: String? = null,
@Json(name = "action_label") val actionLabel: String? = null,
)
@@ -8,6 +8,7 @@ import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import okhttp3.MultipartBody
interface MatchLiveApi {
@@ -38,6 +39,9 @@ interface MatchLiveApi {
@GET("sports")
suspend fun sports(): List<SportDto>
@GET("announcements")
suspend fun announcements(@Query("platform") platform: String = "android"): List<AnnouncementDto>
@GET("teams")
suspend fun teams(): List<TeamDto>
@@ -0,0 +1,102 @@
package com.matchlivetv.match_live_tv.ui.components
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Campaign
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun AnnouncementBanners(
announcements: List<AnnouncementDto>,
onDismiss: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (announcements.isEmpty()) return
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
announcements.forEach { item ->
AnnouncementBanner(item = item, onDismiss = { onDismiss(item.id) })
}
}
}
@Composable
private fun AnnouncementBanner(
item: AnnouncementDto,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
val accent = announcementAccent(item)
Column(
Modifier
.fillMaxWidth()
.background(accent.copy(alpha = 0.16f), RoundedCornerShape(12.dp))
.padding(14.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Icon(announcementIcon(item.kind), contentDescription = null, tint = accent)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(item.title, color = accent, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(item.body, style = MaterialTheme.typography.bodyMedium)
}
if (item.dismissible) {
IconButton(onClick = onDismiss) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.announcement_dismiss),
tint = MatchColors.TextSecondary,
)
}
}
}
val actionUrl = item.actionUrl?.takeIf { it.isNotBlank() }
if (actionUrl != null) {
TextButton(onClick = { runCatching { uriHandler.openUri(actionUrl) } }) {
Text(
item.actionLabel?.takeIf { it.isNotBlank() }
?: stringResource(R.string.announcement_open),
color = accent,
)
}
}
}
}
private fun announcementAccent(item: AnnouncementDto): Color = when {
item.severity == "critical" || item.kind == "maintenance" -> MatchColors.AccentYellow
item.kind == "update" -> MatchColors.SuccessGreen
else -> MatchColors.PrimaryRed
}
private fun announcementIcon(kind: String) = when (kind) {
"maintenance" -> Icons.Filled.Construction
"update" -> Icons.Filled.SystemUpdate
else -> Icons.Filled.Campaign
}
@@ -25,6 +25,7 @@ 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
@@ -52,6 +53,8 @@ import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -72,6 +75,7 @@ fun LoginScreen(
var loading by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
var showLanguagePicker by remember { mutableStateOf(false) }
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val errCredentials = stringResource(R.string.login_error_credentials)
@@ -98,6 +102,10 @@ fun LoginScreen(
}
}
LaunchedEffect(Unit) {
announcements = container.visibleAnnouncements()
}
MatchScreenScaffold(
topBar = {
TopAppBar(
@@ -142,6 +150,16 @@ fun LoginScreen(
Spacer(Modifier.height(16.dp))
}
MatchLiveWordmark(showSlogan = true)
if (announcements.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
AnnouncementBanners(
announcements = announcements,
onDismiss = { id ->
container.announcementStore.dismiss(id)
announcements = announcements.filterNot { it.id == id }
},
)
}
Spacer(Modifier.height(48.dp))
Text(
text = stringResource(R.string.login_submit).uppercase(),
@@ -53,6 +53,8 @@ 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.Team
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -86,6 +88,7 @@ fun MatchesScreen(
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
var activeTeam by remember { mutableStateOf<Team?>(null) }
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
var showNewMatchSheet by remember { mutableStateOf(false) }
var showScheduleSheet by remember { mutableStateOf(false) }
@@ -116,6 +119,7 @@ fun MatchesScreen(
}.onFailure {
error = it.message ?: msgLoadError
}
announcements = container.visibleAnnouncements()
loading = false
refreshing = false
}
@@ -192,7 +196,18 @@ fun MatchesScreen(
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
if (announcements.isNotEmpty()) {
AnnouncementBanners(
announcements = announcements,
onDismiss = { id ->
container.announcementStore.dismiss(id)
announcements = announcements.filterNot { it.id == id }
},
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
Box(Modifier.weight(1f)) {
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { reload(showInitialSpinner = false) },
@@ -339,6 +354,7 @@ fun MatchesScreen(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
@@ -14,6 +14,8 @@
<string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string>
<string name="action_save">Speichern</string>
<string name="announcement_dismiss">Hinweis schließen</string>
<string name="announcement_open">Öffnen</string>
<string name="login_email">E-Mail</string>
<string name="login_password">Passwort</string>
<string name="login_submit">Anmelden</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Cancel</string>
<string name="action_confirm">Confirm</string>
<string name="action_save">Save</string>
<string name="announcement_dismiss">Dismiss notice</string>
<string name="announcement_open">Open</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Log in</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Cancelar</string>
<string name="action_confirm">Confirmar</string>
<string name="action_save">Guardar</string>
<string name="announcement_dismiss">Cerrar aviso</string>
<string name="announcement_open">Abrir</string>
<string name="login_email">Email</string>
<string name="login_password">Contraseña</string>
<string name="login_submit">Acceder</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Annuler</string>
<string name="action_confirm">Confirmer</string>
<string name="action_save">Enregistrer</string>
<string name="announcement_dismiss">Fermer l\'alerte</string>
<string name="announcement_open">Ouvrir</string>
<string name="login_email">E-mail</string>
<string name="login_password">Mot de passe</string>
<string name="login_submit">Connexion</string>
@@ -15,6 +15,8 @@
<string name="action_cancel">Annulla</string>
<string name="action_confirm">Conferma</string>
<string name="action_save">Salva</string>
<string name="announcement_dismiss">Chiudi avviso</string>
<string name="announcement_open">Apri</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Accedi</string>