mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
let a server be edited and move its delete into the edit screen
This commit is contained in:
@@ -17,6 +17,12 @@ interface SourcesDao {
|
|||||||
@Query("SELECT * FROM sources WHERE id = :id")
|
@Query("SELECT * FROM sources WHERE id = :id")
|
||||||
fun source(id: Long): Flow<Source?>
|
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")
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
fun activeSourceId(): Flow<Long?>
|
fun activeSourceId(): Flow<Long?>
|
||||||
|
|
||||||
@@ -64,4 +70,12 @@ interface SourcesDao {
|
|||||||
"WHERE s.isActive = 1 LIMIT 1",
|
"WHERE s.isActive = 1 LIMIT 1",
|
||||||
)
|
)
|
||||||
suspend fun activeSubsonicConfigOnce(): SubsonicConfig?
|
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()
|
fun activeConfig(): Flow<SubsonicConfig?> = db.sourcesDao().activeSubsonicConfig()
|
||||||
|
|
||||||
|
suspend fun sourceConfigOnce(id: Long): SubsonicConfig? = db.sourcesDao().subsonicConfigOnce(id)
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean = false,
|
thumbnail: Boolean = false,
|
||||||
@@ -151,13 +153,36 @@ class SourceRepository(
|
|||||||
|
|
||||||
suspend fun selectSource(id: Long) = db.sourcesDao().setActiveSource(id)
|
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 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
|
val wasActive = dao.activeSourceIdOnce() == id
|
||||||
dao.deleteSource(id)
|
dao.deleteSource(id)
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
dao.firstSourceId()?.let { dao.setActiveSource(it) }
|
dao.firstSourceId()?.let { dao.setActiveSource(it) }
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun ping(
|
suspend fun ping(
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ fun appModule(
|
|||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
|
||||||
viewModel { RootViewModel(get()) }
|
viewModel { RootViewModel(get()) }
|
||||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
viewModel { SettingsViewModel(get(), get()) }
|
||||||
viewModel { DownloadsViewModel(get(), 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 -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> PlaylistDetailViewModel(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 SETTINGS = "settings"
|
||||||
const val DOWNLOADS = "downloads"
|
const val DOWNLOADS = "downloads"
|
||||||
const val ADD_SERVER = "add-server"
|
const val ADD_SERVER = "add-server"
|
||||||
|
const val EDIT_SERVER = "edit-server/{sourceId}"
|
||||||
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
||||||
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
|
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
|
||||||
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
||||||
@@ -123,6 +124,8 @@ private object Routes {
|
|||||||
) = "artist/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
|
) = "artist/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
|
||||||
|
|
||||||
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
||||||
|
|
||||||
|
fun editServer(id: Long) = "edit-server/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class BackStackKey(
|
internal data class BackStackKey(
|
||||||
@@ -308,6 +311,7 @@ private fun MainNavigation() {
|
|||||||
SettingsRoute(
|
SettingsRoute(
|
||||||
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
||||||
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
|
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
|
||||||
|
onEditServer = { id -> navController.navigate(Routes.editServer(id)) },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -388,6 +392,16 @@ private fun MainNavigation() {
|
|||||||
onBack = { navController.popBackStack() },
|
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(
|
AnimatedVisibility(
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -26,11 +29,14 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddSourceRoute(
|
fun AddSourceRoute(
|
||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
onBack: (() -> Unit)?,
|
onBack: (() -> Unit)?,
|
||||||
viewModel: AddSourceViewModel = koinViewModel(),
|
sourceId: Long? = null,
|
||||||
|
viewModel: AddSourceViewModel = koinViewModel(key = sourceId?.toString() ?: "new") { parametersOf(sourceId) },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
AddSourceScreen(
|
AddSourceScreen(
|
||||||
@@ -57,6 +65,7 @@ fun AddSourceRoute(
|
|||||||
onTokenAuthChange = viewModel::setTokenAuth,
|
onTokenAuthChange = viewModel::setTokenAuth,
|
||||||
onTest = viewModel::testConnection,
|
onTest = viewModel::testConnection,
|
||||||
onSave = { viewModel.save(onSaved) },
|
onSave = { viewModel.save(onSaved) },
|
||||||
|
onDelete = { viewModel.delete(onSaved) },
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,11 +81,13 @@ fun AddSourceScreen(
|
|||||||
onTokenAuthChange: (Boolean) -> Unit,
|
onTokenAuthChange: (Boolean) -> Unit,
|
||||||
onTest: () -> Unit,
|
onTest: () -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
onBack: (() -> Unit)?,
|
onBack: (() -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val nameFocus = remember { FocusRequester() }
|
val nameFocus = remember { FocusRequester() }
|
||||||
val addressFocus = remember { FocusRequester() }
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
var confirmingDelete by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(state.nameError, state.addressError) {
|
LaunchedEffect(state.nameError, state.addressError) {
|
||||||
when {
|
when {
|
||||||
state.nameError -> nameFocus.requestFocus()
|
state.nameError -> nameFocus.requestFocus()
|
||||||
@@ -87,7 +98,7 @@ fun AddSourceScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Add server") },
|
title = { Text(if (state.isEditing) "Edit server" else "Add server") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (onBack != null) {
|
if (onBack != null) {
|
||||||
IconButton(onClick = onBack) {
|
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 = {
|
bottomBar = {
|
||||||
@@ -141,10 +163,21 @@ fun AddSourceScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
OutlinedTextField(
|
||||||
value = state.name,
|
value = state.name,
|
||||||
onValueChange = onNameChange,
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,6 +21,8 @@ data class AddSourceState(
|
|||||||
val isError: Boolean = false,
|
val isError: Boolean = false,
|
||||||
val nameError: Boolean = false,
|
val nameError: Boolean = false,
|
||||||
val addressError: Boolean = false,
|
val addressError: Boolean = false,
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
val canDelete: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun AddSourceState.validated(): AddSourceState =
|
internal fun AddSourceState.validated(): AddSourceState =
|
||||||
@@ -33,10 +36,34 @@ internal fun AddSourceState.validated(): AddSourceState =
|
|||||||
class AddSourceViewModel(
|
class AddSourceViewModel(
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
|
private val downloadRepository: DownloadRepository,
|
||||||
|
private val sourceId: Long? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(AddSourceState())
|
private val _state = MutableStateFlow(AddSourceState(isEditing = sourceId != null))
|
||||||
val state: StateFlow<AddSourceState> = _state
|
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 setName(value: String) = _state.update { it.copy(name = value, nameError = false) }
|
||||||
|
|
||||||
fun setAddress(value: String) = _state.update { it.copy(address = value, addressError = false) }
|
fun setAddress(value: String) = _state.update { it.copy(address = value, addressError = false) }
|
||||||
@@ -78,6 +105,8 @@ class AddSourceViewModel(
|
|||||||
val current = _state.value
|
val current = _state.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
val id = sourceId
|
||||||
|
if (id == null) {
|
||||||
sourceRepository.addSource(
|
sourceRepository.addSource(
|
||||||
name = current.name,
|
name = current.name,
|
||||||
address = current.address,
|
address = current.address,
|
||||||
@@ -85,6 +114,16 @@ class AddSourceViewModel(
|
|||||||
password = current.password,
|
password = current.password,
|
||||||
useTokenAuth = current.useTokenAuth,
|
useTokenAuth = current.useTokenAuth,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
sourceRepository.updateSource(
|
||||||
|
id = id,
|
||||||
|
name = current.name,
|
||||||
|
address = current.address,
|
||||||
|
username = current.username,
|
||||||
|
password = current.password,
|
||||||
|
useTokenAuth = current.useTokenAuth,
|
||||||
|
)
|
||||||
|
}
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
syncManager.requestSync()
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Add
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -60,6 +60,7 @@ private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency
|
|||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onOpenDownloads: () -> Unit,
|
onOpenDownloads: () -> Unit,
|
||||||
|
onEditServer: (Long) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: SettingsViewModel = koinViewModel(),
|
viewModel: SettingsViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -75,7 +76,7 @@ fun SettingsRoute(
|
|||||||
mobileQuality = mobileQuality,
|
mobileQuality = mobileQuality,
|
||||||
syncConcurrency = syncConcurrency,
|
syncConcurrency = syncConcurrency,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onDeleteSource = viewModel::deleteSource,
|
onEditServer = onEditServer,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
onMobileQualityChange = viewModel::setMobileQuality,
|
onMobileQualityChange = viewModel::setMobileQuality,
|
||||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||||
@@ -94,7 +95,7 @@ fun SettingsScreen(
|
|||||||
mobileQuality: StreamQuality,
|
mobileQuality: StreamQuality,
|
||||||
syncConcurrency: Int,
|
syncConcurrency: Int,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onDeleteSource: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||||
onSyncConcurrencyChange: (Int) -> Unit,
|
onSyncConcurrencyChange: (Int) -> Unit,
|
||||||
@@ -139,10 +140,9 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = { onDeleteSource(source.id) }) {
|
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null)
|
||||||
Icon(Icons.Rounded.Delete, contentDescription = "Remove ${source.name}")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.clickable(onClickLabel = "Edit ${source.name}") { onEditServer(source.id) },
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -16,7 +15,6 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val downloadRepository: DownloadRepository,
|
|
||||||
private val userPreferences: UserPreferences,
|
private val userPreferences: UserPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val sources: StateFlow<List<Source>> =
|
val sources: StateFlow<List<Source>> =
|
||||||
@@ -44,13 +42,6 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSource(id: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
downloadRepository.removeSource(id)
|
|
||||||
sourceRepository.deleteSource(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWifiQuality(quality: StreamQuality) {
|
fun setWifiQuality(quality: StreamQuality) {
|
||||||
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Wifi, quality) }
|
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
|
@Test
|
||||||
fun aTokenAuthSourceIsProbedAndSwitchedToPassword() =
|
fun aTokenAuthSourceIsProbedAndSwitchedToPassword() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ class AppScreenshotTest {
|
|||||||
mobileQuality = StreamQuality(192, "mp3"),
|
mobileQuality = StreamQuality(192, "mp3"),
|
||||||
syncConcurrency = 4,
|
syncConcurrency = 4,
|
||||||
onSelectSource = {},
|
onSelectSource = {},
|
||||||
onDeleteSource = {},
|
onEditServer = {},
|
||||||
onWifiQualityChange = {},
|
onWifiQualityChange = {},
|
||||||
onMobileQualityChange = {},
|
onMobileQualityChange = {},
|
||||||
onSyncConcurrencyChange = {},
|
onSyncConcurrencyChange = {},
|
||||||
@@ -390,6 +390,7 @@ class AppScreenshotTest {
|
|||||||
onTokenAuthChange = {},
|
onTokenAuthChange = {},
|
||||||
onTest = {},
|
onTest = {},
|
||||||
onSave = {},
|
onSave = {},
|
||||||
|
onDelete = {},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -398,6 +399,37 @@ class AppScreenshotTest {
|
|||||||
composeRule.onRoot().captureRoboImage(file)
|
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
|
@Test
|
||||||
fun nowPlaying() {
|
fun nowPlaying() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
|
|||||||
Reference in New Issue
Block a user