From dbd17597b2c024112ed094e84aab4398bda0162f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 10 May 2026 08:50:08 -0400 Subject: [PATCH] Show public users when switching users (#1358) ## Description Show the public users (ie "Hide this user from login screens" is unchecked) if the server has any. Previously, all users had to be explicitly added by username or quick connect. This PR also refactors the server & user pages to use modern state flows and deletes some unused code. ### Related issues Closes #600 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainContent.kt | 2 +- .../wholphin/data/ServerRepository.kt | 7 +- .../wholphin/ui/setup/ServerList.kt | 127 ----- .../wholphin/ui/setup/SwitchServerContent.kt | 79 ++- .../ui/setup/SwitchServerViewModel.kt | 216 ++++---- .../wholphin/ui/setup/SwitchUserContent.kt | 483 ++++++++++-------- .../wholphin/ui/setup/SwitchUserViewModel.kt | 231 +++++---- .../damontecres/wholphin/ui/setup/UserList.kt | 44 +- .../damontecres/wholphin/ui/BasicUiTests.kt | 17 +- 9 files changed, 579 insertions(+), 627 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 0794da67..4de2ab2f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -85,7 +85,7 @@ fun MainContent( is SetupDestination.UserList -> { SwitchUserContent( - currentServer = key.server, + server = key.server, Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 2d786ed8..7e32f2e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -73,7 +73,7 @@ class ServerRepository suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, - ): CurrentUser? = + ): CurrentUser = withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") @@ -104,15 +104,16 @@ class ServerRepository currentUserId = updatedUser.id.toServerString() }.build() } + val currentUser = CurrentUser(updatedServer, updatedUser) withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser) + _current.value = currentUser _currentUserDto.value = userDto } getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } - return@withContext _current.value + return@withContext currentUser } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 5fd47621..84e4cea6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -9,19 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -31,12 +24,9 @@ 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.window.DialogProperties import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon -import androidx.tv.material3.IconButtonDefaults -import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text @@ -44,9 +34,6 @@ import com.github.damontecres.wholphin.R 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.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.PublicSystemInfo import java.util.UUID @@ -62,120 +49,6 @@ sealed interface ServerConnectionStatus { ) : ServerConnectionStatus } -/** - * Display a list of servers plus option to add a new one - */ -@Composable -fun ServerList( - servers: List, - connectionStatus: Map, - onSwitchServer: (JellyfinServer) -> Unit, - onTestServer: (JellyfinServer) -> Unit, - onAddServer: () -> Unit, - onRemoveServer: (JellyfinServer) -> Unit, - allowAdd: Boolean, - allowDelete: Boolean, - modifier: Modifier = Modifier, -) { - var showDeleteDialog by remember { mutableStateOf(null) } - - LazyColumn(modifier = modifier) { - items(servers) { server -> - val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) }, - supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) }, - leadingContent = { - when (status) { - is ServerConnectionStatus.Success -> {} - - ServerConnectionStatus.Pending -> { - CircularProgress( - Modifier.size(IconButtonDefaults.MediumIconSize), - ) - } - - is ServerConnectionStatus.Error -> { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = status.message, - tint = MaterialTheme.colorScheme.errorContainer, - ) - } - } - }, - onClick = { - when (status) { - is ServerConnectionStatus.Success -> { - onSwitchServer.invoke(server) - } - - ServerConnectionStatus.Pending -> {} - - is ServerConnectionStatus.Error -> { - onTestServer.invoke(server) - } - } - }, - onLongClick = { - if (allowDelete) { - showDeleteDialog = server - } - }, - modifier = Modifier, - ) - } - if (allowAdd) { - item { - HorizontalDivider() - ListItem( - enabled = true, - selected = false, - headlineContent = { Text(text = stringResource(R.string.add_server)) }, - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - tint = Color.Green.copy(alpha = .8f), - contentDescription = null, - ) - }, - onClick = { onAddServer.invoke() }, - modifier = Modifier, - ) - } - } - } - showDeleteDialog?.let { server -> - DialogPopup( - showDialog = allowDelete, - title = server.name ?: server.url, - dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_servers), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchServer.invoke(server) - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveServer.invoke(server) - }, - ), - onDismissRequest = { showDeleteDialog = null }, - dismissOnClick = true, - waitToLoad = true, - properties = DialogProperties(), - elevation = 5.dp, - ) - } -} - /** * Generate a consistent color for a UUID */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index a4af8580..d975fc29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -20,8 +20,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -43,11 +43,14 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.components.BasicDialog 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.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.ifElse @@ -60,16 +63,30 @@ fun SwitchServerContent( modifier: Modifier = Modifier, viewModel: SwitchServerViewModel = hiltViewModel(), ) { - val servers by viewModel.servers.observeAsState(listOf()) - val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) - - var showAddServer by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(null) } - + val state by viewModel.state.collectAsState() LaunchedEffect(Unit) { viewModel.init() } + when (val st = state.loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage(modifier) + + LoadingState.Success -> SwitchServerContentInternal(state, viewModel, modifier) + } +} + +@Composable +private fun SwitchServerContentInternal( + state: SwitchServerState, + viewModel: SwitchServerViewModel, + modifier: Modifier = Modifier, +) { + var showAddServer by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(null) } Box( modifier = modifier.dimAndBlur(showAddServer || showDeleteDialog != null), ) { @@ -108,7 +125,7 @@ fun SwitchServerContent( ) { val focusRequester = remember { FocusRequester() } val firstServerFocus = remember { FocusRequester() } - if (servers.isNotEmpty()) { + if (state.servers.isNotEmpty()) { LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } } LazyRow( @@ -120,16 +137,15 @@ fun SwitchServerContent( .focusRestorer(firstServerFocus) .focusRequester(focusRequester), ) { - itemsIndexed(servers) { index, server -> - val status = serverStatus[server.id] ?: ServerConnectionStatus.Pending + itemsIndexed(state.servers) { index, server -> ServerIconCard( - server = server, - connectionStatus = status, + server = server.server, + connectionStatus = server.status, isCurrentServer = false, // TODO: Determine current server if needed onClick = { - when (status) { + when (server.status) { is ServerConnectionStatus.Success -> { - viewModel.switchServer(server) + viewModel.switchServer(server.server) } ServerConnectionStatus.Pending -> { @@ -137,21 +153,30 @@ fun SwitchServerContent( } is ServerConnectionStatus.Error -> { - viewModel.testServer(server) + viewModel.testServer(server.server) } } }, onLongClick = { - showDeleteDialog = server + showDeleteDialog = server.server }, allowDelete = true, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstServerFocus)), + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost item { AddServerCard( onClick = { showAddServer = true }, + modifier = + Modifier.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -207,13 +232,11 @@ fun SwitchServerContent( } } - val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) val filteredDiscoveredServers = - remember(discoveredServers) { + remember(state.discoveredServers) { val seenUrls = mutableSetOf() - discoveredServers.filter { server -> + state.discoveredServers.filter { server -> val normalizedUrl = server.url.lowercase().trim() if (normalizedUrl in seenUrls) { false // Duplicate, filter it out @@ -258,7 +281,7 @@ fun SwitchServerContent( color = MaterialTheme.colorScheme.onSurface, ) - if (filteredDiscoveredServers.isEmpty() && discoveredServers.isEmpty()) { + if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) { Text( text = stringResource(R.string.searching), style = MaterialTheme.typography.bodyMedium, @@ -294,7 +317,9 @@ fun SwitchServerContent( selected = false, headlineContent = { Text( - text = server.name?.ifBlank { null } ?: server.url, + text = + server.name?.ifBlank { null } + ?: server.url, style = MaterialTheme.typography.bodyLarge, ) }, @@ -323,7 +348,7 @@ fun SwitchServerContent( } } else { // Show enter server address form - val state by viewModel.addServerState.observeAsState(LoadingState.Pending) + val addServerState = state.addServerState var url by remember { mutableStateOf("") } val submit = { viewModel.addServer(url) @@ -357,7 +382,7 @@ fun SwitchServerContent( .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) - when (val st = state) { + when (val st = addServerState) { is LoadingState.Error -> { Text( text = @@ -371,10 +396,10 @@ fun SwitchServerContent( } TextButton( onClick = { submit.invoke() }, - enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, + enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending, modifier = Modifier, ) { - if (state == LoadingState.Loading) { + if (addServerState == LoadingState.Loading) { CircularProgress(Modifier.size(32.dp)) } else { Text(text = stringResource(R.string.submit)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 08398a86..9c28aff8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.setup import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -11,28 +10,23 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager -import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher -import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions -import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerIssue import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -45,52 +39,54 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, - @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, - @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { - val servers = MutableLiveData>(listOf()) - val serverStatus = MutableLiveData>(mapOf()) - val serverQuickConnect = MutableLiveData>(mapOf()) - - val discoveredServers = MutableLiveData>(listOf()) - - val addServerState = MutableLiveData(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchServerState()) + val state: StateFlow = _state fun clearAddServerState() { - addServerState.value = LoadingState.Pending + _state.update { it.copy(addServerState = LoadingState.Pending) } } fun init() { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = mapOf() - serverQuickConnect.value = mapOf() - } + _state.update { SwitchServerState() } val allServers = serverDao .getServers() .map { it.server } .sortedWith(compareBy { it.name }.thenBy { it.url }) - withContext(Dispatchers.Main) { - servers.value = allServers + .map { ServerState(it) } + _state.update { + it.copy( + loading = LoadingState.Success, + servers = allServers, + ) } allServers.forEach { server -> - internalTestServer(server) + internalTestServer(server.server) } } } + private fun updateServerState( + server: JellyfinServer, + result: ServerConnectionStatus, + ) { + _state.update { + val servers = + it.servers.toMutableList().apply { + val index = indexOfFirst { it.server.id == server.id } + set(index, get(index).copy(server = server, status = result)) + } + it.copy(servers = servers) + } + } + fun testServer(server: JellyfinServer) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) - } + updateServerState(server, ServerConnectionStatus.Pending) viewModelScope.launchIO { - delay(1000) + delay(500) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) @@ -100,58 +96,34 @@ class SwitchServerViewModel } } - private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus = - try { - val systemInfo = - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() - .content - val result = ServerConnectionStatus.Success(systemInfo) - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - result, - ) - } + private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus { + val result = + try { + val systemInfo = + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() + .content + ServerConnectionStatus.Success(systemInfo) + } catch (ex: Exception) { + Timber.w(ex, "Error checking server ${server.url}") + ServerConnectionStatus.Error(ex.localizedMessage) } - result - } catch (ex: Exception) { - val status = ServerConnectionStatus.Error(ex.localizedMessage) - Timber.w(ex, "Error checking server ${server.url}") - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - status, - ) - } - } - status - } + updateServerState(server, result) + return result + } fun switchServer(server: JellyfinServer) { viewModelScope.launchIO { - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Pending, - ) - } - } + updateServerState(server, ServerConnectionStatus.Pending) val result = internalTestServer(server) if (result is ServerConnectionStatus.Success) { val updatedServer = @@ -160,9 +132,7 @@ class SwitchServerViewModel version = result.systemInfo.version, ) serverRepository.addAndChangeServer(updatedServer) - withContext(Dispatchers.Main) { - navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) - } + navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } else if (result is ServerConnectionStatus.Error) { showToast(context, "Error connecting: $${result.message}") } @@ -170,7 +140,7 @@ class SwitchServerViewModel } fun addServer(inputUrl: String) { - addServerState.value = LoadingState.Loading + _state.update { it.copy(addServerState = LoadingState.Loading) } viewModelScope.launchIO { try { val scores = @@ -192,27 +162,10 @@ class SwitchServerViewModel version = serverInfo.version, ) serverRepository.addAndChangeServer(server) - val quickConnect = - jellyfin - .createApi(serverUrl) - .quickConnectApi - .getQuickConnectEnabled() - .content - withContext(Dispatchers.Main) { - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(id, quickConnect) - } - } - withContext(Dispatchers.Main) { - addServerState.value = LoadingState.Success - navigationManager.navigateTo(SetupDestination.UserList(server)) - } + _state.update { it.copy(addServerState = LoadingState.Success) } + navigationManager.navigateTo(SetupDestination.UserList(server)) } else { - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error("Server returned invalid response") - } + _state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) } } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -240,14 +193,11 @@ class SwitchServerViewModel "${it.address} - $issues" } val message = "Error, tried addresses:\n$errors" - addServerState.setValueOnMain(LoadingState.Error(message)) + _state.update { it.copy(addServerState = LoadingState.Error(message)) } } } catch (ex: Exception) { Timber.w(ex, "Error creating API for $inputUrl") - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error(exception = ex) - } + _state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) } } } } @@ -262,23 +212,37 @@ class SwitchServerViewModel fun discoverServers() { viewModelScope.launchIO { jellyfin.discovery.discoverLocalServers().collect { server -> - val newServerList = - discoveredServers.value!! - .toMutableList() - .apply { - add( - JellyfinServer( - server.id.toUUID(), - server.name, - server.address, - null, - ), - ) - } - withContext(Dispatchers.Main) { - discoveredServers.value = newServerList + val jellyfinServer = + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + null, + ) + _state.update { + it.copy( + discoveredServers = + it.discoveredServers + .toMutableList() + .apply { + add(jellyfinServer) + }, + ) } } } } } + +data class SwitchServerState( + val loading: LoadingState = LoadingState.Pending, + val servers: List = emptyList(), + val discoveredServers: List = emptyList(), + val addServerState: LoadingState = LoadingState.Pending, +) + +data class ServerState( + val server: JellyfinServer, + val status: ServerConnectionStatus = ServerConnectionStatus.Pending, + val quickConnect: Boolean = false, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 53afb662..8b2245ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -14,10 +14,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,40 +43,45 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch @Composable fun SwitchUserContent( - currentServer: JellyfinServer, + server: JellyfinServer, modifier: Modifier = Modifier, viewModel: SwitchUserViewModel = hiltViewModel( - creationCallback = { it.create(currentServer) }, + creationCallback = { it.create(server) }, ), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.init() } -// val currentServer by viewModel.serverRepository.currentServer.observeAsState() + val state by viewModel.state.collectAsState() + val currentUser by viewModel.serverRepository.currentUser.observeAsState() - val users by viewModel.users.observeAsState(listOf()) - - val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) - val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } + var username by remember(server) { mutableStateOf("") } - val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) - val loginAttempts by viewModel.loginAttempts.observeAsState(0) - LaunchedEffect(userState) { + fun showAddUserDialog(user: JellyfinUser?) { + username = user?.name ?: "" + showAddUser = true + } + + LaunchedEffect(state.switchUserState) { if (!showAddUser) { - when (val s = userState) { + when (val s = state.switchUserState) { is LoadingState.Error -> { val msg = s.message ?: s.exception?.localizedMessage Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() @@ -86,247 +93,281 @@ fun SwitchUserContent( } var switchUserWithPin by remember { mutableStateOf(null) } - currentServer?.let { server -> - Box( - modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(16.dp), + when (val st = state.loading) { + is LoadingState.Error -> { + ErrorMessage(st, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Box( + modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.select_user), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = server.name ?: server.url, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - UserList( - users = users, - currentUser = currentUser, - onSwitchUser = { user -> - if (user.hasPin) { - switchUserWithPin = user - } else { - viewModel.switchUser(user) - } - }, - onAddUser = { showAddUser = true }, - onRemoveUser = { user -> - viewModel.removeUser(user) - }, - onSwitchServer = { - viewModel.setupNavigationManager.navigateTo( - SetupDestination.ServerList, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - if (showAddUser) { - var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } - LaunchedEffect(Unit) { - viewModel.clearSwitchUserState() - viewModel.resetAttempts() - if (useQuickConnect) { - viewModel.initiateQuickConnect(server) - } - } - BasicDialog( - onDismissRequest = { - viewModel.cancelQuickConnect() - showAddUser = false - }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .focusGroup() - .padding(16.dp) - .fillMaxWidth(.4f), + .fillMaxWidth() + .align(Alignment.Center) + .padding(16.dp), ) { - if (useQuickConnect) { - if (quickConnect == null && userState !is LoadingState.Error) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .height(32.dp) - .align(Alignment.CenterHorizontally), - ) { - CircularProgress(Modifier.size(20.dp)) - Text( - text = "Waiting for Quick Connect code...", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.select_user), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = server.name ?: server.url, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + UserList( + users = state.users, + currentUser = currentUser, + onSwitchUser = { user -> + if (user.accessToken == null) { + showAddUserDialog(user) + } else if (user.hasPin) { + switchUserWithPin = user + } else { + val result = viewModel.trySwitchUser(user) + scope.launch { + result.await()?.let { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + showAddUserDialog(user) + } + } } - } else if (quickConnect != null) { + }, + onAddUser = { + showAddUserDialog(null) + }, + onRemoveUser = { user -> + viewModel.removeUser(user) + }, + onSwitchServer = { + viewModel.setupNavigationManager.navigateTo( + SetupDestination.ServerList, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + if (showAddUser) { + var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) } + LaunchedEffect(Unit) { + viewModel.clearSwitchUserState() + viewModel.resetAttempts() + if (useQuickConnect) { + viewModel.initiateQuickConnect(server) + } + } + BasicDialog( + onDismissRequest = { + viewModel.cancelQuickConnect() + showAddUser = false + }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusGroup() + .padding(16.dp) + .fillMaxWidth(.4f), + ) { + if (useQuickConnect) { + if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .height(32.dp) + .align(Alignment.CenterHorizontally), + ) { + CircularProgress(Modifier.size(20.dp)) Text( - text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", + text = "Waiting for Quick Connect code...", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = quickConnect?.code ?: "Failed to get code", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = Modifier, ) } - UserStateError(userState) - TextButton( - stringRes = R.string.username_or_password, - onClick = { - viewModel.cancelQuickConnect() - viewModel.clearSwitchUserState() - useQuickConnect = false - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } else { -// val username = rememberTextFieldState() -// val password = rememberTextFieldState() - var username by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val onSubmit = { - viewModel.login( - server, - username, - password, - ) - } - val focusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } else if (state.quickConnectStatus != null) { Text( - text = "Enter username/password to login to ${server.name ?: server.url}", + text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - UserStateError(userState) - Row( - verticalAlignment = Alignment.CenterVertically, + Text( + text = state.quickConnectStatus?.code ?: "Failed to get code", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Username", - modifier = Modifier.padding(end = 8.dp), - ) - EditTextBox( - value = username, - onValueChange = { username = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - // onKeyboardAction = { + ) + } + UserStateError(state.switchUserState) + TextButton( + stringRes = R.string.username_or_password, + onClick = { + viewModel.cancelQuickConnect() + viewModel.clearSwitchUserState() + useQuickConnect = false + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } else { + var password by remember { mutableStateOf("") } + val onSubmit = { + viewModel.login( + server, + username, + password, + ) + } + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (username.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } + Text( + text = "Enter username/password to login to ${server.name ?: server.url}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + UserStateError(state.switchUserState) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { username = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + // onKeyboardAction = { // passwordFocusRequester.tryRequestFocus() // }, - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = "Password", - modifier = Modifier.padding(end = 8.dp), - ) - LaunchedEffect(password) { - viewModel.clearSwitchUserState() - } - EditTextBox( - value = password, - onValueChange = { password = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke() }, - ), - isInputValid = { userState !is LoadingState.Error }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - TextButton( - stringRes = R.string.login, - onClick = { onSubmit.invoke() }, - enabled = username.isNotNullOrBlank(), - modifier = Modifier.align(Alignment.CenterHorizontally), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(focusRequester), ) } - if (loginAttempts > 2) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { Text( - text = "Trouble logging in?", - modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.password), + modifier = Modifier.padding(end = 8.dp), ) - TextButton( - stringRes = R.string.show_debug_info, - onClick = { - viewModel.navigationManager.navigateTo(Destination.Debug) - }, - modifier = Modifier.align(Alignment.CenterHorizontally), + LaunchedEffect(password) { + viewModel.clearSwitchUserState() + } + EditTextBox( + value = password, + onValueChange = { password = it }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke() }, + ), + isInputValid = { state.switchUserState !is LoadingState.Error }, + modifier = Modifier.focusRequester(passwordFocusRequester), ) } + TextButton( + stringRes = R.string.login, + onClick = { onSubmit.invoke() }, + enabled = username.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + if (state.loginAttempts > 2) { + Text( + text = "Trouble logging in?", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + TextButton( + stringRes = R.string.show_debug_info, + onClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) } } } - switchUserWithPin?.let { user -> - PinEntryDialog( - onDismissRequest = { switchUserWithPin = null }, - onClickServerAuth = { - showAddUser = true - switchUserWithPin = null - }, - onTextChange = { - if (it == user.pin) viewModel.switchUser(user) - }, - ) - } + } + switchUserWithPin?.let { user -> + PinEntryDialog( + onDismissRequest = { switchUserWithPin = null }, + onClickServerAuth = { + showAddUserDialog(user) + switchUserWithPin = null + }, + onTextChange = { + if (it == user.pin) { + val result = viewModel.trySwitchUser(user) + scope.launch { + result.await()?.let { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + showAddUserDialog(user) + switchUserWithPin = null + } + } + } + }, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 68b9e583..9a1f06a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.JellyfinServerDao @@ -11,18 +10,22 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions @@ -30,7 +33,6 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException 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.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult @@ -54,87 +56,85 @@ class SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel } - val serverQuickConnect = MutableLiveData(false) - - val users = MutableLiveData>(listOf()) - val quickConnectState = MutableLiveData(null) + private val _state = MutableStateFlow(SwitchUserState()) + val state: StateFlow = _state private var quickConnectJob: Job? = null - val switchUserState = MutableLiveData(LoadingState.Pending) - - val loginAttempts = MutableLiveData(0) - fun clearSwitchUserState() { - switchUserState.value = LoadingState.Pending + _state.update { it.copy(switchUserState = LoadingState.Pending) } } fun resetAttempts() { - loginAttempts.value = 0 - } - - init { - init() + _state.update { it.copy(loginAttempts = 0) } } fun init() { - viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + viewModelScope.launchDefault { serverRepository.switchServerOrUser() } quickConnectJob?.cancel() viewModelScope.launchIO { - users.setValueOnMain(listOf()) - val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.setValueOnMain(serverUsers) + _state.update { SwitchUserState() } + try { + val serverUsers = getUsers() + _state.update { + it.copy( + loading = LoadingState.Success, + users = serverUsers, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching users for server $server") + _state.update { + it.copy( + loading = LoadingState.Error(ex), + ) + } } } viewModelScope.launchIO { try { - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() val quickConnect by jellyfin - .createApi(server.url) - .quickConnectApi + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).quickConnectApi .getQuickConnectEnabled() - withContext(Dispatchers.Main) { - serverQuickConnect.value = quickConnect - } + _state.update { it.copy(quickConnectEnabled = quickConnect) } + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.w(ex, "Error checking quick connect for server ${server.url}") - withContext(Dispatchers.Main) { - serverQuickConnect.value = false - } + _state.update { it.copy(quickConnectEnabled = false) } } } } - fun switchUser(user: JellyfinUser) { - viewModelScope.launchIO { + fun trySwitchUser(user: JellyfinUser): Deferred = + viewModelScope.async(Dispatchers.IO) { try { val current = serverRepository.changeUser(server, user) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + null + } catch (ex: InvalidStatusException) { + if (ex.status == 401) { + "Credentials expired, please login in again" + } else { + ex.localizedMessage } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - setError("Error switching user", ex) + ex.localizedMessage } } - } fun login( server: JellyfinServer, @@ -150,18 +150,11 @@ class SwitchUserViewModel password = password, ) val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) } catch (ex: Exception) { Timber.e(ex, "Error logging in user") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - switchUserState.value = - LoadingState.Error("Invalid username or password") - } + _state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) } } else { setError("Error during login", ex) } @@ -175,44 +168,37 @@ class SwitchUserViewModel viewModelScope.launchIO { try { val api = jellyfin.createApi(server.url) - var state = + var quickConnectStatus = api .quickConnectApi .initiateQuickConnect() .content + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } - withContext(Dispatchers.Main) { - quickConnectState.value = state - } - - while (!state.authenticated) { + while (!quickConnectStatus.authenticated) { delay(5_000L) - state = + quickConnectStatus = api.quickConnectApi .getQuickConnectState( - secret = state.secret, + secret = quickConnectStatus.secret, ).content - withContext(Dispatchers.Main) { - quickConnectState.value = state - } + _state.update { it.copy(quickConnectStatus = quickConnectStatus) } } val authenticationResult by api.userApi.authenticateWithQuickConnect( - QuickConnectDto(secret = state.secret), + QuickConnectDto(secret = quickConnectStatus.secret), ) val current = serverRepository.changeUser(server.url, authenticationResult) - if (current != null) { - withContext(Dispatchers.Main) { - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } - } + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } catch (_: CancellationException) { + // no-op, user may have canceled } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") if (ex is InvalidStatusException && ex.status == 401) { - withContext(Dispatchers.Main) { - quickConnectState.value = null - serverQuickConnect.value = false + _state.update { + it.copy( + quickConnectEnabled = false, + quickConnectStatus = null, + ) } } setError("Error with Quick Connect", ex) @@ -222,39 +208,86 @@ class SwitchUserViewModel fun cancelQuickConnect() { quickConnectJob?.cancel() - quickConnectState.value = null + _state.update { + it.copy( + quickConnectStatus = null, + ) + } } fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) val serverUsers = getUsers() - withContext(Dispatchers.Main) { - users.value = serverUsers - } + _state.update { it.copy(users = serverUsers) } } } - private suspend fun getUsers(): List { - 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 getUsers(): List = + withContext(Dispatchers.IO) { + val api = jellyfin.createApi(server.url) + val knownUsers = + serverDao + .getServer(server.id) + ?.users + .orEmpty() + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = true, + ) + } + val knownUserIds = knownUsers.map { it.user.id } + val publicUsers = + api.userApi + .getPublicUsers() + .content + .map { + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = null, + ) + }.filter { it.id !in knownUserIds } + .map { + JellyfinUserAndImage( + user = it, + imageUrl = api.imageApi.getUserImageUrl(it.id), + known = false, + ) + } - private suspend fun setError( + knownUsers + publicUsers + } + + private fun setError( msg: String? = null, ex: Exception? = null, - ) = withContext(Dispatchers.Main) { - loginAttempts.value = (loginAttempts.value ?: 0) + 1 - switchUserState.value = LoadingState.Error(msg, ex) + ) { + _state.update { + it.copy( + loginAttempts = it.loginAttempts + 1, + switchUserState = LoadingState.Error(msg, ex), + ) + } } } data class JellyfinUserAndImage( val user: JellyfinUser, val imageUrl: String?, + val known: Boolean, +) + +data class SwitchUserState( + // LoadingState for fetching available users + val loading: LoadingState = LoadingState.Pending, + val quickConnectEnabled: Boolean = false, + val quickConnectStatus: QuickConnectResult? = null, + val users: List = emptyList(), + // LoadingState for while adding/switching users + val switchUserState: LoadingState = LoadingState.Pending, + val loginAttempts: Int = 0, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index 1b35cce6..ddc171c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -58,6 +58,7 @@ 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.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID @@ -75,7 +76,7 @@ fun UserList( onSwitchServer: () -> Unit, modifier: Modifier = Modifier, ) { - var showDeleteDialog by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(null) } Column( modifier = modifier, @@ -106,7 +107,7 @@ fun UserList( user = user, isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, - onLongClick = { showDeleteDialog = user.user }, + onLongClick = { showDeleteDialog = user }, modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, ) } @@ -152,28 +153,33 @@ fun UserList( showDeleteDialog?.let { user -> DialogPopup( showDialog = true, - title = user.name ?: user.id.toString(), + title = user.user.name ?: user.user.id.toServerString(), dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_user), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchUser.invoke(user) - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveUser.invoke(user) - }, - ), + buildList { + add( + DialogItem( + stringResource(R.string.switch_user), + R.string.fa_arrow_left_arrow_right, + ) { + onSwitchUser.invoke(user.user) + }, + ) + if (user.known) { + add( + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + onRemoveUser.invoke(user.user) + }, + ) + } + }, onDismissRequest = { showDeleteDialog = null }, dismissOnClick = true, waitToLoad = true, properties = DialogProperties(), - elevation = 5.dp, ) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt index 25121739..027afb98 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -184,11 +185,15 @@ class BasicUiTests { composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() TestModule.testDispatcher.scheduler.advanceUntilIdle() - switchServerViewModel.addServerState.value.let { + switchServerViewModel.state.value.addServerState.let { if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) } @@ -249,11 +254,15 @@ class BasicUiTests { composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + composeTestRule + .onNodeWithText("Submit") + .assertIsEnabled() + .requestFocus() + .performClickEnter() TestModule.testDispatcher.scheduler.advanceUntilIdle() - Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + Assert.assertTrue(switchServerViewModel.state.value.addServerState is LoadingState.Error) composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() }