1package soy.iko.opencode.ui.server
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.imePadding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.text.KeyboardActions
17import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.foundation.verticalScroll
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.filled.Visibility
22import androidx.compose.material.icons.filled.VisibilityOff
23import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
25import androidx.compose.material3.CircularProgressIndicator
26import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.OutlinedButton
31import androidx.compose.material3.OutlinedTextField
32import androidx.compose.material3.Scaffold
33import androidx.compose.material3.Text
34import androidx.compose.material3.TextButton
35import androidx.compose.material3.TopAppBar
36import androidx.compose.runtime.Composable
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
40import androidx.compose.runtime.saveable.rememberSaveable
41import androidx.compose.runtime.setValue
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.platform.testTag
45import androidx.compose.ui.semantics.contentDescription
46import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.text.input.ImeAction
48import androidx.compose.ui.text.input.KeyboardType
49import androidx.compose.ui.text.input.PasswordVisualTransformation
50import androidx.compose.ui.text.input.VisualTransformation
51import androidx.compose.ui.res.stringResource
52import androidx.compose.ui.unit.dp
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.R
57import soy.iko.opencode.ui.vmFactory
59@OptIn(ExperimentalMaterial3Api::class)
62 container: AppContainer,
65 sourceId: String? = null,
67 val vm: ServerEditViewModel =
68 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
69 val state by vm.state.collectAsStateWithLifecycle()
70 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
71 var passwordVisible by rememberSaveable { mutableStateOf(false) }
75 // A save is in flight: ignore the back gesture (matching the BackHandler guard
76 // below). Popping here would cancel the save's viewModelScope coroutine and
77 // silently abandon the write; the VM calls onDone itself once the save lands.
79 state.isDirty -> showDiscardConfirm = true
84 // Intercept the system back gesture when the form has unsaved changes so the user
85 // gets a chance to keep editing instead of losing their work silently.
86 BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
87 showDiscardConfirm = true
93 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
95 IconButton(onClick = ::safeExit) {
96 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
103 val loadingLabel = stringResource(R.string.loading)
104 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
105 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
110 passwordVisible = passwordVisible,
111 onTogglePassword = { passwordVisible = !passwordVisible },
119 if (showDiscardConfirm) {
121 onDismissRequest = { showDiscardConfirm = false },
122 title = { Text(stringResource(R.string.discard_title)) },
123 text = { Text(stringResource(R.string.discard_text)) },
125 TextButton(onClick = {
126 showDiscardConfirm = false
128 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
131 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
138private fun ServerEditForm(
139 state: ServerEditState,
140 passwordVisible: Boolean,
141 onTogglePassword: () -> Unit,
142 padding: androidx.compose.foundation.layout.PaddingValues,
143 vm: ServerEditViewModel,
146 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
151 .widthIn(max = 600.dp)
153 .verticalScroll(rememberScrollState()),
154 verticalArrangement = Arrangement.spacedBy(12.dp),
158 onValueChange = { v -> vm.update { it.copy(label = v) } },
159 label = { Text(stringResource(R.string.label_optional)) },
161 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
162 modifier = Modifier.fillMaxWidth().testTag("server_label"),
165 value = state.baseUrl,
166 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
167 label = { Text(stringResource(R.string.base_url)) },
168 placeholder = { Text(stringResource(R.string.base_url_hint)) },
170 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
172 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
173 // Offer a one-tap "http://" fix for bare host:port input instead of a
174 // generic error, so the user doesn't have to know the URL needs a scheme.
175 val suggestion = suggestUrlScheme(state.baseUrl)
176 if (suggestion != null) {
178 onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
179 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
181 Text(stringResource(R.string.suggest_scheme))
184 Text(stringResource(R.string.invalid_url))
188 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
189 modifier = Modifier.fillMaxWidth().testTag("server_url"),
192 onClick = { vm.probe() },
193 enabled = state.canSave && !state.probing && !state.saving,
194 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
197 CircularProgressIndicator(
198 modifier = Modifier.size(20.dp),
201 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
203 Text(stringResource(R.string.check_connectivity))
206 // Positive feedback when the probe found the server reachable without auth, so
207 // the user knows the connection works before saving (closes the loop that the
208 // prior "silently does nothing" Reachable path left open).
209 if (state.probeReachable == true && !state.authFieldsVisible) {
211 stringResource(R.string.server_reachable),
212 style = MaterialTheme.typography.bodySmall,
213 color = MaterialTheme.colorScheme.primary,
214 modifier = Modifier.fillMaxWidth(),
217 if (!state.authFieldsVisible) {
219 stringResource(R.string.auth_help),
220 style = MaterialTheme.typography.bodySmall,
221 color = MaterialTheme.colorScheme.onSurfaceVariant,
222 modifier = Modifier.fillMaxWidth(),
225 AnimatedVisibility(visible = state.authFieldsVisible) {
228 passwordVisible = passwordVisible,
229 onTogglePassword = onTogglePassword,
230 onUpdate = vm::update,
231 onTestCredentials = vm::testCredentials,
232 onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
236 modifier = Modifier.fillMaxWidth(),
237 horizontalArrangement = Arrangement.spacedBy(12.dp),
241 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
244 enabled = state.canSave && !state.saving,
245 modifier = Modifier.weight(1f),
248 CircularProgressIndicator(
249 modifier = Modifier.size(20.dp),
250 color = MaterialTheme.colorScheme.primary,
253 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
255 Text(stringResource(R.string.save))
260 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
261 vm.saveAndConnect(onDone)
263 enabled = state.canSave && !state.saving,
264 modifier = Modifier.weight(1f),
266 Text(stringResource(R.string.save_and_connect))
269 state.error?.let { message ->
272 color = MaterialTheme.colorScheme.error,
273 style = MaterialTheme.typography.bodySmall,
274 modifier = Modifier.fillMaxWidth(),
281private fun AuthFields(
282 state: ServerEditState,
283 passwordVisible: Boolean,
284 onTogglePassword: () -> Unit,
285 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
286 onTestCredentials: () -> Unit,
287 onImeDone: () -> Unit,
289 val showPasswordLabel = stringResource(R.string.show_password)
290 val hidePasswordLabel = stringResource(R.string.hide_password)
291 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
293 stringResource(R.string.auth_required),
294 style = MaterialTheme.typography.bodySmall,
295 color = MaterialTheme.colorScheme.error,
298 value = state.username,
299 onValueChange = { v -> onUpdate { it.copy(username = v) } },
300 label = { Text(stringResource(R.string.username_optional)) },
302 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
303 modifier = Modifier.fillMaxWidth().testTag("server_username"),
306 value = state.password,
307 onValueChange = { v -> onUpdate { it.copy(password = v) } },
308 label = { Text(stringResource(R.string.password_optional)) },
310 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
312 IconButton(onClick = onTogglePassword) {
314 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
315 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
319 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
320 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
321 modifier = Modifier.fillMaxWidth().testTag("server_password"),
324 onClick = onTestCredentials,
325 // Require at least one credential field: testCredentials() no-ops when both are
326 // blank, so without this the button is tappable but silently does nothing.
327 enabled = state.canSave && !state.testingCredentials && !state.saving &&
328 (state.username.isNotBlank() || state.password.isNotBlank()),
329 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
331 if (state.testingCredentials) {
332 CircularProgressIndicator(
333 modifier = Modifier.size(20.dp),
336 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
338 Text(stringResource(R.string.test_credentials))
341 state.credentialsResult?.let { ok ->
343 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
344 style = MaterialTheme.typography.bodySmall,
345 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list