Redesign Select Server and Select User screens (#478)

Redesign Select Server and Select User screens with horizontal card
layout, similar to traditional streaming services

- Replace vertical lists with horizontal list of icons
- Center align text in Quick Connect and Username/Password modals
- Display Jellyfin profile images
- Fallback to first letter of username if no image is available
- Filter out duplicate server entries in the auto-discovery list

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
YogiBear12 2025-12-18 03:56:19 +11:00 committed by GitHub
parent bbfbc13532
commit 801ff5e67b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 886 additions and 225 deletions

View file

@ -145,6 +145,8 @@ class ImageUrlService
imageIndex = imageIndex, imageIndex = imageIndex,
) )
fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId)
/** /**
* Just a convenient way to get the image URL and remember it * Just a convenient way to get the image URL and remember it
*/ */

View file

@ -68,6 +68,7 @@ val SlimItemFields =
object Cards { object Cards {
val height2x3 = 172.dp val height2x3 = 172.dp
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
} }
object AspectRatios { object AspectRatios {

View file

@ -1,8 +1,17 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
@ -13,18 +22,26 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -157,3 +174,255 @@ fun ServerList(
) )
} }
} }
/**
* Generate a consistent color for a UUID
*/
@Composable
fun rememberIdColor(id: UUID): Color =
remember(id) {
// Generate a color based on the server ID hash, fallback to URL hash
val hash = id.hashCode()
val hue = (hash % 360).toFloat()
val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0
val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors)
// Convert HSV to RGB
val c = brightness * saturation
val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1))
val m = brightness - c
val (r, g, b) =
when {
hue < 60 -> Triple(c, x, 0f)
hue < 120 -> Triple(x, c, 0f)
hue < 180 -> Triple(0f, c, x)
hue < 240 -> Triple(0f, x, c)
hue < 300 -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
Color(
red = (r + m).coerceIn(0f, 1f),
green = (g + m).coerceIn(0f, 1f),
blue = (b + m).coerceIn(0f, 1f),
)
}
/**
* Server icon card component - displays a circular card with server name/letter
*/
@Composable
fun ServerIconCard(
server: JellyfinServer,
connectionStatus: ServerConnectionStatus,
isCurrentServer: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
allowDelete: Boolean = false,
) {
val interactionSource = remember { MutableInteractionSource() }
// Generate unique color for this server
val serverColor = rememberIdColor(server.id)
// Card dimensions - circular card
val cardSize = Cards.serverUserCircle
val displayText =
remember(server) {
(server.name ?: server.url.replace(Regex("^https?://"), ""))
.firstOrNull()
?.uppercase()
?: "?"
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
onLongClick = if (allowDelete) onLongClick else null,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor =
if (isCurrentServer) {
serverColor.copy(alpha = 0.7f)
} else {
serverColor.copy(alpha = 0.5f)
},
focusedContainerColor =
if (isCurrentServer) {
serverColor.copy(alpha = 0.9f)
} else {
serverColor.copy(alpha = 0.7f)
},
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// Show connection status indicator or server name/letter
when (connectionStatus) {
is ServerConnectionStatus.Success -> {
// Show server name/letter
Text(
text = displayText,
style =
MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
ServerConnectionStatus.Pending -> {
CircularProgress(
modifier = Modifier.size(cardSize * 0.4f),
)
}
is ServerConnectionStatus.Error -> {
// Show warning icon with server letter below
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = connectionStatus.message,
tint = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.size(cardSize * 0.3f),
)
Text(
text = displayText,
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
}
}
// Server name below the card
Text(
text = server.name?.ifBlank { null } ?: server.url,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}
/**
* Add Server card component - displays a + icon in a circle
*/
@Composable
fun AddServerCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Use a neutral gray color for the add server card
val addServerColor = MaterialTheme.colorScheme.surfaceVariant
// Card dimensions - circular card (same as server cards)
val cardSize = Cards.height2x3 * 0.75f // ~120dp
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = addServerColor.copy(alpha = 0.4f),
focusedContainerColor = addServerColor.copy(alpha = 0.6f),
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.add_server),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
)
}
}
// "Add Server" text below the card
Text(
text = stringResource(R.string.add_server),
style =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}

View file

