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:
+18
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
|
||||
+102
@@ -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
|
||||
}
|
||||
+18
@@ -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(),
|
||||
|
||||
+17
-1
@@ -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>
|
||||
|
||||
@@ -108,6 +108,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sessione scaduta. Accedi di nuovo.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
|
||||
"announcement.dismiss": "Chiudi avviso",
|
||||
"announcement.open": "Apri",
|
||||
"broadcast.close.set.button": "CHIUDI SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Anteprima camera non pronta",
|
||||
"broadcast.error.permissions.camera.mic": "Permessi camera e microfono richiesti",
|
||||
@@ -439,6 +441,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Session expired. Please sign in again.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Every match, every event, for your fans.",
|
||||
"announcement.dismiss": "Dismiss notice",
|
||||
"announcement.open": "Open",
|
||||
"broadcast.close.set.button": "CLOSE SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Camera preview not ready",
|
||||
"broadcast.error.permissions.camera.mic": "Camera and microphone permission required",
|
||||
@@ -770,6 +774,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Session expirée. Connectez-vous à nouveau.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
|
||||
"announcement.dismiss": "Fermer l'alerte",
|
||||
"announcement.open": "Ouvrir",
|
||||
"broadcast.close.set.button": "FERMER LE SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Aperçu caméra non prêt",
|
||||
"broadcast.error.permissions.camera.mic": "Autorisations caméra et micro requises",
|
||||
@@ -1101,6 +1107,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
|
||||
"announcement.dismiss": "Hinweis schließen",
|
||||
"announcement.open": "Öffnen",
|
||||
"broadcast.close.set.button": "SATZ BEENDEN",
|
||||
"broadcast.error.camera.preview.not.ready": "Kameravorschau nicht bereit",
|
||||
"broadcast.error.permissions.camera.mic": "Kamera- und Mikrofonberechtigung erforderlich",
|
||||
@@ -1432,6 +1440,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sesión caducada. Vuelve a iniciar sesión.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
|
||||
"announcement.dismiss": "Cerrar aviso",
|
||||
"announcement.open": "Abrir",
|
||||
"broadcast.close.set.button": "CERRAR SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Vista previa de cámara no lista",
|
||||
"broadcast.error.permissions.camera.mic": "Se requieren permisos de cámara y micrófono",
|
||||
|
||||
@@ -109,3 +109,17 @@ final class TokenStore: ObservableObject {
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
final class AnnouncementStore {
|
||||
private let key = "mltv.dismissedAnnouncements"
|
||||
|
||||
func dismissedIds() -> Set<String> {
|
||||
Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
|
||||
}
|
||||
|
||||
func dismiss(_ id: String) {
|
||||
var ids = dismissedIds()
|
||||
ids.insert(id)
|
||||
UserDefaults.standard.set(Array(ids), forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ struct MessageResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct AnnouncementDto: Decodable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let severity: String
|
||||
let title: String
|
||||
let body: String
|
||||
let dismissible: Bool
|
||||
let actionUrl: String?
|
||||
let actionLabel: String?
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserDto
|
||||
let accessToken: String
|
||||
|
||||
@@ -106,6 +106,10 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
try await get("sports")
|
||||
}
|
||||
|
||||
func announcements() async throws -> [AnnouncementDto] {
|
||||
try await get("announcements", query: ["platform": "ios"])
|
||||
}
|
||||
|
||||
func teams() async throws -> [TeamDto] {
|
||||
try await get("teams")
|
||||
}
|
||||
@@ -218,8 +222,8 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await request(path, method: "GET")
|
||||
private func get<T: Decodable>(_ path: String, query: [String: String] = [:]) async throws -> T {
|
||||
try await request(path, method: "GET", query: query)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
@@ -257,9 +261,17 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
private func request<T: Decodable, B: Encodable>(
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: B? = nil as EmptyBody?
|
||||
body: B? = nil as EmptyBody?,
|
||||
query: [String: String] = [:]
|
||||
) async throws -> T {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
if !query.isEmpty {
|
||||
components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
}
|
||||
guard let url = components.url else { throw APIError.invalidURL }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
|
||||
|
||||
@@ -12,6 +12,7 @@ final class AppContainer: ObservableObject {
|
||||
let scoreController: ScoreController
|
||||
let wizardSession = WizardSessionHolder()
|
||||
let broadcastCoordinator = LiveBroadcastCoordinator()
|
||||
let announcementStore = AnnouncementStore()
|
||||
|
||||
init() {
|
||||
let api = MatchLiveAPI()
|
||||
@@ -39,4 +40,10 @@ final class AppContainer: ObservableObject {
|
||||
OverlayLogoCache.configure(token: session.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleAnnouncements() async -> [AnnouncementDto] {
|
||||
let dismissed = announcementStore.dismissedIds()
|
||||
let all = (try? await api.announcements()) ?? []
|
||||
return all.filter { !dismissed.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct LoginScreen: View {
|
||||
@State private var passwordVisible = false
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
@State private var announcements: [AnnouncementDto] = []
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold {
|
||||
@@ -30,6 +31,16 @@ struct LoginScreen: View {
|
||||
.padding(.top, 8)
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 16)
|
||||
if !announcements.isEmpty {
|
||||
AnnouncementBanners(
|
||||
announcements: announcements,
|
||||
onDismiss: { id in
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements.removeAll { $0.id == id }
|
||||
}
|
||||
)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
Text(L10n.t("login.submit").uppercased())
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 32)
|
||||
@@ -73,6 +84,9 @@ struct LoginScreen: View {
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
.task {
|
||||
announcements = await container.visibleAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
private func submitLogin() {
|
||||
|
||||
@@ -22,6 +22,7 @@ struct MatchesScreen: View {
|
||||
@State private var snackbar: String?
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
@State private var announcements: [AnnouncementDto] = []
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
@@ -48,6 +49,18 @@ struct MatchesScreen: View {
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
VStack(spacing: 0) {
|
||||
if !announcements.isEmpty {
|
||||
AnnouncementBanners(
|
||||
announcements: announcements,
|
||||
onDismiss: { id in
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements.removeAll { $0.id == id }
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
@@ -135,6 +148,7 @@ struct MatchesScreen: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||
@@ -277,6 +291,7 @@ struct MatchesScreen: View {
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
|
||||
}
|
||||
announcements = await container.visibleAnnouncements()
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
@@ -418,6 +433,77 @@ private struct MatchListCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AnnouncementBanners: View {
|
||||
let announcements: [AnnouncementDto]
|
||||
let onDismiss: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(announcements) { item in
|
||||
AnnouncementBanner(item: item, onDismiss: { onDismiss(item.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnnouncementBanner: View {
|
||||
let item: AnnouncementDto
|
||||
let onDismiss: () -> Void
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(accent)
|
||||
Text(item.body)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if item.dismissible {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(L10n.t("announcement.dismiss"))
|
||||
}
|
||||
}
|
||||
if let urlString = item.actionUrl, let url = URL(string: urlString), !urlString.isEmpty {
|
||||
Button(item.actionLabel?.isEmpty == false ? item.actionLabel! : L10n.t("announcement.open")) {
|
||||
openURL(url)
|
||||
}
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var accent: Color {
|
||||
if item.severity == "critical" || item.kind == "maintenance" {
|
||||
return MatchColors.accentYellow
|
||||
}
|
||||
if item.kind == "update" {
|
||||
return MatchColors.successGreen
|
||||
}
|
||||
return MatchColors.primaryRed
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch item.kind {
|
||||
case "maintenance": return "wrench.and.screwdriver.fill"
|
||||
case "update": return "arrow.down.app.fill"
|
||||
default: return "megaphone.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewMatchSheet: View {
|
||||
let onSchedule: () -> Void
|
||||
let onQuickStart: () -> Void
|
||||
|
||||
Reference in New Issue
Block a user