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