f751b26a9e
- Add FileWatchService for real-time ON_CHANGE sync (FileObserver for direct paths, ContentObserver for SAF content:// URIs), 5s debounce - Fix remote browser stuck spinner: cancel in-flight jobs on navigation, reset entries immediately, add Retry button on error - Fix browser reuse bug: LaunchedEffect key now includes initialPath - Fix WebDavProvider: rethrow XML parse errors (no more silent Empty folder) and URL-decode file names from href - Notifications now use BigTextStyle showing per-file-type counts (Uploaded/Downloaded/Deleted) matching Autosync notification style - Wire FileWatchService into BootReceiver and HomeViewModel toggle - Register FileWatchService in AndroidManifest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
87 lines
3.2 KiB
Kotlin
87 lines
3.2 KiB
Kotlin
package com.syncflow.ui.browser
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.syncflow.data.providers.ProviderFactory
|
|
import com.syncflow.data.repository.AccountRepository
|
|
import com.syncflow.domain.model.RemoteFile
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
import javax.inject.Inject
|
|
|
|
data class BrowserState(
|
|
val accountId: Long = -1L,
|
|
val currentPath: String = "/",
|
|
val pathStack: List<String> = listOf("/"),
|
|
val entries: List<RemoteFile> = emptyList(),
|
|
val isLoading: Boolean = false,
|
|
val error: String? = null,
|
|
)
|
|
|
|
@HiltViewModel
|
|
class RemoteBrowserViewModel @Inject constructor(
|
|
private val accountRepository: AccountRepository,
|
|
private val providerFactory: ProviderFactory,
|
|
) : ViewModel() {
|
|
|
|
private val _state = MutableStateFlow(BrowserState())
|
|
val state = _state.asStateFlow()
|
|
|
|
private var loadJob: Job? = null
|
|
|
|
fun init(accountId: Long, startPath: String = "/") {
|
|
loadJob?.cancel()
|
|
_state.value = BrowserState(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath), isLoading = true)
|
|
loadJob = loadPath(accountId, startPath)
|
|
}
|
|
|
|
fun navigateTo(path: String) {
|
|
val accountId = _state.value.accountId
|
|
loadJob?.cancel()
|
|
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path, isLoading = true, entries = emptyList(), error = null) }
|
|
loadJob = loadPath(accountId, path)
|
|
}
|
|
|
|
fun navigateUp(): Boolean {
|
|
val stack = _state.value.pathStack
|
|
if (stack.size <= 1) return false
|
|
val newStack = stack.dropLast(1)
|
|
val parent = newStack.last()
|
|
loadJob?.cancel()
|
|
_state.update { it.copy(currentPath = parent, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
|
|
loadJob = loadPath(_state.value.accountId, parent)
|
|
return true
|
|
}
|
|
|
|
fun retry() {
|
|
val s = _state.value
|
|
if (s.accountId == -1L) return
|
|
loadJob?.cancel()
|
|
_state.update { it.copy(isLoading = true, error = null) }
|
|
loadJob = loadPath(s.accountId, s.currentPath)
|
|
}
|
|
|
|
private fun loadPath(accountId: Long, path: String): Job = viewModelScope.launch {
|
|
val account = accountRepository.getAccount(accountId)
|
|
if (account == null) {
|
|
_state.update { it.copy(isLoading = false, error = "Account not found") }
|
|
return@launch
|
|
}
|
|
val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
|
|
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to create provider") }
|
|
return@launch
|
|
}
|
|
provider.listFiles(path)
|
|
.onSuccess { files ->
|
|
_state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) }
|
|
}
|
|
.onFailure { e ->
|
|
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
|
|
}
|
|
}
|
|
}
|