Settings redesign: cards with focusable rows, segmented controls, gear button

- Settings page follows the Google TV pattern: Konto / App / Handy-
  Fernbedienung cards, each row a full-width focus target with label,
  subtitle and trailing value.
- Quelle and Stream-Qualität are segmented controls switched with
  left/right inside the focused row (click cycles).
- Account row with initial avatar, Abmelden as red row, update row
  with version badge; Update installieren only when one is available.
- Top bar: drawn gear icon button replaces the text pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 00:48:14 +02:00
parent 11a8056ce2
commit a4391740ec
3 changed files with 336 additions and 118 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 12 versionCode = 13
versionName = "0.6.1" versionName = "0.7.0"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // Release signing from environment (see ~/.keys/castarr-release.env on the

View File

@@ -11,11 +11,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -23,8 +28,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable @Composable
fun CastarrApp(state: AppState) { fun CastarrApp(state: AppState) {
@@ -123,36 +132,48 @@ private fun TopBar(state: AppState) {
} }
Spacer(Modifier.width(18.dp)) Spacer(Modifier.width(18.dp))
} }
TabItem( GearButton {
if (state.screen == AppState.Screen.LIVE) "Einstellungen" else "Zurück zu Live",
selected = false,
) {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE else AppState.Screen.LIVE
} }
} }
} }
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable @Composable
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) { private fun GearButton(onClick: () -> Unit) {
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), modifier = Modifier.semantics { contentDescription = "Einstellungen" },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
// Visible resting surface, so the pill reads as a target even containerColor = CastarrColors.surface,
// without focus (design review P3). contentColor = CastarrColors.muted,
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Text( val color = LocalContentColor.current
label, Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
fontFamily = AppFont, Canvas(Modifier.size(17.dp)) {
fontSize = 14.sp, val stroke = 1.7.dp.toPx()
fontWeight = FontWeight.Medium, val center = Offset(size.width / 2f, size.height / 2f)
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), drawCircle(color, radius = size.minDimension * 0.17f, center = center, style = Stroke(stroke))
) val inner = size.minDimension * 0.30f
val outer = size.minDimension * 0.48f
for (i in 0 until 8) {
val angle = i * (PI / 4)
val dx = cos(angle).toFloat()
val dy = sin(angle).toFloat()
drawLine(
color,
start = center + Offset(dx * inner, dy * inner),
end = center + Offset(dx * outer, dy * outer),
strokeWidth = stroke,
cap = StrokeCap.Round,
)
}
}
}
} }
} }

View File

