diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt index a19f49088..fb494217b 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt @@ -92,4 +92,8 @@ class AddTorrentRepository( suspend fun getDefaultSavePath(serverId: Int) = requestManager.request(serverId) { service -> service.getDefaultSavePath() } + + suspend fun getDirectoryContent(serverId: Int, path: String) = requestManager.request(serverId) { service -> + service.getDirectoryContent(path) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt index 1ff10de4d..d33c50ff8 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt @@ -89,7 +89,7 @@ val appModule = module { viewModel { (serverId: Int, hash: String) -> TorrentPeersViewModel(serverId, hash, get(), get(), get()) } viewModel { (serverId: Int, hash: String) -> TorrentWebSeedsViewModel(serverId, hash, get(), get()) } - viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get()) } + viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get(), get()) } viewModel { (serverId: Int) -> RssFeedsViewModel(serverId, get()) } viewModel { (serverId: Int, feedPath: List, uid: String?) -> diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt index fa8434ff9..d10a34795 100755 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt @@ -109,6 +109,11 @@ class TorrentService( suspend fun getDefaultSavePath(): Response = get("app/defaultSavePath") + suspend fun getDirectoryContent(path: String): Response> = get( + "app/getDirectoryContent", + mapOf("dirPath" to path), + ) + suspend fun shutdown(): Response = post("app/shutdown") suspend fun getLog(): Response> = get("log/main") diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index 61124f0db..75ed2f5a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -68,11 +71,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -881,24 +890,93 @@ fun AddTorrentScreen( } } - OutlinedTextField( - value = savePath, - onValueChange = { savePath = it }, - label = { - Text( - text = stringResource(Res.string.torrent_add_save_path), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - modifier = Modifier.fillMaxWidth(), - enabled = autoTmmIndex != 2, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, - ), - ) + val directorySuggestions by viewModel.directorySuggestions.collectAsStateWithLifecycle() + var savePathExpanded by remember { mutableStateOf(false) } + + // Hide suggestions when IME is folded + val density = LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(density) > 0 + LaunchedEffect(isImeVisible) { + if (!isImeVisible) { + savePathExpanded = false + } + } + + ExposedDropdownMenuBox( + expanded = savePathExpanded, + onExpandedChange = { savePathExpanded = it }, + ) { + OutlinedTextField( + value = savePath, + onValueChange = { + savePath = it + savePathExpanded = true + viewModel.setSavePath(it.text) + }, + label = { + Text( + text = stringResource(Res.string.torrent_add_save_path), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + enabled = autoTmmIndex != 2, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { + savePathExpanded = false + defaultKeyboardAction(ImeAction.Next) + }, + ), + ) + + if (directorySuggestions.isNotEmpty()) { + ExposedDropdownMenu( + expanded = savePathExpanded, + onDismissRequest = { savePathExpanded = false }, + modifier = Modifier.heightIn(max = 240.dp), + ) { + directorySuggestions.forEach { suggestion -> + DropdownMenuItem( + text = { + Text( + text = buildAnnotatedString { + val matchLength = savePath.text.length + if (suggestion.startsWith(savePath.text, ignoreCase = true)) { + withStyle( + style = SpanStyle(background = Color.LightGray.copy(alpha = 0.5f)), + ) { + append(suggestion.take(matchLength)) + } + append(suggestion.drop(matchLength)) + } else { + append(suggestion) + } + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = { + savePath = TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length), + ) + savePathExpanded = true + viewModel.setSavePath(suggestion) + }, + ) + } + } + } + } OutlinedTextField( value = torrentName, diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index 77334ac6e..a84349a30 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope import dev.bartuzen.qbitcontroller.data.ServerManager import dev.bartuzen.qbitcontroller.data.repositories.AddTorrentRepository import dev.bartuzen.qbitcontroller.model.Category +import dev.bartuzen.qbitcontroller.model.QBittorrentVersion +import dev.bartuzen.qbitcontroller.network.RequestManager import dev.bartuzen.qbitcontroller.network.RequestResult import dev.bartuzen.qbitcontroller.utils.getSerializableStateFlow import io.github.vinceglb.filekit.PlatformFile @@ -15,21 +17,28 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.io.files.FileNotFoundException import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlin.math.max class AddTorrentViewModel( initialServerId: Int?, private val savedStateHandle: SavedStateHandle, private val repository: AddTorrentRepository, private val serverManager: ServerManager, + private val requestManager: RequestManager, ) : ViewModel() { private val eventChannel = Channel() val eventFlow = eventChannel.receiveAsFlow() @@ -48,7 +57,16 @@ class AddTorrentViewModel( private val _isAdding = MutableStateFlow(false) val isAdding = _isAdding.asStateFlow() + private val savePath = MutableStateFlow("") + + private val directoryInfo = MutableStateFlow(null) + + val directorySuggestions = directoryInfo + .map { it?.content ?: emptyList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private var loadCategoryTagJob: Job? = null + private var searchDirectoriesJob: Job? = null init { viewModelScope.launch { @@ -71,6 +89,16 @@ class AddTorrentViewModel( } } } + + viewModelScope.launch { + combine(savePath, serverId) { path, serverId -> + Pair(path, serverId) + }.collectLatest { (path, serverId) -> + if (serverId != null && path != null) { + searchDirectories(serverId, path) + } + } + } } fun addTorrent( @@ -218,6 +246,74 @@ class AddTorrentViewModel( } } + private fun searchDirectories(serverId: Int, path: String) { + searchDirectoriesJob?.cancel() + + val currentDirectoryInfo = directoryInfo.value + if (serverId != currentDirectoryInfo?.serverId) { + directoryInfo.value = null + } + + if (path.isBlank()) { + directoryInfo.value = currentDirectoryInfo?.copy(content = emptyList()) + return + } + + val version = requestManager.getQBittorrentVersion(serverId) + if (version < QBittorrentVersion(5, 0, 0)) { + return + } + + searchDirectoriesJob = viewModelScope.launch { + if (directoryInfo.value != null) { + delay(300) + } + + val pathDeferred = async { + when (val result = repository.getDirectoryContent(serverId, path)) { + is RequestResult.Success -> { + result.data + } + is RequestResult.Error -> { + emptyList() + } + } + } + + val parentDeferred = async { + if (path.endsWith("/") || path.endsWith("\\")) { + return@async emptyList() + } + + val lastSeparatorIndex = max( + path.lastIndexOf('/'), + path.lastIndexOf('\\'), + ) + if (lastSeparatorIndex == -1) { + return@async emptyList() + } + + val parent = path.take(lastSeparatorIndex + 1) + when (val result = repository.getDirectoryContent(serverId, parent)) { + is RequestResult.Success -> { + result.data + } + is RequestResult.Error -> { + emptyList() + } + } + } + + directoryInfo.value = DirectoryInfo( + serverId = serverId, + content = (pathDeferred.await() + parentDeferred.await()) + .distinct() + .filter { it.startsWith(path, ignoreCase = true) } + .sortedWith(String.CASE_INSENSITIVE_ORDER), + ) + } + } + fun setServerId(serverId: Int?) { savedStateHandle["serverId"] = serverId } @@ -230,6 +326,10 @@ class AddTorrentViewModel( savedStateHandle["isLoading"] = isLoading } + fun setSavePath(path: String) { + savePath.value = path + } + sealed class Event { data class Error(val error: RequestResult.Error) : Event() data object NoServersFound : Event() @@ -247,3 +347,8 @@ data class ServerData( val tagList: List, val defaultSavePath: String, ) + +private data class DirectoryInfo( + val serverId: Int, + val content: List, +)