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