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() }