git.iko.soy/parseable-android
on 3b7ea33 help sign in
257 lines 10 kB history raw
1package com.parseable.android.ui.screens.login
3import androidx.compose.foundation.Image
4import androidx.compose.foundation.layout.*
5import androidx.compose.foundation.rememberScrollState
6import androidx.compose.foundation.text.KeyboardActions
7import androidx.compose.foundation.text.KeyboardOptions
8import androidx.compose.foundation.verticalScroll
9import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.filled.Visibility
11import androidx.compose.material.icons.filled.VisibilityOff
12import androidx.compose.material3.*
13import androidx.compose.runtime.*
14import androidx.compose.runtime.saveable.rememberSaveable
15import androidx.compose.ui.Alignment
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.focus.FocusDirection
18import androidx.compose.ui.platform.LocalFocusManager
19import androidx.compose.ui.res.painterResource
20import androidx.compose.ui.text.font.FontWeight
21import com.parseable.android.R
22import com.parseable.android.ui.LocalErrorHandler
23import androidx.compose.ui.text.input.ImeAction
24import androidx.compose.ui.text.input.KeyboardType
25import androidx.compose.ui.text.input.PasswordVisualTransformation
26import androidx.compose.ui.text.input.VisualTransformation
27import androidx.compose.ui.unit.dp
28import androidx.hilt.navigation.compose.hiltViewModel
29import androidx.lifecycle.compose.collectAsStateWithLifecycle
31@OptIn(ExperimentalMaterial3Api::class)
32@Composable
33fun LoginScreen(
34 onLoginSuccess: () -> Unit,
35 sessionExpired: Boolean = false,
36 viewModel: LoginViewModel = hiltViewModel(),
37) {
38 val state by viewModel.state.collectAsStateWithLifecycle()
39 val focusManager = LocalFocusManager.current
40 var passwordVisible by rememberSaveable { mutableStateOf(false) }
41 val errorHandler = LocalErrorHandler.current
43 LaunchedEffect(state.loginSuccess) {
44 if (state.loginSuccess) {
45 onLoginSuccess()
46 }
47 }
49 LaunchedEffect(sessionExpired) {
50 if (sessionExpired) {
51 errorHandler.showError("Session expired. Please log in again.")
52 }
53 }
55 Scaffold { padding ->
56 Column(
57 modifier = Modifier
58 .fillMaxSize()
59 .padding(padding)
60 .verticalScroll(rememberScrollState())
61 .padding(16.dp),
62 horizontalAlignment = Alignment.CenterHorizontally,
63 verticalArrangement = Arrangement.Center,
64 ) {
65 Image(
66 painter = painterResource(id = R.mipmap.ic_launcher),
67 contentDescription = "Parseable logo",
68 modifier = Modifier.size(120.dp),
69 )
71 Text(
72 text = "Parseable",
73 style = MaterialTheme.typography.headlineLarge,
74 fontWeight = FontWeight.Bold,
75 color = MaterialTheme.colorScheme.primary,
76 )
78 Spacer(modifier = Modifier.height(8.dp))
80 Text(
81 text = "Log Viewer",
82 style = MaterialTheme.typography.titleMedium,
83 color = MaterialTheme.colorScheme.onSurfaceVariant,
84 )
86 Spacer(modifier = Modifier.height(48.dp))
88 OutlinedTextField(
89 value = state.serverUrl,
90 onValueChange = viewModel::onServerUrlChange,
91 label = { Text("Server URL") },
92 placeholder = { Text("https://demo.parseable.com") },
93 singleLine = true,
94 modifier = Modifier.fillMaxWidth(),
95 keyboardOptions = KeyboardOptions(
96 keyboardType = KeyboardType.Uri,
97 imeAction = ImeAction.Next,
98 ),
99 keyboardActions = KeyboardActions(
100 onNext = { focusManager.moveFocus(FocusDirection.Down) },
101 ),
102 isError = state.serverUrlError != null,
103 supportingText = state.serverUrlError?.let { { Text(it) } },
104 )
106 Spacer(modifier = Modifier.height(12.dp))
108 OutlinedTextField(
109 value = state.username,
110 onValueChange = viewModel::onUsernameChange,
111 label = { Text("Username") },
112 singleLine = true,
113 modifier = Modifier.fillMaxWidth(),
114 keyboardOptions = KeyboardOptions(
115 imeAction = ImeAction.Next,
116 ),
117 keyboardActions = KeyboardActions(
118 onNext = { focusManager.moveFocus(FocusDirection.Down) },
119 ),
120 isError = state.usernameError != null,
121 supportingText = state.usernameError?.let { { Text(it) } },
122 )
124 Spacer(modifier = Modifier.height(12.dp))
126 OutlinedTextField(
127 value = state.password,
128 onValueChange = viewModel::onPasswordChange,
129 label = { Text("Password") },
130 singleLine = true,
131 modifier = Modifier.fillMaxWidth(),
132 visualTransformation = if (passwordVisible) {
133 VisualTransformation.None
134 } else {
135 PasswordVisualTransformation()
136 },
137 trailingIcon = {
138 IconButton(onClick = { passwordVisible = !passwordVisible }) {
139 Icon(
140 imageVector = if (passwordVisible) {
141 Icons.Filled.VisibilityOff
142 } else {
143 Icons.Filled.Visibility
144 },
145 contentDescription = if (passwordVisible) "Hide password" else "Show password",
146 )
147 }
148 },
149 keyboardOptions = KeyboardOptions(
150 keyboardType = KeyboardType.Password,
151 imeAction = ImeAction.Done,
152 ),
153 keyboardActions = KeyboardActions(
154 onDone = {
155 focusManager.clearFocus()
156 viewModel.onLogin()
157 },
158 ),
159 isError = state.passwordError != null,
160 supportingText = state.passwordError?.let { { Text(it) } },
161 )
163 Spacer(modifier = Modifier.height(8.dp))
165 Row(
166 modifier = Modifier.fillMaxWidth(),
167 verticalAlignment = Alignment.CenterVertically,
168 ) {
169 Checkbox(
170 checked = state.allowInsecure,
171 onCheckedChange = viewModel::onAllowInsecureChange,
172 )
173 Text(
174 text = "Allow insecure (HTTP) connections",
175 style = MaterialTheme.typography.bodyMedium,
176 )
177 }
179 if (state.allowInsecure) {
180 val isPrivateNetwork = remember(state.serverUrl) {
181 val host = state.serverUrl
182 .removePrefix("http://").removePrefix("https://")
183 .substringBefore("/").substringBefore(":")
184 host.isBlank() || host == "localhost" || host.startsWith("127.") ||
185 host.startsWith("10.") || host.startsWith("192.168.") ||
186 host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\..*"))
187 }
188 if (!isPrivateNetwork) {
189 Card(
190 colors = CardDefaults.cardColors(
191 containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
192 ),
193 modifier = Modifier.fillMaxWidth(),
194 ) {
195 Text(
196 text = "Warning: HTTP sends credentials and data in plaintext. " +
197 "Only use this for trusted private networks.",
198 modifier = Modifier.padding(12.dp),
199 color = MaterialTheme.colorScheme.onErrorContainer,
200 style = MaterialTheme.typography.bodySmall,
201 )
202 }
203 }
204 }
206 Spacer(modifier = Modifier.height(24.dp))
208 Button(
209 onClick = viewModel::onLogin,
210 modifier = Modifier
211 .fillMaxWidth()
212 .defaultMinSize(minHeight = 50.dp),
213 enabled = !state.isLoading,
214 ) {
215 if (state.isLoading) {
216 CircularProgressIndicator(
217 modifier = Modifier.size(20.dp),
218 strokeWidth = 2.dp,
219 color = MaterialTheme.colorScheme.onPrimary,
220 )
221 Spacer(modifier = Modifier.width(8.dp))
222 }
223 Text("Connect")
224 }
226 if (state.hasSavedCredentials && state.password.isEmpty()) {
227 Spacer(modifier = Modifier.height(8.dp))
228 OutlinedButton(
229 onClick = viewModel::loginWithSavedCredentials,
230 modifier = Modifier
231 .fillMaxWidth()
232 .defaultMinSize(minHeight = 50.dp),
233 enabled = !state.isLoading,
234 ) {
235 Text("Use saved credentials")
236 }
237 }
239 state.error?.let { errorText ->
240 Spacer(modifier = Modifier.height(16.dp))
241 Card(
242 colors = CardDefaults.cardColors(
243 containerColor = MaterialTheme.colorScheme.errorContainer,
244 ),
245 modifier = Modifier.fillMaxWidth(),
246 ) {
247 Text(
248 text = errorText,
249 modifier = Modifier.padding(16.dp),
250 color = MaterialTheme.colorScheme.onErrorContainer,
251 style = MaterialTheme.typography.bodyMedium,
252 )
253 }
254 }
255 }
256 }