Use the user's profile image on nav drawer (#723)

## Description
Replaces the generic user icon with the user's profile image on the nav
drawer

If there's no image it will fall back to the generated color & first
letter just like on the user list page.

### Related issues
Closes #631

### Screenshots
<img width="54" height="160" alt="image"
src="https://github.com/user-attachments/assets/bee21e56-104a-420f-bb7d-aae717f8aeb0"
/>
This commit is contained in:
Ray 2026-01-19 11:39:21 -05:00 committed by GitHub
parent 4dd44b935c
commit 946043342a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 112 additions and 7 deletions

View file

@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowLeft
@ -87,6 +86,7 @@ import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
@ -99,6 +99,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import java.util.UUID
@ -108,6 +110,7 @@ import javax.inject.Inject
class NavDrawerViewModel
@Inject
constructor(
private val api: ApiClient,
private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
@ -191,6 +194,8 @@ class NavDrawerViewModel
fun setShowMore(value: Boolean) {
showMore.value = value
}
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
}
sealed interface NavDrawerItem {
@ -386,12 +391,11 @@ fun NavDrawer(
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem(
text = user?.name ?: "",
subtext = server?.name ?: server?.url,
icon = Icons.Default.AccountCircle,
selected = false,
drawerOpen = drawerState.isOpen,
val userImageUrl = remember(user) { viewModel.getUserImage(user) }
ProfileIcon(
user = user,
imageUrl = userImageUrl,
serverName = server.name ?: server.url,
interactionSource = interactionSource,
onClick = {
viewModel.setupNavigationManager.navigateTo(
@ -572,6 +576,44 @@ fun NavDrawer(
}
}
@Composable
fun NavigationDrawerScope.ProfileIcon(
user: JellyfinUser,
imageUrl: String?,
serverName: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem(
modifier = modifier,
selected = false,
onClick = onClick,
leadingContent = {
UserIconCardImage(
id = user.id,
name = user.name,
imageUrl = imageUrl,
modifier = Modifier.fillMaxSize(),
)
},
supportingContent = {
Text(
text = serverName,
maxLines = 1,
)
},
interactionSource = interactionSource,
) {
Text(
modifier = Modifier,
text = user.name ?: user.id.toString(),
maxLines = 1,
)
}
}
@Composable
fun NavigationDrawerScope.IconNavItem(
text: String,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -38,6 +39,7 @@ 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.sp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.Button
@ -51,10 +53,13 @@ import com.github.damontecres.wholphin.R
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.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.UUID
/**
* Display a list of users plus option to add a new one or switch servers
@ -285,6 +290,64 @@ private fun UserIconCard(
}
}
@Composable
fun UserIconCardImage(
id: UUID,
name: String?,
imageUrl: String?,
modifier: Modifier = Modifier,
) {
var imageError by remember { mutableStateOf(false) }
val userColor = rememberIdColor(id)
Box(
modifier =
modifier.background(
color = userColor,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isNotNullOrBlank() && !imageError) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
onError = { imageError = true },
modifier =
Modifier
.fillMaxSize()
.clip(CircleShape),
)
} else {
val firstLetter =
remember(id, name) {
(name ?: id.toString()).firstOrNull()?.uppercase() ?: "?"
}
Text(
text = firstLetter,
style = MaterialTheme.typography.bodyLarge,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
}
}
@PreviewTvSpec
@Composable
fun UserIconCardImagePreview() {
WholphinTheme {
UserIconCardImage(
id = UUID.randomUUID(),
name = "A smith",
imageUrl = null,
modifier = Modifier.size(24.dp),
)
}
}
/**
* Add User card component - displays a + icon in a circle
*/