Split the two screens that kept breaking, and let the smoke test reach them
Some checks failed
Build TV app / build (push) Has been cancelled
Some checks failed
Build TV app / build (push) Has been cancelled
SettingsScreen and LiveScreen had grown to 760 and 689 lines and were where most of the last few days' bugs lived. Each is now the screen itself plus the pieces it composes — club chooser, pickers and rows next to settings, channel row and cells next to the live list. No behaviour changes: the same composables, moved, with the visibility widened from private to internal where a caller now sits in another file. formatClock existed twice, once per screen, and the move turned that into a compile error rather than a quiet duplicate. It is one internal function now. The smoke test seeds the demo playlist into the app's preferences before launching, so it walks the channel list and the player instead of stopping at the onboarding screen — the screens this commit moves are now actually exercised. It also refuses to start when its port is already taken: a leftover server from an earlier session answered every request with a 404 and the wait loop span forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal file
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Wall-clock time as the screens show it: "20:15", German locale, device
|
||||
* timezone. Lived twice in two screens, which is once too often for a rule
|
||||
* that has to be the same everywhere.
|
||||
*/
|
||||
internal fun formatClock(millis: Long): String =
|
||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||
247
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal file
247
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal file
@@ -0,0 +1,247 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.input.key.key
|
||||
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.sp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.data.NowNext
|
||||
import dev.castarr.tv.data.isEpgPlaceholder
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
|
||||
/**
|
||||
* The club badge. The images ship in the APK (fetched at build time by
|
||||
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||
* network; the coloured shield stands in if one is ever missing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Crest(
|
||||
team: dev.castarr.tv.data.TeamFilter,
|
||||
state: AppState,
|
||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||
) {
|
||||
// The box is wider than the crest on purpose: the pulse ring is drawn
|
||||
// around it, and a box sized to the crest would clip the ring away.
|
||||
Box(Modifier.size(30.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.fillMaxSize()) {
|
||||
val grow = phase.coerceAtMost(0.75f) / 0.75f
|
||||
drawCircle(
|
||||
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
|
||||
radius = size.minDimension * (0.34f + grow * 0.15f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = "file:///android_asset/crests/${team.key}.png",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||
modifier = Modifier.size(20.dp),
|
||||
loading = { ShieldFallback(team) },
|
||||
error = { 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
|
||||
val shield = Path().apply {
|
||||
moveTo(w * 0.5f, 0f)
|
||||
lineTo(w, h * 0.18f)
|
||||
lineTo(w, h * 0.55f)
|
||||
cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h)
|
||||
cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f)
|
||||
lineTo(0f, h * 0.18f)
|
||||
close()
|
||||
}
|
||||
drawPath(shield, team.primary)
|
||||
clipPath(shield) {
|
||||
drawRect(
|
||||
team.secondary,
|
||||
topLeft = Offset(0f, h * 0.42f),
|
||||
size = androidx.compose.ui.geometry.Size(w, h * 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||
@Composable
|
||||
internal fun LogoTile(channel: Channel) {
|
||||
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
||||
.take(2).map { it.first() }.joinToString("").uppercase()
|
||||
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
|
||||
if (channel.logo.isNotEmpty()) {
|
||||
SubcomposeAsyncImage(
|
||||
model = channel.logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = { LogoInitials(initials) },
|
||||
error = { LogoInitials(initials) },
|
||||
)
|
||||
} else {
|
||||
LogoInitials(initials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoInitials(initials: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
initials,
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The next club broadcast on this channel, and how many follow it. */
|
||||
@Composable
|
||||
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||
val programme = hit.programme
|
||||
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
programme.title,
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (running) "läuft" else "ab ${formatClock(programme.start)}",
|
||||
color = if (running) CastarrColors.accent else CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-aligned EPG column with a fixed leading edge: title + times, progress
|
||||
* under the title, next programme only when it differs (design review P1/P2).
|
||||
*/
|
||||
@Composable
|
||||
internal fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
|
||||
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||
Column(modifier) {
|
||||
if (now != null) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
now.title,
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
"${formatClock(now.start)}–${formatClock(now.stop)}",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(CastarrColors.line)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(2.dp)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
val next = nowNext.next?.takeUnless {
|
||||
isEpgPlaceholder(it.title) || it.title == now.title
|
||||
}
|
||||
if (next != null) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
"danach: ${next.title}",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal file
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.data.NowNext
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
|
||||
@Composable
|
||||
internal fun ChannelRow(
|
||||
channel: Channel,
|
||||
number: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nowNext: NowNext,
|
||||
highlight: AppState.TeamHit? = null,
|
||||
playing: Boolean,
|
||||
favorite: Boolean,
|
||||
epgStamp: Long,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
// No focus scale — grown rows get clipped at the pane edges.
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
|
||||
contentColor = CastarrColors.fg,
|
||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||
focusedContentColor = CastarrColors.fg,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"$number",
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.width(40.dp),
|
||||
)
|
||||
LogoTile(channel)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.width(230.dp),
|
||||
) {
|
||||
Text(
|
||||
channel.name,
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (favorite) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("★", color = CastarrColors.accent, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
if (highlight != null) {
|
||||
// In the club view the matching broadcast is the point, not
|
||||
// whatever happens to be running.
|
||||
HighlightCell(highlight, Modifier.weight(1f))
|
||||
} else {
|
||||
EpgCell(nowNext, Modifier.weight(1f))
|
||||
}
|
||||
if (playing) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -19,9 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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
|
||||
@@ -31,10 +26,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -49,19 +40,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.data.NowNext
|
||||
import dev.castarr.tv.data.isEpgPlaceholder
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Live tab: group rail on the left (focusing a group opens it — exclusive,
|
||||
@@ -387,303 +369,3 @@ private fun GroupItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: Channel,
|
||||
number: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nowNext: NowNext,
|
||||
highlight: AppState.TeamHit? = null,
|
||||
playing: Boolean,
|
||||
favorite: Boolean,
|
||||
epgStamp: Long,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
// No focus scale — grown rows get clipped at the pane edges.
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
|
||||
contentColor = CastarrColors.fg,
|
||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||
focusedContentColor = CastarrColors.fg,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"$number",
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.width(40.dp),
|
||||
)
|
||||
LogoTile(channel)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.width(230.dp),
|
||||
) {
|
||||
Text(
|
||||
channel.name,
|
||||
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (favorite) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("★", color = CastarrColors.accent, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
if (highlight != null) {
|
||||
// In the club view the matching broadcast is the point, not
|
||||
// whatever happens to be running.
|
||||
HighlightCell(highlight, Modifier.weight(1f))
|
||||
} else {
|
||||
EpgCell(nowNext, Modifier.weight(1f))
|
||||
}
|
||||
if (playing) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The club badge. The images ship in the APK (fetched at build time by
|
||||
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||
* network; the coloured shield stands in if one is ever missing.
|
||||
*/
|
||||
@Composable
|
||||
private fun Crest(
|
||||
team: dev.castarr.tv.data.TeamFilter,
|
||||
state: AppState,
|
||||
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||
) {
|
||||
// The box is wider than the crest on purpose: the pulse ring is drawn
|
||||
// around it, and a box sized to the crest would clip the ring away.
|
||||
Box(Modifier.size(30.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.fillMaxSize()) {
|
||||
val grow = phase.coerceAtMost(0.75f) / 0.75f
|
||||
drawCircle(
|
||||
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
|
||||
radius = size.minDimension * (0.34f + grow * 0.15f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = "file:///android_asset/crests/${team.key}.png",
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||
modifier = Modifier.size(20.dp),
|
||||
loading = { ShieldFallback(team) },
|
||||
error = { 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
|
||||
val shield = Path().apply {
|
||||
moveTo(w * 0.5f, 0f)
|
||||
lineTo(w, h * 0.18f)
|
||||
lineTo(w, h * 0.55f)
|
||||
cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h)
|
||||
cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f)
|
||||
lineTo(0f, h * 0.18f)
|
||||
close()
|
||||
}
|
||||
drawPath(shield, team.primary)
|
||||
clipPath(shield) {
|
||||
drawRect(
|
||||
team.secondary,
|
||||
topLeft = Offset(0f, h * 0.42f),
|
||||
size = androidx.compose.ui.geometry.Size(w, h * 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||
@Composable
|
||||
private fun LogoTile(channel: Channel) {
|
||||
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
||||
.take(2).map { it.first() }.joinToString("").uppercase()
|
||||
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
|
||||
if (channel.logo.isNotEmpty()) {
|
||||
SubcomposeAsyncImage(
|
||||
model = channel.logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = { LogoInitials(initials) },
|
||||
error = { LogoInitials(initials) },
|
||||
)
|
||||
} else {
|
||||
LogoInitials(initials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoInitials(initials: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
initials,
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The next club broadcast on this channel, and how many follow it. */
|
||||
@Composable
|
||||
private fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||
val programme = hit.programme
|
||||
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
programme.title,
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (running) "läuft" else "ab ${formatClock(programme.start)}",
|
||||
color = if (running) CastarrColors.accent else CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (running) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatClock(millis: Long): String =
|
||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||
|
||||
/**
|
||||
* Left-aligned EPG column with a fixed leading edge: title + times, progress
|
||||
* under the title, next programme only when it differs (design review P1/P2).
|
||||
*/
|
||||
@Composable
|
||||
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
|
||||
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||
Column(modifier) {
|
||||
if (now != null) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
now.title,
|
||||
color = CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
"${formatClock(now.start)}–${formatClock(now.stop)}",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(CastarrColors.line)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(2.dp)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
val next = nowNext.next?.takeUnless {
|
||||
isEpgPlaceholder(it.title) || it.title == now.title
|
||||
}
|
||||
if (next != null) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
"danach: ${next.title}",
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,6 @@ import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.data.isEpgPlaceholder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
|
||||
@@ -106,9 +103,6 @@ private val OverlayText = TextStyle(
|
||||
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
||||
)
|
||||
|
||||
private fun formatClock(millis: Long): String =
|
||||
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||
|
||||
@Composable
|
||||
private fun Overlay(state: AppState) {
|
||||
val playFocus = remember { FocusRequester() }
|
||||
|
||||
182
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal file
182
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal file
@@ -0,0 +1,182 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
|
||||
/** Club crest from the bundled assets; initials while it loads. */
|
||||
@Composable
|
||||
internal 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
|
||||
internal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -16,18 +11,12 @@ 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.items
|
||||
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
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -37,21 +26,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.castarr.tv.AppState
|
||||
import dev.castarr.tv.BuildConfig
|
||||
@@ -61,7 +39,7 @@ import dev.castarr.tv.update.UpdateChecker
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** One open picker dialog: which setting, its options, what happens on pick. */
|
||||
private data class Picker(
|
||||
internal data class Picker(
|
||||
val title: String,
|
||||
val options: List<String>,
|
||||
val selected: Int,
|
||||
@@ -321,440 +299,3 @@ fun SettingsScreen(state: AppState) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@Composable
|
||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
// 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),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Own heavy scrim — the platform default is too light and
|
||||
// lets the white QR card fight the panel.
|
||||
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(360.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(CastarrColors.surface)
|
||||
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
picker.title.uppercase(),
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.heightIn(max = 420.dp),
|
||||
) {
|
||||
itemsIndexed(picker.options) { index, option ->
|
||||
val selected = index == picker.selected
|
||||
Surface(
|
||||
onClick = {
|
||||
picker.onPick(index)
|
||||
onClose()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
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 = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
option,
|
||||
color = if (selected) CastarrColors.accent else CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (selected) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
||||
@Composable
|
||||
private fun ValueWithCaret(value: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
value,
|
||||
color = CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val color = CastarrColors.faint
|
||||
Canvas(Modifier.size(9.dp)) {
|
||||
drawPath(
|
||||
Path().apply {
|
||||
moveTo(0f, size.height * 0.3f)
|
||||
lineTo(size.width, size.height * 0.3f)
|
||||
lineTo(size.width / 2f, size.height * 0.75f)
|
||||
close()
|
||||
},
|
||||
color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
|
||||
@Composable
|
||||
private fun RemoveIcon() {
|
||||
val color = CastarrColors.muted
|
||||
Canvas(Modifier.size(14.dp)) {
|
||||
val stroke = 2.dp.toPx()
|
||||
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
|
||||
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(CastarrColors.surface)
|
||||
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
title.uppercase(),
|
||||
color = CastarrColors.faint,
|
||||
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,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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, if (danger) CastarrColors.live else CastarrColors.accent),
|
||||
shape = rowShape,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
||||
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
|
||||
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
|
||||
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = AppFont, fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QrCard(bitmap: Bitmap) {
|
||||
// Quiet zone stays white for scannability, but the tile sits in a
|
||||
// bordered frame on the card instead of floating as a bare white block.
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(CastarrColors.bg)
|
||||
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR-Code zum Koppeln",
|
||||
modifier = Modifier.size(164.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
343
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal file
343
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal file
@@ -0,0 +1,343 @@
|
||||
package dev.castarr.tv.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
||||
@Composable
|
||||
internal fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
// 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),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Own heavy scrim — the platform default is too light and
|
||||
// lets the white QR card fight the panel.
|
||||
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(360.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(CastarrColors.surface)
|
||||
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
picker.title.uppercase(),
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.heightIn(max = 420.dp),
|
||||
) {
|
||||
itemsIndexed(picker.options) { index, option ->
|
||||
val selected = index == picker.selected
|
||||
Surface(
|
||||
onClick = {
|
||||
picker.onPick(index)
|
||||
onClose()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
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 = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
option,
|
||||
color = if (selected) CastarrColors.accent else CastarrColors.fg,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (selected) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(CastarrColors.accent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
||||
@Composable
|
||||
internal fun ValueWithCaret(value: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
value,
|
||||
color = CastarrColors.muted,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val color = CastarrColors.faint
|
||||
Canvas(Modifier.size(9.dp)) {
|
||||
drawPath(
|
||||
Path().apply {
|
||||
moveTo(0f, size.height * 0.3f)
|
||||
lineTo(size.width, size.height * 0.3f)
|
||||
lineTo(size.width / 2f, size.height * 0.75f)
|
||||
close()
|
||||
},
|
||||
color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
|
||||
@Composable
|
||||
internal fun RemoveIcon() {
|
||||
val color = CastarrColors.muted
|
||||
Canvas(Modifier.size(14.dp)) {
|
||||
val stroke = 2.dp.toPx()
|
||||
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
|
||||
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
internal fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(CastarrColors.surface)
|
||||
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
title.uppercase(),
|
||||
color = CastarrColors.faint,
|
||||
fontFamily = AppFont,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
internal val rowShape = RoundedCornerShape(10.dp)
|
||||
|
||||
/** One full-width focusable settings row: label + subtitle, value right. */
|
||||
@Composable
|
||||
internal fun SettingRow(
|
||||
label: String,
|
||||
subtitle: String? = null,
|
||||
danger: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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, if (danger) CastarrColors.live else CastarrColors.accent),
|
||||
shape = rowShape,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
||||
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
|
||||
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
|
||||
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = AppFont, fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QrCard(bitmap: Bitmap) {
|
||||
// Quiet zone stays white for scannability, but the tile sits in a
|
||||
// bordered frame on the card instead of floating as a bare white block.
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(CastarrColors.bg)
|
||||
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR-Code zum Koppeln",
|
||||
modifier = Modifier.size(164.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user