diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e9b01eb9..ec223223 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -182,14 +182,20 @@ Username (optional) Password (optional) Client ID prefix + Used to identify this Spotube instance + Encrypt the broker connection + Check the current broker settings before starting a room. Test connection Testing… Connected in %1$d ms Failed: %1$s Placeholder broker — configure your own to self-host + Connection + Credentials + Identity + Diagnostics Go to album Sleep timer Track details - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 9c9c7964..511332ba 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -74,6 +74,7 @@ import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -127,6 +128,7 @@ val sharedModules = module { // Settings singleOf(::SettingsRepository) viewModelOf(::SettingsViewModel) { bind() } + viewModelOf(::JamSettingsViewModel) // Downloads singleOf(::DownloadManager) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index bfea9743..b3f7f48f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -33,6 +33,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen import dev.krtirtho.spotube.modules.search.SearchScreen import dev.krtirtho.spotube.modules.settings.SettingsScreen +import dev.krtirtho.spotube.modules.settings.JamSettingsScreen import kotlinx.serialization.Serializable import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI @@ -87,6 +88,9 @@ sealed interface Routes : NavKey { @Serializable data object Jam : Routes + + @Serializable + data object JamSettings : Routes } @OptIn(KoinExperimentalAPI::class) @@ -171,5 +175,7 @@ val navigationModule = module { navigation { JamScreen(navigationCommands = get()) } + navigation { + JamSettingsScreen(viewModel = koinViewModel()) + } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt new file mode 100644 index 00000000..3264af61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -0,0 +1,274 @@ +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard +import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.modules.settings.sections.SettingsItemIcon +import dev.krtirtho.spotube.modules.settings.sections.settingsSectionCard +import dev.krtirtho.spotube.modules.settings.sections.settingsSectionHeader +import dev.krtirtho.spotube.resources.iconsax.CustomServer +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxEye +import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation +import dev.krtirtho.spotube.resources.iconsax.IconsaxTag +import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare +import dev.krtirtho.spotube.resources.iconsax.User +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_error_port_range +import spotube.composeapp.generated.resources.settings_error_whole_number +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle +import spotube.composeapp.generated.resources.settings_jam_section_connection +import spotube.composeapp.generated.resources.settings_jam_section_credentials +import spotube.composeapp.generated.resources.settings_jam_section_diagnostics +import spotube.composeapp.generated.resources.settings_jam_section_identity +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam + +@Composable +fun JamSettingsScreen( + viewModel: JamSettingsViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val broker = uiState.settings.jamBroker + val shellBottomInset = LocalAppShellBottomInset.current + val contentPadding = remember(shellBottomInset) { + PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) + } + + Scaffold( + topBar = { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + LazyColumn( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter), + contentPadding = contentPadding, + ) { + settingsSectionHeader(Res.string.settings_jam_section_connection) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + value = broker.host, + placeholder = "broker.example.com", + icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") }, + onValueSaved = { host -> + viewModel.update { + copy( + jamBroker = jamBroker.copy( + host = host + ) + ) + } + }, + ) + }, + { + val errorWholeNumber = + stringResource(Res.string.settings_error_whole_number) + val errorPortRange = + stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + placeholder = "1883", + normalize = { it.filter(Char::isDigit).take(5) }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> errorWholeNumber + port !in 1..65535 -> errorPortRange + else -> null + } + }, + onValueSaved = { port -> + viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) } + }, + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_port) + ) + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle), + checked = broker.useTls, + onCheckedChange = { tls -> + viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxWifiSquare, + stringResource(Res.string.settings_jam_broker_tls) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_credentials) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + placeholder = "anonymous", + onValueSaved = { username -> + viewModel.update { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.User, + stringResource(Res.string.settings_jam_broker_username) + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + placeholder = "••••••••", + onValueSaved = { password -> + viewModel.update { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxEye, + stringResource(Res.string.settings_jam_broker_password) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_identity) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle), + value = broker.clientIdPrefix, + placeholder = "spotube", + onValueSaved = { prefix -> + viewModel.update { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxTag, + stringResource(Res.string.settings_jam_broker_client_id) + ) + }, + ) + }, + ), + ) + settingsSectionHeader(Res.string.settings_jam_section_diagnostics) + settingsSectionCard( + items = listOf( + { + val canTest = broker.host.isNotBlank() && !uiState.isTesting + val resultMessage = uiState.testResult?.fold( + onSuccess = { it }, + onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, + ) + + SettingCardItem( + enabled = canTest, + title = stringResource(Res.string.settings_jam_broker_test), + subtitle = when { + uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) + resultMessage != null -> resultMessage + else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) + }, + icon = { + SettingsItemIcon( + Iconsax.IconsaxInformation, + stringResource(Res.string.settings_jam_broker_test), + ) + }, + trailingContent = { + PrimaryButton( + onClick = viewModel::testConnection, + enabled = canTest, + ) { + Text( + if (uiState.isTesting) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, + ) + } + }, + onClick = viewModel::testConnection, + ) + }, + ), + ) + item { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt new file mode 100644 index 00000000..010fc6e2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt @@ -0,0 +1,51 @@ +package dev.krtirtho.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.jam.JamRoomClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class JamSettingsUiState( + val settings: UserSettings = UserSettings(), + val isTesting: Boolean = false, + val testResult: Result? = null, +) + +class JamSettingsViewModel( + private val settingsRepository: SettingsRepository, + private val jamRoomClient: JamRoomClient, +) : ViewModel() { + private val _uiState = MutableStateFlow(JamSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.userSettings.collect { settings -> + _uiState.update { it.copy(settings = settings) } + } + } + } + + fun update(transform: UserSettings.() -> UserSettings) { + viewModelScope.launch { + settingsRepository.updateSettings(uiState.value.settings.transform()) + } + } + + fun testConnection() { + if (_uiState.value.isTesting) return + _uiState.update { it.copy(isTesting = true, testResult = null) } + viewModelScope.launch { + val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) + _uiState.update { it.copy(isTesting = false, testResult = result) } + } + } + + fun clearTestResult() { + _uiState.update { it.copy(testResult = null) } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 2321ecef..f7119895 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -114,7 +114,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { if (settingsState != null) jamSection( settings = settingsState!!, - settingsViewModel = settingsViewModel, + navigationCommands = navigatorCommands, ) if (settingsState != null) cacheSection( @@ -142,4 +142,3 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt index a15326da..9feaedf3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -5,204 +5,58 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . */ package dev.krtirtho.spotube.modules.settings.sections -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.jam.JamRoomClient -import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard -import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard +import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.settings_jam_broker_client_id -import spotube.composeapp.generated.resources.settings_jam_broker_host import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle -import spotube.composeapp.generated.resources.settings_jam_broker_password -import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note -import spotube.composeapp.generated.resources.settings_jam_broker_port -import spotube.composeapp.generated.resources.settings_jam_broker_test -import spotube.composeapp.generated.resources.settings_jam_broker_test_fail -import spotube.composeapp.generated.resources.settings_jam_broker_test_ok -import spotube.composeapp.generated.resources.settings_jam_broker_testing -import spotube.composeapp.generated.resources.settings_jam_broker_tls -import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_jam_broker_title import spotube.composeapp.generated.resources.settings_section_jam internal fun LazyListScope.jamSection( settings: UserSettings, - settingsViewModel: SettingsViewModel, + navigationCommands: NavigationCommands, ) { - val broker = settings.jamBroker - settingsSectionHeader(Res.string.settings_section_jam) settingsSectionCard( items = listOf( { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_host), + SettingCardItem( + title = stringResource(Res.string.settings_jam_broker_title), subtitle = stringResource( Res.string.settings_jam_broker_host_subtitle, - broker.host.ifBlank { "—" }, - broker.port, + settings.jamBroker.host.ifBlank { "—" }, + settings.jamBroker.port, ), - value = broker.host, - onValueSaved = { host -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(host = host)) - } - }, - placeholder = "broker.example.com", icon = { SettingsItemIcon( Iconsax.CustomServer, - stringResource(Res.string.settings_jam_broker_host), + stringResource(Res.string.settings_jam_broker_title), ) }, - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_port), - value = broker.port.toString(), - onValueSaved = { port -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883)) - } - }, - placeholder = "1883", - normalize = { it.filter { c -> c.isDigit() }.take(5) }, - validate = { input -> - val port = input.toIntOrNull() - if (port == null || port !in 1..65535) "Invalid port" else null - }, - ) - }, - { - SwitchSettingCard( - title = stringResource(Res.string.settings_jam_broker_tls), - checked = broker.useTls, - onCheckedChange = { tls -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(useTls = tls)) - } - }, - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_username), - value = broker.username.orEmpty(), - onValueSaved = { username -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) - } - }, - placeholder = "anonymous", - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_password), - value = broker.password.orEmpty(), - onValueSaved = { password -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) - } - }, - placeholder = "••••••••", - ) - }, - { - TextInputSettingCard( - title = stringResource(Res.string.settings_jam_broker_client_id), - value = broker.clientIdPrefix, - onValueSaved = { prefix -> - settingsViewModel.updateSettings { - copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) - } - }, - placeholder = "spotube", - ) - }, - { - val jamClient = koinInject() - val scope = rememberCoroutineScope() - var testing by remember { mutableStateOf(false) } - var result by remember { mutableStateOf(null) } - - Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { - Button( - onClick = { - testing = true - result = null - scope.launch { - val outcome = jamClient.testConnection(broker) - result = outcome.fold( - onSuccess = { ok -> "OK: $ok" }, - onFailure = { e -> "ERR: ${e.message ?: "unknown"}" }, - ) - testing = false - } - }, - enabled = broker.host.isNotBlank() && !testing, - ) { - Text( - text = if (testing) { - stringResource(Res.string.settings_jam_broker_testing) - } else { - stringResource(Res.string.settings_jam_broker_test) - }, + trailingContent = { + Icon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_jam_broker_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - result?.let { message -> - val ok = message.startsWith("OK:") - Text( - text = message.removePrefix("OK:").removePrefix("ERR:"), - style = MaterialTheme.typography.bodySmall, - color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - }, - { - Text( - text = stringResource(Res.string.settings_jam_broker_placeholder_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + }, + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }, ) }, ) ) -} \ No newline at end of file +}