git.iko.soy/opencode-android
on e66c7ed help sign in
353 lines 16 kB history raw
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.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.layout.wrapContentWidth
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.material3.TopAppBarDefaults
37import androidx.compose.runtime.Composable
38import androidx.compose.runtime.LaunchedEffect
39import androidx.compose.runtime.getValue
40import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
42import androidx.compose.runtime.saveable.rememberSaveable
43import androidx.compose.runtime.setValue
44import androidx.compose.ui.Alignment
45import androidx.compose.ui.Modifier
46import androidx.compose.ui.focus.FocusRequester
47import androidx.compose.ui.focus.focusRequester
48import androidx.compose.ui.input.nestedscroll.nestedScroll
49import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
51import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.input.ImeAction
54import androidx.compose.ui.text.input.KeyboardType
55import androidx.compose.ui.text.input.PasswordVisualTransformation
56import androidx.compose.ui.text.input.VisualTransformation
57import androidx.compose.ui.res.stringResource
58import androidx.compose.ui.unit.dp
59import androidx.lifecycle.compose.collectAsStateWithLifecycle
60import androidx.lifecycle.viewmodel.compose.viewModel
61import soy.iko.opencode.di.AppContainer
62import soy.iko.opencode.R
63import soy.iko.opencode.ui.components.autofillModifier
64import soy.iko.opencode.ui.components.AutofillHint
65import soy.iko.opencode.ui.vmFactory
67@OptIn(ExperimentalMaterial3Api::class)
68@Composable
69fun ServerEditScreen(
70 container: AppContainer,
71 onDone: () -> Unit,
72) {
73 val vm: ServerEditViewModel =
74 viewModel(factory = vmFactory { ServerEditViewModel(container) })
75 val state by vm.state.collectAsStateWithLifecycle()
76 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
77 // Keyed on state.id so the password-reveal flag resets when a different profile is loaded
78 // into the same back-stack entry. Without the key, revealing server A's password and then
79 // editing server B (which reuses this entry via nav back-and-forth) would show B's password
80 // in cleartext without the user tapping the eye icon — a minor credential info-leak.
81 var passwordVisible by rememberSaveable(state.id) { mutableStateOf(false) }
83 fun safeExit() {
84 when {
85 // A save is in flight: ignore the back gesture (matching the BackHandler guard
86 // below). Popping here would cancel the save's viewModelScope coroutine and
87 // silently abandon the write; the VM calls onDone itself once the save lands.
88 state.saving -> Unit
89 state.isDirty -> showDiscardConfirm = true
90 else -> onDone()
91 }
92 }
94 BackHandler(enabled = (state.isDirty || state.saving) && !showDiscardConfirm) {
95 if (!state.saving) showDiscardConfirm = true
96 }
98 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
99 Scaffold(
100 topBar = {
101 TopAppBar(
102 title = { Text(stringResource(R.string.add_server)) },
103 navigationIcon = {
104 IconButton(onClick = ::safeExit) {
105 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
106 }
107 },
108 scrollBehavior = scrollBehavior,
109 )
110 },
111 ) { padding ->
112 if (!state.loaded) {
113 val loadingLabel = stringResource(R.string.loading)
114 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
115 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
116 }
117 } else {
118 ServerEditForm(
119 state = state,
120 passwordVisible = passwordVisible,
121 onTogglePassword = { passwordVisible = !passwordVisible },
122 padding = padding,
123 scrollBehavior = scrollBehavior,
124 vm = vm,
125 onDone = onDone,
126 )
127 }
128 }
130 if (showDiscardConfirm) {
131 AlertDialog(
132 onDismissRequest = { showDiscardConfirm = false },
133 title = { Text(stringResource(R.string.discard_title)) },
134 text = { Text(stringResource(R.string.discard_text)) },
135 confirmButton = {
136 TextButton(onClick = {
137 showDiscardConfirm = false
138 onDone()
139 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
140 },
141 dismissButton = {
142 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
143 },
144 )
145 }
148@OptIn(ExperimentalMaterial3Api::class)
149@Composable
150private fun ServerEditForm(
151 state: ServerEditState,
152 passwordVisible: Boolean,
153 onTogglePassword: () -> Unit,
154 padding: androidx.compose.foundation.layout.PaddingValues,
155 scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
156 vm: ServerEditViewModel,
157 onDone: () -> Unit,
158) {
159 val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
160 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
161 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
162 val baseUrlFocus = remember { FocusRequester() }
163 val keyboard = LocalSoftwareKeyboardController.current
164 // Focus the Base URL field (and raise the keyboard) on the fresh Add-server form so the
165 // user can start typing the required field immediately.
166 LaunchedEffect(Unit) {
167 if (state.baseUrl.isBlank()) {
168 baseUrlFocus.requestFocus()
169 keyboard?.show()
170 }
171 }
172 Column(
173 modifier = Modifier
174 .imePadding()
175 .padding(padding)
176 .fillMaxWidth()
177 .wrapContentWidth(Alignment.CenterHorizontally)
178 .widthIn(max = 600.dp)
179 .padding(16.dp)
180 .nestedScroll(scrollBehavior.nestedScrollConnection)
181 .verticalScroll(rememberScrollState()),
182 verticalArrangement = Arrangement.spacedBy(12.dp),
183 ) {
184 // Base URL is the only required field, so it leads the form and takes focus on a
185 // fresh add form (via baseUrlFocus above).
186 OutlinedTextField(
187 value = state.baseUrl,
188 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
189 label = { Text(stringResource(R.string.base_url)) },
190 placeholder = { Text(stringResource(R.string.base_url_hint)) },
191 singleLine = true,
192 isError = state.baseUrl.isNotBlank() && !urlValid,
193 supportingText = {
194 if (state.baseUrl.isNotBlank() && !urlValid) {
195 Text(stringResource(R.string.invalid_url))
196 }
197 },
198 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
199 keyboardActions = KeyboardActions(onDone = { keyboard?.hide() }),
200 modifier = Modifier
201 .fillMaxWidth()
202 .focusRequester(baseUrlFocus)
203 .testTag("server_url"),
204 )
205 // Auth fields appear only once a connect attempt learns the server requires them
206 // (or an authed profile is loaded) — the common no-auth server never sees them.
207 AnimatedVisibility(visible = state.authFieldsVisible) {
208 AuthFields(
209 state = state,
210 passwordVisible = passwordVisible,
211 onTogglePassword = onTogglePassword,
212 onUpdate = vm::update,
213 onTestCredentials = vm::testCredentials,
214 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
215 )
216 }
217 // Warn when credentials will travel over cleartext HTTP: an http:// URL with a
218 // username sends Basic auth unencrypted on every request. Surface this prominently
219 // so the user switches to https:// (or enables Require HTTPS) rather than silently
220 // leaking the password on a hostile LAN.
221 if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
222 Text(
223 stringResource(R.string.cleartext_credentials_warning),
224 color = MaterialTheme.colorScheme.error,
225 style = MaterialTheme.typography.bodySmall,
226 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
227 )
228 }
229 // Single primary action: probe-then-save-and-connect. The probe IS the connection
230 // test — a failed probe keeps the user on this screen with the error shown below.
231 Button(
232 onClick = {
233 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
234 vm.connect(onDone)
235 },
236 enabled = canSave && !state.saving,
237 modifier = Modifier.fillMaxWidth().testTag("server_connect"),
238 ) {
239 if (state.saving) {
240 CircularProgressIndicator(
241 modifier = Modifier.size(20.dp),
242 color = MaterialTheme.colorScheme.onPrimary,
243 strokeWidth = 2.dp,
244 )
245 Text(stringResource(R.string.connecting), modifier = Modifier.padding(start = 8.dp))
246 } else {
247 Text(stringResource(R.string.connect))
248 }
249 }
250 // Keep the error directly under the primary action so it's in view where the user is
251 // looking when a Connect/Save fails — not buried further down the form.
252 state.error?.let { message ->
253 Text(
254 message,
255 color = MaterialTheme.colorScheme.error,
256 style = MaterialTheme.typography.bodySmall,
257 modifier = Modifier.fillMaxWidth(),
258 )
259 }
260 }
263@Composable
264private fun AuthFields(
265 state: ServerEditState,
266 passwordVisible: Boolean,
267 onTogglePassword: () -> Unit,
268 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
269 onTestCredentials: () -> Unit,
270 onImeDone: () -> Unit,
271) {
272 val showPasswordLabel = stringResource(R.string.show_password)
273 val hidePasswordLabel = stringResource(R.string.hide_password)
274 // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
275 // every keystroke's recomposition (see ServerEditForm).
276 val canSave = remember(state.baseUrl, state.certPin) { state.canSave }
277 val usernameFocus = remember { FocusRequester() }
278 val passwordFocus = remember { FocusRequester() }
279 // AuthFields only enters composition once the server is known to require credentials
280 // (AnimatedVisibility in ServerEditForm), so a Unit-keyed effect fires once on reveal
281 // and focuses the username — otherwise the user has to tap into it after the probe flips
282 // the auth fields open.
283 LaunchedEffect(Unit) {
284 runCatching { usernameFocus.requestFocus() }
285 }
286 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
287 Text(
288 stringResource(R.string.auth_required),
289 style = MaterialTheme.typography.bodySmall,
290 color = MaterialTheme.colorScheme.error,
291 )
292 OutlinedTextField(
293 value = state.username,
294 onValueChange = { v -> onUpdate { it.copy(username = v) } },
295 label = { Text(stringResource(R.string.username_optional)) },
296 singleLine = true,
297 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
298 keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
299 modifier = Modifier
300 .fillMaxWidth()
301 .focusRequester(usernameFocus)
302 .then(autofillModifier(AutofillHint.Username) { v -> onUpdate { it.copy(username = v) } })
303 .testTag("server_username"),
304 )
305 OutlinedTextField(
306 value = state.password,
307 onValueChange = { v -> onUpdate { it.copy(password = v) } },
308 label = { Text(stringResource(R.string.password_optional)) },
309 singleLine = true,
310 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
311 trailingIcon = {
312 IconButton(onClick = onTogglePassword) {
313 Icon(
314 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
315 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
316 )
317 }
318 },
319 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
320 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
321 modifier = Modifier
322 .fillMaxWidth()
323 .focusRequester(passwordFocus)
324 .then(autofillModifier(AutofillHint.Password) { v -> onUpdate { it.copy(password = v) } })
325 .testTag("server_password"),
326 )
327 OutlinedButton(
328 onClick = onTestCredentials,
329 // Require at least one credential field: testCredentials() no-ops when both are
330 // blank, so without this the button is tappable but silently does nothing.
331 enabled = canSave && !state.testingCredentials && !state.saving &&
332 (state.username.isNotBlank() || state.password.isNotBlank()),
333 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
334 ) {
335 if (state.testingCredentials) {
336 CircularProgressIndicator(
337 modifier = Modifier.size(20.dp),
338 strokeWidth = 2.dp,
339 )
340 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
341 } else {
342 Text(stringResource(R.string.test_credentials))
343 }
344 }
345 state.credentialsResult?.let { ok ->
346 Text(
347 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
348 style = MaterialTheme.typography.bodySmall,
349 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
350 )
351 }
352 }