mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
bbfbc13532
commit
801ff5e67b
8 changed files with 886 additions and 225 deletions
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Center the content like the Select User screen
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(32.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
|
// Match SwitchUser header height (title + subtitle) to align icons vertically across screens
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.padding(16.dp)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
|
||||||
shape = RoundedCornerShape(16.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(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val firstServerFocus = remember { FocusRequester() }
|
||||||
|
if (servers.isNotEmpty()) {
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
}
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.wrapContentWidth()
|
||||||
|
.focusRestorer(firstServerFocus)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
) {
|
||||||
|
itemsIndexed(servers) { index, server ->
|
||||||
|
val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending
|
||||||
|
ServerIconCard(
|
||||||
|
server = server,
|
||||||
|
connectionStatus = status,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
showDeleteDialog = server
|
||||||
|
},
|
||||||
|
allowDelete = true,
|
||||||
|
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
.weight(1f)
|
.height(56.dp),
|
||||||
.padding(16.dp)
|
// approximate TV button height
|
||||||
.background(
|
)
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
}
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
// Delete server dialog
|
||||||
) {
|
showDeleteDialog?.let { server ->
|
||||||
Text(
|
DialogPopup(
|
||||||
text = stringResource(R.string.discovered_servers),
|
showDialog = true,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
title = server.name ?: server.url,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
dialogItems =
|
||||||
)
|
listOf(
|
||||||
if (discoveredServers.isEmpty()) {
|
DialogItem(
|
||||||
Text(
|
stringResource(R.string.switch_servers),
|
||||||
text = stringResource(R.string.searching),
|
R.string.fa_arrow_left_arrow_right,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
viewModel.switchServer(server)
|
||||||
)
|
showDeleteDialog = null
|
||||||
} else {
|
|
||||||
ServerList(
|
|
||||||
servers = discoveredServers,
|
|
||||||
connectionStatus =
|
|
||||||
discoveredServers
|
|
||||||
.map { it.id }
|
|
||||||
.associateWith { ServerConnectionStatus.Success(PublicSystemInfo()) },
|
|
||||||
onSwitchServer = {
|
|
||||||
viewModel.addServer(it.url)
|
|
||||||
},
|
},
|
||||||
onTestServer = {
|
DialogItem(
|
||||||
viewModel.testServer(it)
|
stringResource(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
Color.Red.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
viewModel.removeServer(server)
|
||||||
|
showDeleteDialog = null
|
||||||
},
|
},
|
||||||
onAddServer = {},
|
),
|
||||||
onRemoveServer = {},
|
onDismissRequest = { showDeleteDialog = null },
|
||||||
allowAdd = false,
|
dismissOnClick = true,
|
||||||
allowDelete = false,
|
waitToLoad = true,
|
||||||
modifier =
|
properties = DialogProperties(),
|
||||||
Modifier
|
elevation = 5.dp,
|
||||||
.fillMaxWidth()
|
)
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddServer) {
|
if (showAddServer) {
|
||||||
|
var showEnterAddress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.clearAddServerState()
|
viewModel.clearAddServerState()
|
||||||
|
if (!showEnterAddress) {
|
||||||
|
viewModel.discoverServers()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
|
|
||||||
// val url = rememberTextFieldState()
|
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
|
||||||
var url by remember { mutableStateOf("") }
|
|
||||||
val submit = {
|
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
|
||||||
// viewModel.addServer(url.text.toString())
|
val filteredDiscoveredServers =
|
||||||
viewModel.addServer(url)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
// Default focus to first discovered server if available
|
||||||
|
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
|
||||||
|
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
|
||||||
|
firstDiscoveredServerFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,50 +249,134 @@ fun SwitchServerContent(
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth(.4f),
|
.fillMaxWidth(.4f),
|
||||||
) {
|
) {
|
||||||
Text(
|
if (!showEnterAddress) {
|
||||||
text = stringResource(R.string.enter_server_url),
|
// Show discovered servers first
|
||||||
)
|
Text(
|
||||||
EditTextBox(
|
text = stringResource(R.string.discovered_servers),
|
||||||
value = url,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
onValueChange = { url = it },
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
keyboardOptions =
|
)
|
||||||
KeyboardOptions(
|
|
||||||
capitalization = KeyboardCapitalization.None,
|
if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) {
|
||||||
autoCorrectEnabled = false,
|
|
||||||
keyboardType = KeyboardType.Uri,
|
|
||||||
imeAction = ImeAction.Go,
|
|
||||||
),
|
|
||||||
keyboardActions =
|
|
||||||
KeyboardActions(
|
|
||||||
onGo = { submit.invoke() },
|
|
||||||
),
|
|
||||||
// onKeyboardAction = { submit.invoke() },
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
when (val st = state) {
|
|
||||||
is LoadingState.Error -> {
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = stringResource(R.string.searching),
|
||||||
st.message ?: st.exception?.localizedMessage
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
?: "An error occurred",
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
)
|
||||||
|
} 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
TextButton(
|
||||||
}
|
onClick = {
|
||||||
TextButton(
|
showEnterAddress = true
|
||||||
onClick = { submit.invoke() },
|
},
|
||||||
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
modifier = Modifier,
|
) {
|
||||||
) {
|
Text(text = stringResource(R.string.enter_server_address))
|
||||||
if (state == LoadingState.Loading) {
|
}
|
||||||
CircularProgress(Modifier.size(32.dp))
|
} else {
|
||||||
} else {
|
// Show enter server address form
|
||||||
Text(text = stringResource(R.string.submit))
|
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 = stringResource(R.string.enter_server_url),
|
||||||
|
)
|
||||||
|
EditTextBox(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onGo = { submit.invoke() },
|
||||||
|
),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(textBoxFocusRequester)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
when (val st = state) {
|
||||||
|
is LoadingState.Error -> {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
st.message ?: st.exception?.localizedMessage
|
||||||
|
?: "An error occurred",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { submit.invoke() },
|
||||||
|
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (state == LoadingState.Loading) {
|
||||||
|
CircularProgress(Modifier.size(32.dp))
|
||||||
|
} else {
|
||||||
|
Text(text = stringResource(R.string.submit))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,27 +91,28 @@ 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),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(
|
||||||
text = stringResource(R.string.select_user),
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
) {
|
||||||
)
|
Text(
|
||||||
Text(
|
text = stringResource(R.string.select_user),
|
||||||
text = server.name ?: server.url,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
)
|
||||||
)
|
Text(
|
||||||
|
text = server.name ?: server.url,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,59 +72,76 @@ 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()
|
||||||
item {
|
.focusRestorer(firstFocusRequester)
|
||||||
HorizontalDivider()
|
.focusRequester(focusRequester),
|
||||||
ListItem(
|
) {
|
||||||
enabled = true,
|
itemsIndexed(users) { index, user ->
|
||||||
selected = false,
|
UserIconCard(
|
||||||
headlineContent = { Text(text = stringResource(R.string.add_user)) },
|
user = user,
|
||||||
leadingContent = {
|
isCurrentUser = user.user.id == currentUser?.id,
|
||||||
Icon(
|
onClick = { onSwitchUser.invoke(user.user) },
|
||||||
imageVector = Icons.Default.Add,
|
onLongClick = { showDeleteDialog = user.user },
|
||||||
tint = Color.Green.copy(alpha = .8f),
|
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||||
contentDescription = null,
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
onClick = { onAddUser.invoke() },
|
// Add User card - always rightmost
|
||||||
modifier = Modifier,
|
item {
|
||||||
)
|
AddUserCard(
|
||||||
|
onClick = { onAddUser.invoke() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
HorizontalDivider()
|
// Switch servers button below user list - centered
|
||||||
ListItem(
|
Row(
|
||||||
enabled = true,
|
horizontalArrangement = Arrangement.Center,
|
||||||
selected = false,
|
modifier =
|
||||||
headlineContent = { Text(text = stringResource(R.string.switch_servers)) },
|
Modifier
|
||||||
leadingContent = {
|
.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 ->
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue