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) }
74 if (state.isDirty && !state.saving) {
75 showDiscardConfirm = true
81 // Intercept the system back gesture when the form has unsaved changes so the user
82 // gets a chance to keep editing instead of losing their work silently.
83 BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
84 showDiscardConfirm = true
90 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
92 IconButton(onClick = ::safeExit) {
93 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
100 val loadingLabel = stringResource(R.string.loading)
101 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
102 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
107 passwordVisible = passwordVisible,
108 onTogglePassword = { passwordVisible = !passwordVisible },
116 if (showDiscardConfirm) {
118 onDismissRequest = { showDiscardConfirm = false },
119 title = { Text(stringResource(R.string.discard_title)) },
120 text = { Text(stringResource(R.string.discard_text)) },
122 TextButton(onClick = {
123 showDiscardConfirm = false
125 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
128 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
135private fun ServerEditForm(
136 state: ServerEditState,
137 passwordVisible: Boolean,
138 onTogglePassword: () -> Unit,
139 padding: androidx.compose.foundation.layout.PaddingValues,
140 vm: ServerEditViewModel,
143 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
148 .widthIn(max = 600.dp)
150 .verticalScroll(rememberScrollState()),
151 verticalArrangement = Arrangement.spacedBy(12.dp),
155 onValueChange = { v -> vm.update { it.copy(label = v) } },
156 label = { Text(stringResource(R.string.label_optional)) },
158 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
159 modifier = Modifier.fillMaxWidth().testTag("server_label"),
162 value = state.baseUrl,
163 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
164 label = { Text(stringResource(R.string.base_url)) },
165 placeholder = { Text(stringResource(R.string.base_url_hint)) },
167 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
169 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
170 // Offer a one-tap "http://" fix for bare host:port input instead of a
171 // generic error, so the user doesn't have to know the URL needs a scheme.
172 val suggestion = suggestUrlScheme(state.baseUrl)
173 if (suggestion != null) {
175 onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
176 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
178 Text(stringResource(R.string.suggest_scheme))
181 Text(stringResource(R.string.invalid_url))
185 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
186 modifier = Modifier.fillMaxWidth().testTag("server_url"),
189 onClick = { vm.probe() },
190 enabled = state.canSave && !state.probing && !state.saving,
191 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
194 CircularProgressIndicator(
195 modifier = Modifier.size(20.dp),
198 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
200 Text(stringResource(R.string.check_connectivity))
203 // Positive feedback when the probe found the server reachable without auth, so
204 // the user knows the connection works before saving (closes the loop that the
205 // prior "silently does nothing" Reachable path left open).
206 if (state.probeReachable == true && !state.authFieldsVisible) {
208 stringResource(R.string.server_reachable),
209 style = MaterialTheme.typography.bodySmall,
210 color = MaterialTheme.colorScheme.primary,
211 modifier = Modifier.fillMaxWidth(),
214 if (!state.authFieldsVisible) {
216 stringResource(R.string.auth_help),
217 style = MaterialTheme.typography.bodySmall,
218 color = MaterialTheme.colorScheme.onSurfaceVariant,
219 modifier = Modifier.fillMaxWidth(),
222 AnimatedVisibility(visible = state.authFieldsVisible) {
225 passwordVisible = passwordVisible,
226 onTogglePassword = onTogglePassword,
227 onUpdate = vm::update,
228 onTestCredentials = vm::testCredentials,
229 onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
233 modifier = Modifier.fillMaxWidth(),
234 horizontalArrangement = Arrangement.spacedBy(12.dp),
238 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
241 enabled = state.canSave && !state.saving,
242 modifier = Modifier.weight(1f),
245 CircularProgressIndicator(
246 modifier = Modifier.size(20.dp),
247 color = MaterialTheme.colorScheme.primary,
250 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
252 Text(stringResource(R.string.save))
257 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
258 vm.saveAndConnect(onDone)
260 enabled = state.canSave && !state.saving,
261 modifier = Modifier.weight(1f),
263 Text(stringResource(R.string.save_and_connect))
266 state.error?.let { message ->
269 color = MaterialTheme.colorScheme.error,
270 style = MaterialTheme.typography.bodySmall,
271 modifier = Modifier.fillMaxWidth(),
278private fun AuthFields(
279 state: ServerEditState,
280 passwordVisible: Boolean,
281 onTogglePassword: () -> Unit,
282 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
283 onTestCredentials: () -> Unit,
284 onImeDone: () -> Unit,
286 val showPasswordLabel = stringResource(R.string.show_password)
287 val hidePasswordLabel = stringResource(R.string.hide_password)
288 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
290 stringResource(R.string.auth_required),
291 style = MaterialTheme.typography.bodySmall,
292 color = MaterialTheme.colorScheme.error,
295 value = state.username,
296 onValueChange = { v -> onUpdate { it.copy(username = v) } },
297 label = { Text(stringResource(R.string.username_optional)) },
299 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
300 modifier = Modifier.fillMaxWidth().testTag("server_username"),
303 value = state.password,
304 onValueChange = { v -> onUpdate { it.copy(password = v) } },
305 label = { Text(stringResource(R.string.password_optional)) },
307 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
309 IconButton(onClick = onTogglePassword) {
311 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
312 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
316 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
317 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
318 modifier = Modifier.fillMaxWidth().testTag("server_password"),
321 onClick = onTestCredentials,
322 enabled = state.canSave && !state.testingCredentials && !state.saving,
323 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
325 if (state.testingCredentials) {
326 CircularProgressIndicator(
327 modifier = Modifier.size(20.dp),
330 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
332 Text(stringResource(R.string.test_credentials))
335 state.credentialsResult?.let { ok ->
337 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
338 style = MaterialTheme.typography.bodySmall,
339 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