git.iko.soy/opencode-android
on d643c13 help sign in
113 lines 4.3 kB history raw
1package soy.iko.opencode.ui.server
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.di.AppContainer
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.first
12import kotlinx.coroutines.launch
13import kotlinx.coroutines.withTimeoutOrNull
14import soy.iko.opencode.R
15import soy.iko.opencode.util.runCatchingCancellable
16import java.util.UUID
18@Immutable
19data class ServerEditState(
20 val id: String? = null,
21 val label: String = "",
22 val baseUrl: String = "",
23 val username: String = "",
24 val password: String = "",
25 val loaded: Boolean = false,
26 val saving: Boolean = false,
27 val error: String? = null,
28) {
29 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
30 val isNew: Boolean get() = id == null
33private fun isValidUrl(url: String): Boolean = try {
34 val u = java.net.URI(url.trim())
35 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
36} catch (_: Exception) {
37 false
40class ServerEditViewModel(
41 private val container: AppContainer,
42 private val profileId: String?,
43) : ViewModel() {
45 private val _state = MutableStateFlow(ServerEditState(id = profileId))
46 val state: StateFlow<ServerEditState> = _state.asStateFlow()
48 init {
49 viewModelScope.launch {
50 if (profileId != null) {
51 // Wait for a non-empty profile list before searching, so DataStore
52 // emitting an empty list during loading doesn't cause a false timeout.
53 val existing = withTimeoutOrNull(5_000) {
54 container.profileStore.profiles
55 .first { it.isNotEmpty() || it.none { p -> p.id == profileId } }
56 .firstOrNull { it.id == profileId }
57 }
58 if (existing != null) {
59 _state.value = ServerEditState(
60 id = existing.id,
61 label = existing.label,
62 baseUrl = existing.baseUrl,
63 username = existing.username.orEmpty(),
64 password = existing.password.orEmpty(),
65 loaded = true,
66 )
67 return@launch
68 }
69 // Profile existed but timed out loading — show an error instead of
70 // silently presenting an empty form that looks like "new server".
71 _state.value = ServerEditState(
72 id = profileId,
73 loaded = true,
74 error = container.string(R.string.error_load_timeout),
75 )
76 return@launch
77 }
78 _state.value = _state.value.copy(loaded = true)
79 }
80 }
82 fun update(transform: (ServerEditState) -> ServerEditState) {
83 _state.value = transform(_state.value)
84 }
86 fun save(onDone: () -> Unit) {
87 val s = _state.value
88 if (!s.canSave || s.saving) return
89 _state.value = s.copy(saving = true)
90 viewModelScope.launch {
91 val result = runCatchingCancellable {
92 val existingLastUsed = if (s.id != null) {
93 withTimeoutOrNull(5_000) {
94 container.profileStore.profiles.first()
95 .firstOrNull { it.id == s.id }?.lastUsed
96 } ?: 0L
97 } else 0L
98 container.profileStore.save(
99 ServerProfile(
100 id = s.id ?: UUID.randomUUID().toString(),
101 label = s.label.trim(),
102 baseUrl = s.baseUrl.trim(),
103 username = s.username.trim().takeIf { it.isNotBlank() },
104 password = s.password.trim().takeIf { it.isNotEmpty() },
105 lastUsed = existingLastUsed,
106 ),
107 )
108 }
109 result.onSuccess { onDone() }
110 .onFailure { _state.value = _state.value.copy(error = container.friendlyError(it), saving = false) }
111 }
112 }