@ -1,16 +1,23 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -29,19 +38,21 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.PublicSystemInfo
@Composable @Composable
fun SwitchServerContent( fun SwitchServerContent(
@ -51,143 +62,185 @@ fun SwitchServerContent(
val servers by viewModel.servers.observeAsState(listOf()) val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<com.github.damontecres.wholphin.data.model.JellyfinServer?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() viewModel.init()
viewModel.discoverServers()
} }
Box( Box(
modifier = modifier.dimAndBlur(showAddServer), modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null),
) {
Row(
modifier =
Modifier
.align(Alignment.Center)
.padding(32.dp),
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) // Center the content like the Select User screen
.padding(16.dp) .align(Alignment.Center)
.background( .padding(16.dp),
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), ) {
shape = RoundedCornerShape(16.dp), // Match SwitchUser header height (title + subtitle) to align icons vertically across screens
), Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = stringResource(R.string.select_server), text = stringResource(R.string.select_server),
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
ServerList( // Invisible subtitle placeholder to mirror the server name line on the Select User screen
servers = servers, Text(
connectionStatus = serverStatus, text = "Server placeholder",
onSwitchServer = { style = MaterialTheme.typography.titleLarge,
viewModel.switchServer(it) color = Color.Transparent,
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {
showAddServer = true
},
onRemoveServer = {
viewModel.removeServer(it)
},
allowAdd = true,
allowDelete = true,
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
) )
} }
// Discover
Column( // Horizontal scrollable list of server icons - centered
horizontalAlignment = Alignment.CenterHorizontally, Box(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(),
modifier = contentAlignment = Alignment.Center,
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) { ) {
Text( val focusRequester = remember { FocusRequester() }
text = stringResource(R.string.discovered_servers), val firstServerFocus = remember { FocusRequester() }
style = MaterialTheme.typography.displaySmall, if (servers.isNotEmpty()) {
color = MaterialTheme.colorScheme.onSurface, LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
) }
if (discoveredServers.isEmpty()) { LazyRow(
Text( horizontalArrangement = Arrangement.spacedBy(24.dp),
text = stringResource(R.string.searching), contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
style = MaterialTheme.typography.titleMedium, modifier =
color = MaterialTheme.colorScheme.onSurface, Modifier
) .wrapContentWidth()
} else { .focusRestorer(firstServerFocus)
ServerList( .focusRequester(focusRequester),
servers = discoveredServers, ) {
connectionStatus = itemsIndexed(servers) { index, server ->
discoveredServers val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
.map { it.id } ServerIconCard(
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) }, server = server,
onSwitchServer = { connectionStatus = status,
viewModel.addServer(it.url) isCurrentServer = false, // TODO: Determine current server if needed
onClick = {
when (status) {
is ServerConnectionStatus.Success -> {
viewModel.switchServer(server)
}
ServerConnectionStatus.Pending -> {
// Do nothing while pending
}
is ServerConnectionStatus.Error -> {
viewModel.testServer(server)
}
}
}, },
onTestServer = { onLongClick = {
viewModel.testServer(it) showDeleteDialog = server
}, },
onAddServer = {}, allowDelete = true,
onRemoveServer = {}, modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
allowAdd = false, )
allowDelete = false, }
// Add Server card - always rightmost
item {
AddServerCard(
onClick = { showAddServer = true },
)
}
}
}
// Non-focusable spacer to mirror the space occupied by the "Switch Servers" button
Spacer(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background( .height(56.dp),
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), // approximate TV button height
shape = RoundedCornerShape(16.dp),
),
) )
} }
// Delete server dialog
showDeleteDialog?.let { server ->
DialogPopup(
showDialog = true,
title = server.name ?: server.url,
dialogItems =
listOf(
DialogItem(
stringResource(R.string.switch_servers),
R.string.fa_arrow_left_arrow_right,
) {
viewModel.switchServer(server)
showDeleteDialog = null
},
DialogItem(
stringResource(R.string.delete),
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
viewModel.removeServer(server)
showDeleteDialog = null
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
if (showAddServer) {
var showEnterAddress by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
if (!showEnterAddress) {
viewModel.discoverServers()
}
}
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers =
remember(discoveredServers) {
val seenUrls = mutableSetOf<String>()
discoveredServers.filter { server ->
val normalizedUrl = server.url.lowercase().trim()
if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out
} else {
seenUrls.add(normalizedUrl)
true // First occurrence, keep it
}
} }
} }
if (showAddServer) { val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
viewModel.clearAddServerState() // Default focus to first discovered server if available
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
firstDiscoveredServerFocusRequester.tryRequestFocus()
} }
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
// val url = rememberTextFieldState()
var url by remember { mutableStateOf("") }
val submit = {
// viewModel.addServer(url.text.toString())
viewModel.addServer(url)
} }
BasicDialog( BasicDialog(
onDismissRequest = { onDismissRequest = {
showAddServer = false showAddServer = false
showEnterAddress = false
viewModel.clearAddServerState() viewModel.clearAddServerState()
}, },
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp, elevation = 10.dp,
) { ) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -196,6 +249,90 @@ fun SwitchServerContent(
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(.4f), .fillMaxWidth(.4f),
) { ) {
if (!showEnterAddress) {
// Show discovered servers first
Text(
text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else if (filteredDiscoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.no_servers_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
) {
items(
filteredDiscoveredServers.size,
key = { filteredDiscoveredServers[it].url },
) { index ->
val server = filteredDiscoveredServers[index]
val focusRequester =
if (index == 0) {
firstDiscoveredServerFocusRequester
} else {
remember { FocusRequester() }
}
ListItem(
enabled = true,
selected = false,
headlineContent = {
Text(
text = server.name?.ifBlank { null } ?: server.url,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = {
viewModel.addServer(server.url)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
TextButton(
onClick = {
showEnterAddress = true
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = stringResource(R.string.enter_server_address))
}
} else {
// Show enter server address form
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url)
}
val textBoxFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
textBoxFocusRequester.tryRequestFocus()
}
Text( Text(
text = stringResource(R.string.enter_server_url), text = stringResource(R.string.enter_server_url),
) )
@ -213,10 +350,9 @@ fun SwitchServerContent(
KeyboardActions( KeyboardActions(
onGo = { submit.invoke() }, onGo = { submit.invoke() },
), ),
// onKeyboardAction = { submit.invoke() },
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequester) .focusRequester(textBoxFocusRequester)
.fillMaxWidth(), .fillMaxWidth(),
) )
when (val st = state) { when (val st = state) {
@ -247,3 +383,4 @@ fun SwitchServerContent(
} }
} }
} }
}

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -11,7 +10,6 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -36,7 +34,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -94,16 +91,16 @@ fun SwitchUserContent(
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.5f) .fillMaxWidth()
.align(Alignment.Center) .align(Alignment.Center)
.padding(16.dp) .padding(16.dp),
.background( ) {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), Column(
shape = RoundedCornerShape(16.dp), horizontalAlignment = Alignment.CenterHorizontally,
), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = stringResource(R.string.select_user), text = stringResource(R.string.select_user),
@ -115,6 +112,7 @@ fun SwitchUserContent(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
}
UserList( UserList(
users = users, users = users,
currentUser = currentUser, currentUser = currentUser,
@ -134,13 +132,7 @@ fun SwitchUserContent(
onSwitchServer = { onSwitchServer = {
viewModel.navigationManager.navigateTo(Destination.ServerList) viewModel.navigationManager.navigateTo(Destination.ServerList)
}, },
modifier = modifier = Modifier.fillMaxWidth(),
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
) )
} }
} }
@ -170,7 +162,7 @@ fun SwitchUserContent(
Modifier Modifier
.focusGroup() .focusGroup()
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(.66f), .fillMaxWidth(.4f),
) { ) {
if (useQuickConnect) { if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) { if (quickConnect == null && userState !is LoadingState.Error) {
@ -196,6 +188,8 @@ fun SwitchUserContent(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
Text( Text(
text = quickConnect?.code ?: "Failed to get code", text = quickConnect?.code ?: "Failed to get code",
@ -233,6 +227,8 @@ fun SwitchUserContent(
text = "Enter username/password to login to ${server.name ?: server.url}", text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
) )
UserStateError(userState) UserStateError(userState)
Row( Row(

View file

@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -25,6 +26,7 @@ import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
@ -41,6 +43,7 @@ class SwitchUserViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val imageUrlService: ImageUrlService,
@Assisted val server: JellyfinServer, @Assisted val server: JellyfinServer,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -50,7 +53,7 @@ class SwitchUserViewModel
val serverQuickConnect = MutableLiveData<Boolean>(false) val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUser>>(listOf()) val users = MutableLiveData<List<JellyfinUserAndImage>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null) val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null private var quickConnectJob: Job? = null
@ -78,8 +81,7 @@ class SwitchUserViewModel
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launchIO { viewModelScope.launchIO {
users.setValueOnMain(listOf()) users.setValueOnMain(listOf())
val serverUsers = val serverUsers = getUsers()
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
users.setValueOnMain(serverUsers) users.setValueOnMain(serverUsers)
} }
@ -215,14 +217,23 @@ class SwitchUserViewModel
fun removeUser(user: JellyfinUser) { fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.removeUser(user) serverRepository.removeUser(user)
val serverUsers = val serverUsers = getUsers()
serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
users.value = serverUsers users.value = serverUsers
} }
} }
} }
private suspend fun getUsers(): List<JellyfinUserAndImage> {
val api = jellyfin.createApi(server.url)
return serverDao
.getServer(server.id)
?.users
?.sortedBy { it.name }
?.map { JellyfinUserAndImage(it, api.imageApi.getUserImageUrl(it.id)) }
.orEmpty()
}
private suspend fun setError( private suspend fun setError(
msg: String? = null, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,
@ -231,3 +242,8 @@ class SwitchUserViewModel
switchUserState.value = LoadingState.Error(msg, ex) switchUserState.value = LoadingState.Error(msg, ex)
} }
} }
data class JellyfinUserAndImage(
val user: JellyfinUser,
val imageUrl: String?,
)