@@ -1,17 +1,22 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -24,9 +29,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
@@ -38,8 +51,10 @@ import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text * Google-TV-style settings: cards with full-width focusable rows, segmented
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen. * controls switched with left/right inside the row. Ten-Foot-Regel (#14):
* one screen, no text fields, no scrolling — text-heavy input lives in the
* Erweitert screen.
*/ */
@Composable @Composable
fun SettingsScreen(state: AppState) { fun SettingsScreen(state: AppState) {
@@ -51,131 +66,313 @@ fun SettingsScreen(state: AppState) {
Row( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp) .padding(horizontal = 40.dp, vertical = 12.dp)
) { ) {
Column(Modifier.weight(1.2f)) { Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionTitle("Konto") SettingsCard("Konto") {
Spacer(Modifier.height(8.dp)) if (state.auth.isLoggedIn) {
if (state.auth.isLoggedIn) { Row(
Text( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
"Angemeldet als ${state.auth.username}", verticalAlignment = Alignment.CenterVertically,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp, ) {
) Box(
Spacer(Modifier.height(14.dp)) Modifier
if (state.source.m3uUrl.isNotEmpty()) { .size(34.dp)
Row { .clip(CircleShape)
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) { .background(CastarrColors.accentDim),
state.setMode(AppState.SourceMode.DISPATCHARR) contentAlignment = Alignment.Center,
) {
Text(
state.auth.username.take(1).uppercase().ifEmpty { "?" },
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(14.dp))
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) { Column {
state.setMode(AppState.SourceMode.GENERIC) Text(
state.auth.username,
color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp,
)
Text(
"Angemeldet über SSO",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
)
} }
} }
Spacer(Modifier.height(14.dp)) if (state.source.m3uUrl.isNotEmpty()) {
} val modes = listOf("Dispatcharr", "Eigene M3U")
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp) val selected = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
Spacer(Modifier.height(6.dp)) SegmentedRow("Quelle", "Woher die Senderliste kommt", modes, selected) {
Row { state.setMode(
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" } if (it == 0) AppState.SourceMode.DISPATCHARR
Spacer(Modifier.width(8.dp)) else AppState.SourceMode.GENERIC
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" } )
profiles.forEach { name -> }
Spacer(Modifier.width(8.dp))
Chip(name, state.outputProfile == name) { state.outputProfile = name }
} }
val qualities = listOf("Standard", "Original") + profiles
val qualityIndex = when (state.outputProfile) {
"" -> 0
"raw" -> 1
else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0)
}
SegmentedRow(
"Stream-Qualität", "Standard remuxt nur den Ton",
qualities, qualityIndex,
) {
state.outputProfile = when (it) {
0 -> ""
1 -> "raw"
else -> profiles.getOrElse(it - 2) { "" }
}
}
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
SettingRow("Jetzt anmelden") { state.startOnboarding() }
} }
Spacer(Modifier.height(18.dp))
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
} }
Spacer(Modifier.height(28.dp)) SettingsCard("App") {
SectionTitle("App") SettingRow(
Spacer(Modifier.height(8.dp)) "Version ${BuildConfig.VERSION_NAME}",
Text( subtitle = updateStatus.ifEmpty { "Klicken prüft auf Updates" },
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""), trailing = state.updateAvailable?.let { version ->
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, {
) Box(
Spacer(Modifier.height(8.dp)) Modifier
Row { .clip(RoundedCornerShape(999.dp))
ActionButton( .background(CastarrColors.accentDim)
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen" .padding(horizontal = 10.dp, vertical = 4.dp)
) { ) {
scope.launch { Text(
updateStatus = if (state.updateAvailable != null) { "$version verfügbar",
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…" color = CastarrColors.accent,
} else { fontFamily = AppFont,
when (val v = UpdateChecker.check(state)) { fontSize = 11.sp,
null -> "App ist aktuell" fontWeight = FontWeight.SemiBold,
else -> "$v verfügbar — nochmal drücken zum Installieren" )
} }
} }
},
) {
scope.launch {
updateStatus = when (val v = UpdateChecker.check(state)) {
null -> "App ist aktuell"
else -> "$v verfügbar"
}
} }
} }
Spacer(Modifier.width(8.dp)) if (state.updateAvailable != null) {
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED } SettingRow("Update installieren") {
} scope.launch {
if (updateStatus.isNotEmpty()) { updateStatus =
Spacer(Modifier.height(8.dp)) UpdateChecker.downloadAndInstall(context, state)
Text(updateStatus, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp) ?: "Update wird geöffnet…"
}
}
}
SettingRow(
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
) { state.screen = AppState.Screen.ADVANCED }
} }
} }
Spacer(Modifier.width(40.dp)) Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.weight(0.75f)) {
SectionTitle("Handy-Fernbedienung") SettingsCard("Handy-Fernbedienung") {
Spacer(Modifier.height(10.dp)) val address = remember { Pairing.lanAddress() }
val address = remember { Pairing.lanAddress() } if (address != null) {
if (address != null) { val qr = remember(address) {
val qr = remember(address) { Qr.encode(
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216")) Pairing.pairingUrl(context, address), 400,
android.graphics.Color.parseColor("#101216"),
)
}
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCard(qr)
Spacer(Modifier.height(10.dp))
Text(
"Mit der Handy-Kamera scannen",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
)
}
} else {
Text(
"Keine Netzwerkverbindung",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
} }
QrCard(qr)
Spacer(Modifier.height(10.dp))
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp)
} }
} }
} }
} }
@Composable @Composable
private fun SectionTitle(text: String) { private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Text(text, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 20.sp) Column(
} Modifier
.fillMaxWidth()
@Composable .clip(RoundedCornerShape(16.dp))
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) { .background(CastarrColors.surface)
Surface( .padding(horizontal = 10.dp, vertical = 14.dp)
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) { ) {
Text( Text(
label, title.uppercase(),
fontFamily = AppFont, fontSize = 12.sp, color = CastarrColors.faint,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
) )
content()
} }
} }
private val rowShape = RoundedCornerShape(10.dp)
/** One full-width focusable settings row: label + subtitle, value right. */
@Composable
private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
trailing: (@Composable () -> Unit)? = null,
keyHandler: ((Key) -> Boolean)? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.let { m ->
if (keyHandler == null) m
else m.onPreviewKeyEvent { event ->
event.type == KeyEventType.KeyDown && keyHandler(event.key)
}
},
shape = ClickableSurfaceDefaults.shape(rowShape),
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, if (danger) CastarrColors.live else CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
color = if (danger) CastarrColors.live else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
subtitle,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(12.dp))
trailing()
}
}
}
}
/** Row whose value is a segmented control; left/right switches in place. */
@Composable
private fun SegmentedRow(
label: String,
subtitle: String,
options: List<String>,
selected: Int,
onChange: (Int) -> Unit,
) {
SettingRow(
label = label,
subtitle = subtitle,
trailing = {
Row(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.bg)
.border(1.dp, CastarrColors.line, RoundedCornerShape(999.dp))
.padding(3.dp)
) {
options.forEachIndexed { index, option ->
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(
if (index == selected) CastarrColors.accent
else androidx.compose.ui.graphics.Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 5.dp)
) {
Text(
option,
color = if (index == selected) CastarrColors.onAccent
else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = if (index == selected) FontWeight.SemiBold
else FontWeight.Normal,
)
}
}
}
},
keyHandler = { key ->
when {
key == Key.DirectionLeft && selected > 0 -> {
onChange(selected - 1); true
}
key == Key.DirectionRight && selected < options.lastIndex -> {
onChange(selected + 1); true
}
else -> false
}
},
) { onChange((selected + 1) % options.size) }
}
@Composable @Composable
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) { fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
Surface( Surface(