git.iko.soy/opencode-android
on 29df003 help sign in
199 lines 8.6 kB history raw
1package soy.iko.opencode.ui.screens
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.height
9import androidx.compose.foundation.layout.imePadding
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.rememberScrollState
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.verticalScroll
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Lock
18import androidx.compose.material.icons.filled.Person
19import androidx.compose.material.icons.filled.Terminal
20import androidx.compose.material.icons.filled.Visibility
21import androidx.compose.material.icons.filled.VisibilityOff
22import androidx.compose.material3.Button
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.Icon
25import androidx.compose.material3.IconButton
26import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.OutlinedTextField
28import androidx.compose.material3.Text
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.LaunchedEffect
31import androidx.compose.runtime.getValue
32import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.remember
34import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusRequester
39import androidx.compose.ui.text.input.ImeAction
40import androidx.compose.ui.text.input.KeyboardType
41import androidx.compose.ui.text.input.PasswordVisualTransformation
42import androidx.compose.ui.text.input.VisualTransformation
43import androidx.compose.ui.text.style.TextAlign
44import androidx.compose.ui.unit.dp
45import soy.iko.opencode.core.Event
46import soy.iko.opencode.core.UiState
48@Composable
49fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50 val focusRequester = remember { FocusRequester() }
51 var passwordVisible by remember { mutableStateOf(false) }
53 // Focus the server URL field when the screen opens, but only when it's empty
54 // so we don't steal focus (and pop the keyboard) on a return visit.
55 LaunchedEffect(Unit) {
56 if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
57 }
59 Column(
60 modifier = Modifier
61 .fillMaxSize()
62 .verticalScroll(rememberScrollState())
63 .imePadding()
64 .padding(horizontal = 28.dp),
65 verticalArrangement = Arrangement.Center,
66 horizontalAlignment = Alignment.CenterHorizontally,
67 ) {
68 Icon(
69 Icons.Filled.Terminal,
70 contentDescription = null,
71 tint = MaterialTheme.colorScheme.primary,
72 modifier = Modifier.size(72.dp),
73 )
74 Spacer(Modifier.height(16.dp))
75 Text(
76 text = "opencode",
77 style = MaterialTheme.typography.displaySmall,
78 color = MaterialTheme.colorScheme.primary,
79 )
80 Text(
81 text = "Connect to your opencode server",
82 style = MaterialTheme.typography.bodyMedium,
83 color = MaterialTheme.colorScheme.onSurfaceVariant,
84 textAlign = TextAlign.Center,
85 )
87 Spacer(Modifier.height(32.dp))
89 OutlinedTextField(
90 value = state.serverUrl,
91 onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
92 label = { Text("Server URL") },
93 placeholder = { Text("http://192.168.1.10:4096") },
94 singleLine = true,
95 modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
96 keyboardOptions = KeyboardOptions(
97 keyboardType = KeyboardType.Uri,
98 imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
99 ),
100 keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
101 enabled = !state.loading,
102 )
104 // Point first-time users at the expected address format. Hidden once they
105 // start typing so it doesn't compete with the field's own contents.
106 if (state.serverUrl.isEmpty()) {
107 Spacer(Modifier.height(6.dp))
108 Text(
109 text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110 style = MaterialTheme.typography.bodySmall,
111 color = MaterialTheme.colorScheme.onSurfaceVariant,
112 textAlign = TextAlign.Center,
113 )
114 }
116 // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117 // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118 if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119 Spacer(Modifier.height(6.dp))
120 Text(
121 text = "Tip: Add http:// if connecting to a local server",
122 style = MaterialTheme.typography.bodySmall,
123 color = MaterialTheme.colorScheme.tertiary,
124 textAlign = TextAlign.Center,
125 )
126 }
128 if (state.authRequired) {
129 Spacer(Modifier.height(12.dp))
130 OutlinedTextField(
131 value = state.username,
132 onValueChange = { dispatch(Event.UsernameChanged(it)) },
133 label = { Text("Username") },
134 singleLine = true,
135 leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136 modifier = Modifier.fillMaxWidth(),
137 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138 enabled = !state.loading,
139 )
140 Spacer(Modifier.height(12.dp))
141 OutlinedTextField(
142 value = state.password,
143 onValueChange = { dispatch(Event.PasswordChanged(it)) },
144 label = { Text("Password") },
145 singleLine = true,
146 visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
147 leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
148 trailingIcon = {
149 IconButton(onClick = { passwordVisible = !passwordVisible }) {
150 Icon(
151 imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
152 contentDescription = if (passwordVisible) "Hide password" else "Show password",
153 )
154 }
155 },
156 modifier = Modifier.fillMaxWidth(),
157 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158 keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
159 enabled = !state.loading,
160 )
161 Spacer(Modifier.height(4.dp))
162 Text(
163 text = "This server requires authentication.",
164 style = MaterialTheme.typography.bodySmall,
165 color = MaterialTheme.colorScheme.onSurfaceVariant,
166 )
167 }
169 Spacer(Modifier.height(24.dp))
171 Button(
172 onClick = { dispatch(Event.Connect) },
173 enabled = !state.loading && state.serverUrl.isNotBlank(),
174 modifier = Modifier.fillMaxWidth(),
175 ) {
176 if (state.loading) {
177 CircularProgressIndicator(
178 modifier = Modifier.height(20.dp),
179 strokeWidth = 2.dp,
180 color = MaterialTheme.colorScheme.onPrimary,
181 )
182 } else {
183 Text(if (state.authRequired) "Sign in" else "Connect")
184 }
185 }
187 // Surface the error inline as well, so it stays visible while the user
188 // corrects the form (the snackbar is transient).
189 if (state.error != null && !state.loading) {
190 Spacer(Modifier.height(8.dp))
191 Text(
192 text = state.error,
193 style = MaterialTheme.typography.bodySmall,
194 color = MaterialTheme.colorScheme.error,
195 textAlign = TextAlign.Center,
196 )
197 }
198 }