let a server be edited and move its delete into the edit screen

This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent e6b58b8f59
commit a77ce6984e
10 changed files with 250 additions and 30 deletions
@@ -17,6 +17,12 @@ interface SourcesDao {
@Query("SELECT * FROM sources WHERE id = :id")
fun source(id: Long): Flow<Source?>
@Query("SELECT * FROM sources WHERE id = :id")
suspend fun sourceOnce(id: Long): Source?
@Query("SELECT COUNT(*) FROM sources")
suspend fun sourceCount(): Int
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
fun activeSourceId(): Flow<Long?>
@@ -64,4 +70,12 @@ interface SourcesDao {
"WHERE s.isActive = 1 LIMIT 1",
)
suspend fun activeSubsonicConfigOnce(): SubsonicConfig?
@Query(
"SELECT s.id AS id, s.name AS name, s.address AS address, c.username AS username, " +
"c.password AS password, c.useTokenAuth AS useTokenAuth " +
"FROM sources s JOIN subsonic_sources c ON c.sourceId = s.id " +
"WHERE s.id = :sourceId LIMIT 1",
)
suspend fun subsonicConfigOnce(sourceId: Long): SubsonicConfig?
}
@@ -83,6 +83,8 @@ class SourceRepository(
fun activeConfig(): Flow<SubsonicConfig?> = db.sourcesDao().activeSubsonicConfig()
suspend fun sourceConfigOnce(id: Long): SubsonicConfig? = db.sourcesDao().subsonicConfigOnce(id)
fun coverArt(
coverArt: String?,
thumbnail: Boolean = false,
@@ -151,13 +153,36 @@ class SourceRepository(
suspend fun selectSource(id: Long) = db.sourcesDao().setActiveSource(id)
suspend fun deleteSource(id: Long) {
suspend fun updateSource(
id: Long,
name: String,
address: String,
username: String,
password: String,
useTokenAuth: Boolean,
) {
val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
dao.upsertSubsonicSource(
SubsonicSource(
sourceId = id,
username = username.trim(),
password = password,
useTokenAuth = useTokenAuth,
),
)
}
suspend fun deleteSource(id: Long): Boolean {
val dao = db.sourcesDao()
if (dao.sourceCount() <= 1) return false
val wasActive = dao.activeSourceIdOnce() == id
dao.deleteSource(id)
if (wasActive) {
dao.firstSourceId()?.let { dao.setActiveSource(it) }
}
return true
}
suspend fun ping(
@@ -73,9 +73,9 @@ fun appModule(
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { DownloadsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -108,6 +108,7 @@ private object Routes {
const val SETTINGS = "settings"
const val DOWNLOADS = "downloads"
const val ADD_SERVER = "add-server"
const val EDIT_SERVER = "edit-server/{sourceId}"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
@@ -123,6 +124,8 @@ private object Routes {
) = "artist/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
fun editServer(id: Long) = "edit-server/$id"
}
internal data class BackStackKey(
@@ -308,6 +311,7 @@ private fun MainNavigation() {
SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
onEditServer = { id -> navController.navigate(Routes.editServer(id)) },
onBack = { navController.popBackStack() },
)
}
@@ -388,6 +392,16 @@ private fun MainNavigation() {
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.EDIT_SERVER,
arguments = listOf(navArgument("sourceId") { type = NavType.LongType }),
) { entry ->
AddSourceRoute(
onSaved = { navController.popBackStack() },
onBack = { navController.popBackStack() },
sourceId = entry.arguments?.getLong("sourceId"),
)
}
}
AnimatedVisibility(
@@ -14,11 +14,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -26,11 +29,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
@@ -40,12 +46,14 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun AddSourceRoute(
onSaved: () -> Unit,
onBack: (() -> Unit)?,
viewModel: AddSourceViewModel = koinViewModel(),
sourceId: Long? = null,
viewModel: AddSourceViewModel = koinViewModel(key = sourceId?.toString() ?: "new") { parametersOf(sourceId) },
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AddSourceScreen(
@@ -57,6 +65,7 @@ fun AddSourceRoute(
onTokenAuthChange = viewModel::setTokenAuth,
onTest = viewModel::testConnection,
onSave = { viewModel.save(onSaved) },
onDelete = { viewModel.delete(onSaved) },
onBack = onBack,
)
}
@@ -72,11 +81,13 @@ fun AddSourceScreen(
onTokenAuthChange: (Boolean) -> Unit,
onTest: () -> Unit,
onSave: () -> Unit,
onDelete: () -> Unit,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val nameFocus = remember { FocusRequester() }
val addressFocus = remember { FocusRequester() }
var confirmingDelete by remember { mutableStateOf(false) }
LaunchedEffect(state.nameError, state.addressError) {
when {
state.nameError -> nameFocus.requestFocus()
@@ -87,7 +98,7 @@ fun AddSourceScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Add server") },
title = { Text(if (state.isEditing) "Edit server" else "Add server") },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
@@ -95,6 +106,17 @@ fun AddSourceScreen(
}
}
},
actions = {
if (state.isEditing) {
IconButton(
onClick = { confirmingDelete = true },
enabled = state.canDelete && !state.busy,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete server")
}
}
},
)
},
bottomBar = {
@@ -141,10 +163,21 @@ fun AddSourceScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic.",
if (state.isEditing) {
"Update this server's connection details."
} else {
"Connect a Subsonic-compatible server such as Navidrome, gonic or Airsonic."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.isEditing && !state.canDelete) {
Text(
"This is the only server. Add another before you can remove this one.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
@@ -206,4 +239,23 @@ fun AddSourceScreen(
}
}
}
if (confirmingDelete) {
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text("Delete server?") },
text = { Text("${state.name} and its downloaded songs will be removed from this device.") },
confirmButton = {
TextButton(
onClick = {
confirmingDelete = false
onDelete()
},
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) { Text("Cancel") }
},
)
}
}
@@ -2,6 +2,7 @@ package com.subtracks.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,8 @@ data class AddSourceState(
val isError: Boolean = false,
val nameError: Boolean = false,
val addressError: Boolean = false,
val isEditing: Boolean = false,
val canDelete: Boolean = false,
)
internal fun AddSourceState.validated(): AddSourceState =
@@ -33,10 +36,34 @@ internal fun AddSourceState.validated(): AddSourceState =
class AddSourceViewModel(
private val sourceRepository: SourceRepository,
private val syncManager: SyncManager,
private val downloadRepository: DownloadRepository,
private val sourceId: Long? = null,
) : ViewModel() {
private val _state = MutableStateFlow(AddSourceState())
private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null))
val state: StateFlow<AddSourceState> = _state
init {
sourceId?.let { id ->
viewModelScope.launch {
val config = sourceRepository.sourceConfigOnce(id) ?: return@launch
_state.update {
it.copy(
name = config.name,
address = config.address,
username = config.username,
password = config.password,
useTokenAuth = config.useTokenAuth,
)
}
}
}
viewModelScope.launch {
sourceRepository.sources().collect { sources ->
_state.update { it.copy(canDelete = sources.size > 1) }
}
}
}
fun setName(value: String) = _state.update { it.copy(name = value, nameError = false) }
fun setAddress(value: String) = _state.update { it.copy(address = value, addressError = false) }
@@ -78,13 +105,25 @@ class AddSourceViewModel(
val current = _state.value
viewModelScope.launch {
runCatching {
sourceRepository.addSource(
name = current.name,
address = current.address,
username = current.username,
password = current.password,
useTokenAuth = current.useTokenAuth,
)
val id = sourceId
if (id == null) {
sourceRepository.addSource(
name = current.name,
address = current.address,
username = current.username,
password = current.password,
useTokenAuth = current.useTokenAuth,
)
} else {
sourceRepository.updateSource(
id = id,
name = current.name,
address = current.address,
username = current.username,
password = current.password,
useTokenAuth = current.useTokenAuth,
)
}
}.fold(
onSuccess = {
syncManager.requestSync()
@@ -99,4 +138,20 @@ class AddSourceViewModel(
)
}
}
fun delete(onDeleted: () -> Unit) {
val id = sourceId ?: return
_state.update { it.copy(busy = true, message = null) }
viewModelScope.launch {
if (!sourceRepository.deleteSource(id)) {
_state.update {
it.copy(busy = false, message = "At least one server is required", isError = true)
}
return@launch
}
downloadRepository.removeSource(id)
_state.update { it.copy(busy = false) }
onDeleted()
}
}
}
@@ -19,8 +19,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -60,6 +60,7 @@ private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency
fun SettingsRoute(
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onEditServer: (Long) -> Unit,
onBack: () -> Unit,
viewModel: SettingsViewModel = koinViewModel(),
) {
@@ -75,7 +76,7 @@ fun SettingsRoute(
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
onSelectSource = viewModel::selectSource,
onDeleteSource = viewModel::deleteSource,
onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
@@ -94,7 +95,7 @@ fun SettingsScreen(
mobileQuality: StreamQuality,
syncConcurrency: Int,
onSelectSource: (Long) -> Unit,
onDeleteSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
@@ -139,10 +140,9 @@ fun SettingsScreen(
)
},
trailingContent = {
IconButton(onClick = { onDeleteSource(source.id) }) {
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
}
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
},
modifier = Modifier.clickable(onClickLabel = "Edit ${source.name}") { onEditServer(source.id) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -6,7 +6,6 @@ import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import kotlinx.coroutines.flow.SharingStarted
@@ -16,7 +15,6 @@ import kotlinx.coroutines.launch
class SettingsViewModel(
private val sourceRepository: SourceRepository,
private val downloadRepository: DownloadRepository,
private val userPreferences: UserPreferences,
) : ViewModel() {
val sources: StateFlow<List<Source>> =
@@ -44,13 +42,6 @@ class SettingsViewModel(
viewModelScope.launch { sourceRepository.selectSource(id) }
}
fun deleteSource(id: Long) {
viewModelScope.launch {
downloadRepository.removeSource(id)
sourceRepository.deleteSource(id)
}
}
fun setWifiQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Wifi, quality) }
}
@@ -83,6 +83,43 @@ class SourceRepositoryTest {
)
}
@Test
fun editingASourceRewritesItsDetailsWithoutAddingOne() =
runTest {
repository.addSource("first", "http://a.example", "u1", "p1", true)
val id = repository.activeSourceId().first()!!
repository.addSource("second", "http://b.example", "u2", "p2", true)
repository.updateSource(id, "renamed", "c.example", "u3", "p3", false)
val sources = repository.sources().first()
assertEquals(2, sources.size)
val edited = sources.first { it.id == id }
assertEquals("renamed", edited.name)
assertEquals("http://c.example/", edited.address)
assertFalse("editing must not change which source is active", edited.isActive)
val config = repository.sourceConfigOnce(id)!!
assertEquals("u3", config.username)
assertEquals("p3", config.password)
assertFalse(config.useTokenAuth)
}
@Test
fun theLastSourceCannotBeDeleted() =
runTest {
repository.addSource("only", "http://a.example", "u", "p", true)
val id = repository.activeSourceId().first()!!
assertFalse("the last source must survive deletion", repository.deleteSource(id))
assertEquals(1, repository.sources().first().size)
repository.addSource("second", "http://b.example", "u", "p", true)
assertTrue(repository.deleteSource(id))
assertEquals(listOf("second"), repository.sources().first().map { it.name })
assertEquals("second", repository.activeConfig().first()?.name)
}
@Test
fun aTokenAuthSourceIsProbedAndSwitchedToPassword() =
runBlocking {
@@ -344,7 +344,7 @@ class AppScreenshotTest {
mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4,
onSelectSource = {},
onDeleteSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
@@ -390,6 +390,7 @@ class AppScreenshotTest {
onTokenAuthChange = {},
onTest = {},
onSave = {},
onDelete = {},
onBack = null,
)
}
@@ -398,6 +399,37 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage(file)
}
@Test
fun editSource() {
composeRule.setContent {
SubtracksTheme {
AddSourceScreen(
state =
AddSourceState(
name = "Home",
address = "https://music.example.com",
username = "austin",
password = "hunter2",
useTokenAuth = true,
isEditing = true,
canDelete = true,
),
onNameChange = {},
onAddressChange = {},
onUsernameChange = {},
onPasswordChange = {},
onTokenAuthChange = {},
onTest = {},
onSave = {},
onDelete = {},
onBack = {},
)
}
}
awaitText("Edit server")
composeRule.onRoot().captureRoboImage("src/test/screenshots/edit_source.png")
}
@Test
fun nowPlaying() {
composeRule.setContent {