git.iko.soy/opencode-android
on d29e57b help sign in
312 lines 13 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.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)
60@Composable
61fun ServerEditScreen(
62 container: AppContainer,
63 profileId: String?,
64 onDone: () -> Unit,
65) {
66 val vm: ServerEditViewModel =
67 viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
68 val state by vm.state.collectAsStateWithLifecycle()
69 var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
70 var passwordVisible by rememberSaveable { mutableStateOf(false) }
72 fun safeExit() {
73 if (state.isDirty && !state.saving) {
74 showDiscardConfirm = true
75 } else {
76 onDone()
77 }
78 }
80 // Intercept the system back gesture when the form has unsaved changes so the user
81 // gets a chance to keep editing instead of losing their work silently.
82 BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
83 showDiscardConfirm = true
84 }
86 Scaffold(
87 topBar = {
88 TopAppBar(
89 title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
90 navigationIcon = {
91 IconButton(onClick = ::safeExit) {
92 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
93 }
94 },
95 )
96 },
97 ) { padding ->
98 if (!state.loaded) {
99 val loadingLabel = stringResource(R.string.loading)
100 Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
101 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
102 }
103 } else {
104 ServerEditForm(
105 state = state,
106 passwordVisible = passwordVisible,
107 onTogglePassword = { passwordVisible = !passwordVisible },
108 padding = padding,
109 vm = vm,
110 onDone = onDone,
111 )
112 }
113 }
115 if (showDiscardConfirm) {
116 AlertDialog(
117 onDismissRequest = { showDiscardConfirm = false },
118 title = { Text(stringResource(R.string.discard_title)) },
119 text = { Text(stringResource(R.string.discard_text)) },
120 confirmButton = {
121 TextButton(onClick = {
122 showDiscardConfirm = false
123 onDone()
124 }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
125 },
126 dismissButton = {
127 TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
128 },
129 )
130 }
133@Composable
134private fun ServerEditForm(
135 state: ServerEditState,
136 passwordVisible: Boolean,
137 onTogglePassword: () -> Unit,
138 padding: androidx.compose.foundation.layout.PaddingValues,
139 vm: ServerEditViewModel,
140 onDone: () -> Unit,
141) {
142 Column(
143 modifier = Modifier
144 .padding(padding)
145 .imePadding()
146 .widthIn(max = 600.dp)
147 .padding(16.dp)
148 .verticalScroll(rememberScrollState()),
149 verticalArrangement = Arrangement.spacedBy(12.dp),
150 ) {
151 OutlinedTextField(
152 value = state.label,
153 onValueChange = { v -> vm.update { it.copy(label = v) } },
154 label = { Text(stringResource(R.string.label_optional)) },
155 singleLine = true,
156 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
157 modifier = Modifier.fillMaxWidth().testTag("server_label"),
158 )
159 OutlinedTextField(
160 value = state.baseUrl,
161 onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null) } },
162 label = { Text(stringResource(R.string.base_url)) },
163 placeholder = { Text(stringResource(R.string.base_url_hint)) },
164 singleLine = true,
165 isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
166 supportingText = {
167 if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
168 Text(stringResource(R.string.invalid_url))
169 }
170 },
171 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
172 modifier = Modifier.fillMaxWidth().testTag("server_url"),
173 )
174 OutlinedButton(
175 onClick = { vm.probe() },
176 enabled = state.canSave && !state.probing && !state.saving,
177 modifier = Modifier.fillMaxWidth().testTag("server_probe"),
178 ) {
179 if (state.probing) {
180 CircularProgressIndicator(
181 modifier = Modifier.size(20.dp),
182 strokeWidth = 2.dp,
183 )
184 Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
185 } else {
186 Text(stringResource(R.string.check_connectivity))
187 }
188 }
189 if (!state.authFieldsVisible) {
190 Text(
191 stringResource(R.string.auth_help),
192 style = MaterialTheme.typography.bodySmall,
193 color = MaterialTheme.colorScheme.onSurfaceVariant,
194 modifier = Modifier.fillMaxWidth(),
195 )
196 }
197 AnimatedVisibility(visible = state.authFieldsVisible) {
198 AuthFields(
199 state = state,
200 passwordVisible = passwordVisible,
201 onTogglePassword = onTogglePassword,
202 onUpdate = vm::update,
203 onTestCredentials = vm::testCredentials,
204 onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
205 )
206 }
207 Row(
208 modifier = Modifier.fillMaxWidth(),
209 horizontalArrangement = Arrangement.spacedBy(12.dp),
210 ) {
211 Button(
212 onClick = { vm.save(onDone) },
213 enabled = state.canSave && !state.saving,
214 modifier = Modifier.weight(1f),
215 ) {
216 if (state.saving) {
217 CircularProgressIndicator(
218 modifier = Modifier.size(20.dp),
219 color = MaterialTheme.colorScheme.onPrimary,
220 strokeWidth = 2.dp,
221 )
222 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
223 } else {
224 Text(stringResource(R.string.save))
225 }
226 }
227 Button(
228 onClick = { vm.saveAndConnect(onDone) },
229 enabled = state.canSave && !state.saving,
230 modifier = Modifier.weight(1f),
231 ) {
232 Text(stringResource(R.string.save_and_connect))
233 }
234 }
235 state.error?.let { message ->
236 Text(
237 message,
238 color = MaterialTheme.colorScheme.error,
239 style = MaterialTheme.typography.bodySmall,
240 modifier = Modifier.fillMaxWidth(),
241 )
242 }
243 }
246@Composable
247private fun AuthFields(
248 state: ServerEditState,
249 passwordVisible: Boolean,
250 onTogglePassword: () -> Unit,
251 onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
252 onTestCredentials: () -> Unit,
253 onImeDone: () -> Unit,
254) {
255 val showPasswordLabel = stringResource(R.string.show_password)
256 val hidePasswordLabel = stringResource(R.string.hide_password)
257 Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
258 Text(
259 stringResource(R.string.auth_required),
260 style = MaterialTheme.typography.bodySmall,
261 color = MaterialTheme.colorScheme.error,
262 )
263 OutlinedTextField(
264 value = state.username,
265 onValueChange = { v -> onUpdate { it.copy(username = v) } },
266 label = { Text(stringResource(R.string.username_optional)) },
267 singleLine = true,
268 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
269 modifier = Modifier.fillMaxWidth().testTag("server_username"),
270 )
271 OutlinedTextField(
272 value = state.password,
273 onValueChange = { v -> onUpdate { it.copy(password = v) } },
274 label = { Text(stringResource(R.string.password_optional)) },
275 singleLine = true,
276 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
277 trailingIcon = {
278 IconButton(onClick = onTogglePassword) {
279 Icon(
280 if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
281 contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
282 )
283 }
284 },
285 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
286 keyboardActions = KeyboardActions(onDone = { onImeDone() }),
287 modifier = Modifier.fillMaxWidth().testTag("server_password"),
288 )
289 OutlinedButton(
290 onClick = onTestCredentials,
291 enabled = state.canSave && !state.testingCredentials && !state.saving,
292 modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
293 ) {
294 if (state.testingCredentials) {
295 CircularProgressIndicator(
296 modifier = Modifier.size(20.dp),
297 strokeWidth = 2.dp,
298 )
299 Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
300 } else {
301 Text(stringResource(R.string.test_credentials))
302 }
303 }
304 state.credentialsResult?.let { ok ->
305 Text(
306 stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
307 style = MaterialTheme.typography.bodySmall,
308 color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
309 )
310 }
311 }