View file

@ -1,37 +1,68 @@
package com.github.damontecres.wholphin.ui.setup package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
/** /**
* Display a list of users plus option to add a new one or switch servers * Display a list of users plus option to add a new one or switch servers
* Redesigned to match streaming service style with horizontal scrollable user icons
*/ */
@Composable @Composable
fun UserList( fun UserList(
users: List<JellyfinUser>, users: List<JellyfinUserAndImage>,
currentUser: JellyfinUser?, currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit, onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit, onAddUser: () -> Unit,
@ -41,61 +72,78 @@ fun UserList(
) { ) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) } var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
LazyColumn(modifier = modifier) { Column(
items(users) { user -> modifier = modifier,
ListItem( horizontalAlignment = Alignment.CenterHorizontally,
enabled = true, verticalArrangement = Arrangement.spacedBy(24.dp),
selected = user == currentUser, ) {
headlineContent = { Text(text = user.name ?: user.id.toString()) }, // Horizontal scrollable list of user icons - centered
leadingContent = { Box(
if (user.id == currentUser?.id) { modifier = Modifier.fillMaxWidth(),
Icon( contentAlignment = Alignment.Center,
imageVector = Icons.Default.Check, ) {
contentDescription = "current user", val focusRequester = remember { FocusRequester() }
) val firstFocusRequester = remember { FocusRequester() }
} if (users.isNotEmpty()) {
}, LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
onClick = { onSwitchUser.invoke(user) }, }
onLongClick = { LazyRow(
showDeleteDialog = user horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
}, contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
modifier = Modifier, modifier =
Modifier
.wrapContentWidth()
.focusRestorer(firstFocusRequester)
.focusRequester(focusRequester),
) {
itemsIndexed(users) { index, user ->
UserIconCard(
user = user,
isCurrentUser = user.user.id == currentUser?.id,
onClick = { onSwitchUser.invoke(user.user) },
onLongClick = { showDeleteDialog = user.user },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
) )
} }
// Add User card - always rightmost
item { item {
HorizontalDivider() AddUserCard(
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = stringResource(R.string.add_user)) },
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(alpha = .8f),
contentDescription = null,
)
},
onClick = { onAddUser.invoke() }, onClick = { onAddUser.invoke() },
modifier = Modifier,
) )
} }
item { }
HorizontalDivider() }
ListItem(
enabled = true, // Switch servers button below user list - centered
selected = false, Row(
headlineContent = { Text(text = stringResource(R.string.switch_servers)) }, horizontalArrangement = Arrangement.Center,
leadingContent = { modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Button(
onClick = { onSwitchServer.invoke() },
modifier = Modifier.width(200.dp), // Fixed width for consistency
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
text = stringResource(R.string.fa_arrow_left_arrow_right), text = stringResource(R.string.fa_arrow_left_arrow_right),
fontFamily = FontAwesome, fontFamily = FontAwesome,
modifier = Modifier.padding(end = 8.dp),
) )
}, Text(
onClick = { onSwitchServer.invoke() }, text = stringResource(R.string.switch_servers),
modifier = Modifier, textAlign = TextAlign.Center,
) )
} }
} }
}
}
showDeleteDialog?.let { user -> showDeleteDialog?.let { user ->
DialogPopup( DialogPopup(
showDialog = true, showDialog = true,
@ -124,3 +172,193 @@ fun UserList(
) )
} }
} }
@Composable
private fun UserIconCard(
user: JellyfinUserAndImage,
isCurrentUser: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Generate unique color for this user
val userColor = rememberIdColor(user.user.id)
// Track image loading errors
var imageError by remember { mutableStateOf(false) }
// Card dimensions - circular card
val cardSize = Cards.serverUserCircle
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// Circular card with colored background
Surface(
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor =
if (isCurrentUser) {
userColor.copy(alpha = 0.7f)
} else {
userColor.copy(alpha = 0.5f)
},
focusedContainerColor =
if (isCurrentUser) {
userColor.copy(alpha = 0.9f)
} else {
userColor.copy(alpha = 0.7f)
},
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (user.imageUrl.isNotNullOrBlank() && !imageError) {
AsyncImage(
model = user.imageUrl,
contentDescription = user.user.name,
contentScale = ContentScale.Crop,
onError = { imageError = true },
modifier =
Modifier
.fillMaxSize()
.clip(CircleShape),
)
} else {
// Show big bold first letter of username
val firstLetter =
remember(user) {
user.user.let {
(it.name ?: it.id.toString()).firstOrNull()?.uppercase()
} ?: "?"
}
Text(
text = firstLetter,
style =
MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
// Username below the card
Text(
text = user.user.name ?: user.user.id.toString(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}
/**
* Add User card component - displays a + icon in a circle
*/
@Composable
private fun AddUserCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
// Use a neutral gray color for the add user card
val addUserColor = MaterialTheme.colorScheme.surfaceVariant
// Card dimensions - circular card (same as user cards)
val cardSize = Cards.height2x3 * 0.75f // ~120dp
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale
) {
// Circular card with colored background
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(
containerColor = addUserColor.copy(alpha = 0.4f),
focusedContainerColor = addUserColor.copy(alpha = 0.6f),
),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.onSurface,
),
shape = CircleShape,
),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.add_user),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon
)
}
}
// "Add User" text below the card
Text(
text = stringResource(R.string.add_user),
style =
MaterialTheme.typography.bodyLarge.copy(
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.width(cardSize)
.padding(horizontal = 4.dp),
)
}
}

View file

@ -37,6 +37,7 @@
<string name="downloading">Downloading…</string> <string name="downloading">Downloading…</string>
<string name="enabled">Enabled</string> <string name="enabled">Enabled</string>
<string name="enter_server_url">Enter Server IP or URL</string> <string name="enter_server_url">Enter Server IP or URL</string>
<string name="enter_server_address">Enter server address</string>
<string name="episodes">Episodes</string> <string name="episodes">Episodes</string>
<string name="error_loading_collection">Error loading collection %1$s</string> <string name="error_loading_collection">Error loading collection %1$s</string>
<string name="external_track">External</string> <string name="external_track">External</string>
@ -69,6 +70,7 @@
<string name="next_up">Next Up</string> <string name="next_up">Next Up</string>
<string name="no_data">No data</string> <string name="no_data">No data</string>
<string name="no_results">No results</string> <string name="no_results">No results</string>
<string name="no_servers_found">No servers found</string>
<string name="no_scheduled_recordings">No scheduled recordings</string> <string name="no_scheduled_recordings">No scheduled recordings</string>
<string name="no_update_available">No update available</string> <string name="no_update_available">No update available</string>
<string name="none">None</string> <string name="none">None</string>