Show crests and league sections when picking a club
All checks were successful
Build TV app / build (push) Successful in 2m53s
All checks were successful
Build TV app / build (push) Successful in 2m53s
The club chooser was a flat list of 56 names in a generic picker dialog, which is unreadable from a couch. It now has its own dialog with a crest per row, the short label on the right, and section headers per division. Enabled clubs in the settings card show their crest too. A search field would have been the obvious alternative, but text entry on a remote is exactly what the Ten-Foot rule rules out, so grouping plus crests carries the recognition instead. Also adds tests/demo, a generator for neutral playlist and EPG data so screenshots never carry real channel names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "dev.castarr.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 36
|
||||
versionName = "0.11.3"
|
||||
versionCode = 37
|
||||
versionName = "0.11.4"
|
||||
}
|
||||
|
||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||
|
||||
@@ -179,19 +179,25 @@ class AppState(
|
||||
/** How urgent a club menu is right now — drives the rail highlight. */
|
||||
enum class TeamUrgency { NONE, SOON, LIVE }
|
||||
|
||||
/** The urgent state plus the kick-off it refers to, for the rail label. */
|
||||
data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long)
|
||||
|
||||
/**
|
||||
* LIVE while a match is running, SOON within half an hour of kick-off —
|
||||
* the window in which someone actually wants to be nudged.
|
||||
*/
|
||||
fun teamUrgency(matches: List<Pair<Channel, TeamHit>>): TeamUrgency {
|
||||
fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
|
||||
val now = System.currentTimeMillis()
|
||||
var soon = false
|
||||
var soonAt = 0L
|
||||
matches.forEach { (_, hit) ->
|
||||
val p = hit.programme
|
||||
if (now in p.start until p.stop) return TeamUrgency.LIVE
|
||||
if (p.start in now..(now + SOON_WINDOW_MS)) soon = true
|
||||
if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
|
||||
if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
|
||||
soonAt = p.start
|
||||
}
|
||||
}
|
||||
return if (soon) TeamUrgency.SOON else TeamUrgency.NONE
|
||||
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
|
||||
else TeamSignal(TeamUrgency.NONE, 0L)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ data class TeamFilter(
|
||||
val secondary: Color,
|
||||
/** German Wikipedia article the crest is resolved from. */
|
||||
val article: String,
|
||||
/** 1, 2 or 3 — groups the picker by division. */
|
||||
val league: Int,
|
||||
) {
|
||||
fun matches(title: String): Boolean {
|
||||
val haystack = title.lowercase()
|
||||
@@ -63,69 +65,70 @@ object TeamFilters {
|
||||
primary: Long,
|
||||
secondary: Long,
|
||||
article: String = fullName,
|
||||
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
|
||||
league: Int = 1,
|
||||
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league)
|
||||
|
||||
/** Clubs of the top three German divisions. */
|
||||
val all: List<TeamFilter> = listOf(
|
||||
// --- Bundesliga ---
|
||||
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE),
|
||||
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK),
|
||||
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE),
|
||||
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK),
|
||||
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F),
|
||||
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE),
|
||||
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F),
|
||||
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE),
|
||||
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE),
|
||||
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE),
|
||||
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE),
|
||||
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE),
|
||||
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F),
|
||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100),
|
||||
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE),
|
||||
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK),
|
||||
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4),
|
||||
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE),
|
||||
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
|
||||
club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
|
||||
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
|
||||
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
|
||||
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
|
||||
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
|
||||
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
|
||||
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
|
||||
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
|
||||
club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
|
||||
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
|
||||
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
|
||||
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
|
||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
|
||||
club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
|
||||
club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
||||
club("heidenheim", "FCH1", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
|
||||
club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||
// --- 2. Bundesliga ---
|
||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE),
|
||||
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE),
|
||||
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE),
|
||||
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE),
|
||||
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE),
|
||||
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE),
|
||||
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE),
|
||||
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE),
|
||||
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE),
|
||||
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F),
|
||||
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE),
|
||||
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E),
|
||||
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE),
|
||||
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE),
|
||||
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE),
|
||||
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F),
|
||||
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK),
|
||||
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE),
|
||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
|
||||
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
|
||||
club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
|
||||
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
|
||||
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
|
||||
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
|
||||
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
|
||||
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
|
||||
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
|
||||
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
|
||||
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
|
||||
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
|
||||
club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
|
||||
club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
|
||||
club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
|
||||
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
|
||||
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
|
||||
// --- 3. Liga ---
|
||||
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE),
|
||||
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK),
|
||||
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE),
|
||||
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE),
|
||||
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE),
|
||||
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE),
|
||||
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE),
|
||||
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK),
|
||||
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball"),
|
||||
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE),
|
||||
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE),
|
||||
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE),
|
||||
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE),
|
||||
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE),
|
||||
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE),
|
||||
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK),
|
||||
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK),
|
||||
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2),
|
||||
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK),
|
||||
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim"),
|
||||
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
|
||||
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
|
||||
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
|
||||
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
|
||||
club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
|
||||
club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
|
||||
club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
|
||||
club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
|
||||
club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
|
||||
club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
|
||||
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
|
||||
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
|
||||
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
|
||||
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
|
||||
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
|
||||
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
|
||||
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
|
||||
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
|
||||
club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
|
||||
club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
|
||||
)
|
||||
|
||||
/** Clubs switched on for a viewer before they touch the settings. */
|
||||
@@ -155,6 +158,14 @@ object TeamFilters {
|
||||
|
||||
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
|
||||
|
||||
/** Clubs grouped by division, in the order the picker shows them. */
|
||||
fun byLeague(clubs: List<TeamFilter> = all): List<Pair<String, List<TeamFilter>>> =
|
||||
listOf(
|
||||
"1. Bundesliga" to clubs.filter { it.league == 1 },
|
||||
"2. Bundesliga" to clubs.filter { it.league == 2 },
|
||||
"3. Liga" to clubs.filter { it.league == 3 },
|
||||
).filter { it.second.isNotEmpty() }
|
||||
|
||||
fun defaultKeysFor(username: String): List<String> =
|
||||
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -199,14 +201,15 @@ fun LiveScreen(state: AppState) {
|
||||
}
|
||||
items(teams, key = { it.key }) { club ->
|
||||
val clubMatches = matchesByTeam[club.key].orEmpty()
|
||||
val clubSignal = remember(clubMatches, urgencyTick) {
|
||||
state.teamSignal(clubMatches)
|
||||
}
|
||||
GroupItem(
|
||||
label = club.label,
|
||||
count = clubMatches.size,
|
||||
selected = state.activeTeam == club.key,
|
||||
leading = { Crest(club, state) },
|
||||
urgency = remember(clubMatches, urgencyTick) {
|
||||
state.teamUrgency(clubMatches)
|
||||
},
|
||||
leading = { Crest(club, state, clubSignal.urgency) },
|
||||
signal = clubSignal,
|
||||
modifier = intoList.then(
|
||||
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||
else Modifier
|
||||
@@ -312,8 +315,8 @@ private fun GroupItem(
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
/** Replaces the count and adds a dot when a match is on or imminent. */
|
||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||
/** Replaces the count when a match is on or about to start. */
|
||||
signal: AppState.TeamSignal? = null,
|
||||
suppressAutoSelect: () -> Boolean = { false },
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
@@ -351,32 +354,25 @@ private fun GroupItem(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
when (urgency) {
|
||||
AppState.TeamUrgency.LIVE, AppState.TeamUrgency.SOON -> {
|
||||
val accent =
|
||||
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||
else CastarrColors.accent
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accent)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
if (urgency == AppState.TeamUrgency.LIVE) "läuft" else "gleich",
|
||||
color = accent,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
AppState.TeamUrgency.NONE -> Text(
|
||||
"$count",
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
// The signal lives in the value column instead of adding a
|
||||
// badge — the count is worth less than "now" or "16:00" is.
|
||||
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
||||
Text(
|
||||
when (urgent) {
|
||||
AppState.TeamUrgency.LIVE -> "läuft"
|
||||
AppState.TeamUrgency.SOON -> formatClock(signal!!.kickOff)
|
||||
AppState.TeamUrgency.NONE -> "$count"
|
||||
},
|
||||
color = when (urgent) {
|
||||
AppState.TeamUrgency.LIVE -> CastarrColors.live
|
||||
AppState.TeamUrgency.SOON -> CastarrColors.accent
|
||||
AppState.TeamUrgency.NONE -> Color.Unspecified
|
||||
},
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (urgent == AppState.TeamUrgency.NONE) FontWeight.Normal
|
||||
else FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,8 +470,39 @@ private fun ChannelRow(
|
||||
* network; the coloured shield stands in if one is ever missing.
|
||||
*/
|
||||
@Composable
|
||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||
private fun Crest(
|
||||
team: dev.castarr.tv.data.TeamFilter,
|
||||
state: AppState,
|
||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||
) {
|
||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||
if (urgency != AppState.TeamUrgency.NONE) {
|
||||
// A slow pulse is what actually catches the eye from the sofa;
|
||||
// colour alone does not at that distance.
|
||||
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
|
||||
label = "crest-pulse",
|
||||
)
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
|
||||
androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing),
|
||||
androidx.compose.animation.core.RepeatMode.Restart,
|
||||
),
|
||||
label = "phase",
|
||||
)
|
||||
val ringColor =
|
||||
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||
else CastarrColors.accent
|
||||
Canvas(Modifier.size(30.dp)) {
|
||||
val grow = phase.coerceAtMost(0.7f) / 0.7f
|
||||
drawCircle(
|
||||
color = ringColor.copy(alpha = (1f - grow) * 0.9f),
|
||||
radius = size.minDimension * (0.33f + grow * 0.17f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = "file:///android_asset/crests/${team.key}.png",
|
||||
contentDescription = null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -79,6 +80,7 @@ fun SettingsScreen(state: AppState) {
|
||||
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||
var updateStatus by remember { mutableStateOf("") }
|
||||
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||
var clubPicker by remember { mutableStateOf(false) }
|
||||
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||
var pairingEpoch by remember { mutableStateOf(0) }
|
||||
|
||||
@@ -191,6 +193,7 @@ fun SettingsScreen(state: AppState) {
|
||||
SettingRow(
|
||||
club.fullName,
|
||||
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
||||
leading = { ClubCrest(club, state) },
|
||||
trailing = { RemoveIcon() },
|
||||
) { state.toggleTeam(club.key) }
|
||||
}
|
||||
@@ -198,17 +201,7 @@ fun SettingsScreen(state: AppState) {
|
||||
SettingRow(
|
||||
"Verein hinzufügen",
|
||||
subtitle = "1. bis 3. Liga",
|
||||
) {
|
||||
val choices = dev.castarr.tv.data.TeamFilters.all
|
||||
.filter { it.key !in state.enabledTeams }
|
||||
picker = Picker(
|
||||
"Verein hinzufügen",
|
||||
choices.map { it.fullName },
|
||||
-1,
|
||||
) { index ->
|
||||
choices.getOrNull(index)?.let { state.toggleTeam(it.key) }
|
||||
}
|
||||
}
|
||||
) { clubPicker = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +313,155 @@ fun SettingsScreen(state: AppState) {
|
||||
picker?.let { current ->
|
||||
PickerDialog(current) { picker = null }
|
||||
}
|
||||
if (clubPicker) {
|
||||
ClubPickerDialog(
|
||||
state = state,
|
||||
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
|
||||
onClose = { clubPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Club crest from the bundled assets; initials while it loads. */
|
||||
@Composable
|
||||
private fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) {
|
||||
Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) {
|
||||
coil.compose.SubcomposeAsyncImage(
|
||||
model = "file:///android_asset/crests/${team.key}.png",
|
||||
contentDescription = null,
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = { CrestInitials(team) },
|
||||
error = { CrestInitials(team) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(team.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
team.label.take(3),
|
||||
color = team.secondary,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 8.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Club chooser: crests plus division headers, because a flat list of 56
|
||||
* names is unreadable on a remote.
|
||||
*/
|
||||
@Composable
|
||||
private fun ClubPickerDialog(
|
||||
state: AppState,
|
||||
onPick: (dev.castarr.tv.data.TeamFilter) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||
val sections = remember(state.enabledTeams) {
|
||||
dev.castarr.tv.data.TeamFilters.byLeague(
|
||||
dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams }
|
||||
)
|
||||
}
|
||||
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
|
||||
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(420.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(CastarrColors.surface)
|
||||
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
"VEREIN HINZUFÜGEN",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||
)
|
||||
LazyColumn(Modifier.heightIn(max = 460.dp)) {
|
||||
sections.forEach { (league, clubs) ->
|
||||
item(key = "h-$league") {
|
||||
Text(
|
||||
league,
|
||||
color = CastarrColors.accent,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 1.5.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
items(clubs, key = { it.key }) { club ->
|
||||
val focusMod =
|
||||
if (club.key == firstKey) Modifier.focusRequester(firstFocus)
|
||||
else Modifier
|
||||
Surface(
|
||||
onClick = { onPick(club) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(focusMod),
|
||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
contentColor = CastarrColors.fg,
|
||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||
focusedContentColor = CastarrColors.fg,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||
shape = rowShape,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ClubCrest(club, state, size = 24)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
club.fullName,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
club.label,
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
||||
@@ -512,6 +654,7 @@ private fun SettingRow(
|
||||
label: String,
|
||||
subtitle: String? = null,
|
||||
danger: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -537,6 +680,10 @@ private fun SettingRow(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leading != null) {
|
||||
leading()
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user