Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1dff5661 | ||
|
|
869060c56f |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -8,20 +8,32 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Full URLs: this Gitea resolves bare action names against itself,
|
||||
# where these actions do not exist.
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: https://github.com/gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest --stacktrace
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --stacktrace
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://github.com/actions/upload-artifact@v4
|
||||
with:
|
||||
name: castarr-debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@v4
|
||||
with:
|
||||
name: castarr-test-report
|
||||
path: tests/runs/
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "dev.castarr.tv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 26
|
||||
versionName = "0.10.0"
|
||||
versionCode = 28
|
||||
versionName = "0.10.2"
|
||||
}
|
||||
|
||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||
|
||||
@@ -27,6 +27,7 @@ class AppState(
|
||||
val dispatcharr: DispatcharrRepository,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||
val crests = dev.castarr.tv.data.Crests(context)
|
||||
|
||||
var screen by mutableStateOf(Screen.LIVE)
|
||||
var playerVisible by mutableStateOf(false)
|
||||
@@ -54,14 +55,21 @@ class AppState(
|
||||
private fun loadEnabledTeams(): Set<String> {
|
||||
val stored = prefs.getStringSet("teams", null)
|
||||
if (stored != null) return stored.toSet()
|
||||
return setOfNotNull(dev.castarr.tv.data.TeamFilters.defaultKeyFor(auth.username))
|
||||
return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
|
||||
}
|
||||
|
||||
fun toggleTeam(key: String) {
|
||||
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
|
||||
fun toggleTeam(key: String): Boolean {
|
||||
if (key !in enabledTeams &&
|
||||
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
|
||||
) {
|
||||
return false
|
||||
}
|
||||
enabledTeams =
|
||||
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
|
||||
prefs.edit().putStringSet("teams", enabledTeams).apply()
|
||||
if (activeTeam == key && key !in enabledTeams) activeTeam = null
|
||||
return true
|
||||
}
|
||||
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
||||
private set
|
||||
|
||||
54
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
54
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Resolves club crests at runtime from Wikipedia's page summary. The badges
|
||||
* are trademarks, so they are fetched and cached on the device rather than
|
||||
* shipped with the app — nothing copyrighted lives in the repository.
|
||||
*/
|
||||
class Crests(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
||||
|
||||
/** Cached crest URL for a club, fetching it once per article. */
|
||||
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||
prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } }
|
||||
val resolved = runCatching { fetch(article) }
|
||||
.onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") }
|
||||
.getOrNull()
|
||||
// Remember misses too, so a club without a crest is not looked up
|
||||
// again on every screen.
|
||||
prefs.edit().putString(article, resolved.orEmpty()).apply()
|
||||
resolved
|
||||
}
|
||||
|
||||
private fun fetch(article: String): String? {
|
||||
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
|
||||
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Crests"
|
||||
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||
const val USER_AGENT = "Castarr TV (private use)"
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,21 @@ import androidx.compose.ui.graphics.Color
|
||||
/**
|
||||
* Per-club shortcut to "where does my club play tonight": a rail entry that
|
||||
* lists every channel whose EPG mentions the club within the next few hours.
|
||||
* Club crests are trademarks, so the icon is a plain shield in the club
|
||||
* colours rather than the real badge.
|
||||
* Crests are fetched from Wikipedia at runtime and cached on the device;
|
||||
* the club colours are the fallback while that is pending or unavailable.
|
||||
*/
|
||||
data class TeamFilter(
|
||||
val key: String,
|
||||
/** Short caption next to the crest in the rail. */
|
||||
val label: String,
|
||||
/** How the club is named in a sentence. */
|
||||
val name: String,
|
||||
/** Full club name, used in the settings. */
|
||||
/** Full club name, shown in the settings and in messages. */
|
||||
val fullName: String,
|
||||
/** Lowercase needles matched against programme titles. */
|
||||
val needles: List<String>,
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
/** German Wikipedia article the crest is resolved from. */
|
||||
val article: String,
|
||||
) {
|
||||
fun matches(title: String): Boolean {
|
||||
val haystack = title.lowercase()
|
||||
@@ -32,31 +32,89 @@ object TeamFilters {
|
||||
/** Window scanned ahead of now. */
|
||||
const val WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
val all = listOf(
|
||||
TeamFilter(
|
||||
key = "hansa",
|
||||
label = "FCH",
|
||||
name = "Hansa",
|
||||
fullName = "Hansa Rostock",
|
||||
needles = listOf("hansa"),
|
||||
primary = Color(0xFF0B4EA2),
|
||||
secondary = Color(0xFFF2F3F5),
|
||||
),
|
||||
TeamFilter(
|
||||
key = "vfb",
|
||||
label = "1893",
|
||||
name = "dem VfB",
|
||||
fullName = "VfB Stuttgart",
|
||||
needles = listOf("vfb stuttgart", "vfb"),
|
||||
primary = Color(0xFFE32219),
|
||||
secondary = Color(0xFFF2F3F5),
|
||||
),
|
||||
/** At most this many club groups sit in the rail at once. */
|
||||
const val MAX_ACTIVE = 3
|
||||
|
||||
private const val WHITE = 0xFFF2F3F5
|
||||
private const val BLACK = 0xFF15171B
|
||||
|
||||
private fun club(
|
||||
key: String,
|
||||
label: String,
|
||||
fullName: String,
|
||||
needles: List<String>,
|
||||
primary: Long,
|
||||
secondary: Long,
|
||||
article: String = fullName,
|
||||
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
|
||||
|
||||
/** 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),
|
||||
// --- 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),
|
||||
// --- 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),
|
||||
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),
|
||||
)
|
||||
|
||||
/** The club a viewer gets switched on by default. */
|
||||
/** Clubs switched on for a viewer before they touch the settings. */
|
||||
private val defaultForUser = mapOf(
|
||||
"benjamin" to "hansa",
|
||||
"tobiasb" to "vfb",
|
||||
"benjamin" to listOf("hansa"),
|
||||
"tobiasb" to listOf("stuttgart"),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -80,6 +138,6 @@ object TeamFilters {
|
||||
|
||||
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
|
||||
|
||||
fun defaultKeyFor(username: String): String? =
|
||||
defaultForUser[username.trim().lowercase()]
|
||||
fun defaultKeysFor(username: String): List<String> =
|
||||
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -130,11 +134,18 @@ fun LiveScreen(state: AppState) {
|
||||
state.restorePending = false
|
||||
}
|
||||
}
|
||||
val intoList = if (channels.isEmpty()) Modifier
|
||||
else Modifier.focusProperties { right = listFocus }
|
||||
// A FocusRequester that is not currently attached throws when used, and
|
||||
// in a LazyColumn the selected row may well be scrolled out of
|
||||
// composition — so jumps are attempted, not declared.
|
||||
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
|
||||
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
|
||||
runCatching { listFocus.requestFocus() }.isSuccess
|
||||
}
|
||||
|
||||
val railState = rememberLazyListState()
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = railState,
|
||||
modifier = Modifier
|
||||
.width(264.dp)
|
||||
.fillMaxHeight(),
|
||||
@@ -182,7 +193,7 @@ fun LiveScreen(state: AppState) {
|
||||
label = club.label,
|
||||
count = matchesByTeam[club.key]?.size ?: 0,
|
||||
selected = state.activeTeam == club.key,
|
||||
leading = { Crest(club) },
|
||||
leading = { Crest(club, state) },
|
||||
modifier = intoList.then(
|
||||
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||
else Modifier
|
||||
@@ -235,7 +246,7 @@ fun LiveScreen(state: AppState) {
|
||||
Text(
|
||||
when {
|
||||
activeTeam != null ->
|
||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.name}."
|
||||
"In den nächsten 3 Stunden läuft nichts mit ${activeTeam.fullName}."
|
||||
state.favoritesOnly ->
|
||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||
else -> "Diese Gruppe ist leer."
|
||||
@@ -254,7 +265,11 @@ fun LiveScreen(state: AppState) {
|
||||
// matching the remote's number pad in every view.
|
||||
number = allChannels.indexOf(channel) + 1,
|
||||
modifier = Modifier
|
||||
.focusProperties { left = railFocus }
|
||||
.onPreviewKeyEvent { event ->
|
||||
event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.DirectionLeft &&
|
||||
runCatching { railFocus.requestFocus() }.isSuccess
|
||||
}
|
||||
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||
.then(
|
||||
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||
@@ -418,11 +433,33 @@ private fun ChannelRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for the club badge: real crests are trademarks, so this draws a
|
||||
* simple shield in the club colours instead.
|
||||
* The club badge, resolved from Wikipedia and cached on the device. Until
|
||||
* it arrives (or if it never does) a shield in the club colours stands in.
|
||||
*/
|
||||
@Composable
|
||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter) {
|
||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||
val url by androidx.compose.runtime.produceState<String?>(null, team.key) {
|
||||
value = state.crests.urlFor(team.article)
|
||||
}
|
||||
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||
val crestUrl = url
|
||||
if (crestUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = crestUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = { ShieldFallback(team) },
|
||||
error = { ShieldFallback(team) },
|
||||
)
|
||||
} else {
|
||||
ShieldFallback(team)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
|
||||
Canvas(Modifier.size(18.dp)) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
@@ -14,9 +14,13 @@ 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.heightIn
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -165,36 +169,37 @@ fun SettingsScreen(state: AppState) {
|
||||
|
||||
SettingsCard("Vereinsmenüs") {
|
||||
Text(
|
||||
"Zeigt eine eigene Gruppe mit allen Sendern, auf denen der " +
|
||||
"Verein in den nächsten 3 Stunden läuft.",
|
||||
"Eigene Gruppe mit allen Sendern, auf denen der Verein in den " +
|
||||
"nächsten 3 Stunden läuft. Bis zu " +
|
||||
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||
)
|
||||
dev.castarr.tv.data.TeamFilters.all.forEach { club ->
|
||||
val on = club.key in state.enabledTeams
|
||||
val active = dev.castarr.tv.data.TeamFilters.all
|
||||
.filter { it.key in state.enabledTeams }
|
||||
active.forEach { club ->
|
||||
SettingRow(
|
||||
club.fullName,
|
||||
subtitle = "Erscheint als ${club.label} in der Senderliste",
|
||||
trailing = {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(
|
||||
if (on) CastarrColors.accent else CastarrColors.bg
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp)
|
||||
) {
|
||||
Text(
|
||||
if (on) "An" else "Aus",
|
||||
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailing = { TogglePill(on = true) },
|
||||
) { state.toggleTeam(club.key) }
|
||||
}
|
||||
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard("App") {
|
||||
@@ -307,7 +312,13 @@ fun SettingsScreen(state: AppState) {
|
||||
@Composable
|
||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
|
||||
// A requester bound to no composed row throws; with no preselection
|
||||
// (adding something new) there is deliberately no such row.
|
||||
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(picker.selected) {
|
||||
if (picker.selected > 0) listState.scrollToItem(picker.selected)
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
@@ -336,7 +347,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||
)
|
||||
picker.options.forEachIndexed { index, option ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.heightIn(max = 420.dp),
|
||||
) {
|
||||
itemsIndexed(picker.options) { index, option ->
|
||||
val selected = index == picker.selected
|
||||
Surface(
|
||||
onClick = {
|
||||
@@ -345,7 +360,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
|
||||
.then(
|
||||
if (selected || (picker.selected < 0 && index == 0))
|
||||
Modifier.focusRequester(selectedFocus)
|
||||
else Modifier
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
@@ -386,6 +405,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +439,24 @@ private fun ValueWithCaret(value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TogglePill(on: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (on) CastarrColors.accent else CastarrColors.bg)
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp)
|
||||
) {
|
||||
Text(
|
||||
if (on) "An" else "Aus",
|
||||
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
|
||||
@@ -2,14 +2,13 @@ package dev.castarr.tv.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TeamFiltersTest {
|
||||
|
||||
private val hansa = TeamFilters.byKey("hansa")!!
|
||||
private val vfb = TeamFilters.byKey("vfb")!!
|
||||
private val vfb = TeamFilters.byKey("stuttgart")!!
|
||||
|
||||
@Test
|
||||
fun `matches the club regardless of case and surrounding text`() {
|
||||
@@ -39,8 +38,24 @@ class TeamFiltersTest {
|
||||
|
||||
@Test
|
||||
fun `assigns each viewer their own club by default`() {
|
||||
assertEquals("hansa", TeamFilters.defaultKeyFor("benjamin"))
|
||||
assertEquals("vfb", TeamFilters.defaultKeyFor("TobiasB"))
|
||||
assertNull(TeamFilters.defaultKeyFor("someone-else"))
|
||||
assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
|
||||
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
|
||||
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `club keys are unique and every club has needles`() {
|
||||
val keys = TeamFilters.all.map { it.key }
|
||||
assertEquals(keys.size, keys.toSet().size)
|
||||
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
|
||||
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `covers all three divisions`() {
|
||||
assertTrue(TeamFilters.all.size > 50)
|
||||
listOf("bayern", "schalke", "hansa").forEach {
|
||||
assertTrue(it, TeamFilters.byKey(it) != null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user