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)
34 onLoginSuccess: () -> Unit,
35 sessionExpired: Boolean = false,
36 viewModel: LoginViewModel = hiltViewModel(),
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) {
49 LaunchedEffect(sessionExpired) {
51 errorHandler.showError("Session expired. Please log in again.")
60 .verticalScroll(rememberScrollState())
62 horizontalAlignment = Alignment.CenterHorizontally,
63 verticalArrangement = Arrangement.Center,
66 painter = painterResource(id = R.mipmap.ic_launcher),
67 contentDescription = "Parseable logo",
68 modifier = Modifier.size(120.dp),
73 style = MaterialTheme.typography.headlineLarge,
74 fontWeight = FontWeight.Bold,
75 color = MaterialTheme.colorScheme.primary,
78 Spacer(modifier = Modifier.height(8.dp))
82 style = MaterialTheme.typography.titleMedium,
83 color = MaterialTheme.colorScheme.onSurfaceVariant,
86 Spacer(modifier = Modifier.height(48.dp))
89 value = state.serverUrl,
90 onValueChange = viewModel::onServerUrlChange,
91 label = { Text("Server URL") },
92 placeholder = { Text("https://demo.parseable.com") },
94 modifier = Modifier.fillMaxWidth(),
95 keyboardOptions = KeyboardOptions(
96 keyboardType = KeyboardType.Uri,
97 imeAction = ImeAction.Next,
99 keyboardActions = KeyboardActions(
100 onNext = { focusManager.moveFocus(FocusDirection.Down) },
102 isError = state.serverUrlError != null,
103 supportingText = state.serverUrlError?.let { { Text(it) } },
106 Spacer(modifier = Modifier.height(12.dp))
109 value = state.username,
110 onValueChange = viewModel::onUsernameChange,
111 label = { Text("Username") },
113 modifier = Modifier.fillMaxWidth(),
114 keyboardOptions = KeyboardOptions(
115 imeAction = ImeAction.Next,
117 keyboardActions = KeyboardActions(
118 onNext = { focusManager.moveFocus(FocusDirection.Down) },
120 isError = state.usernameError != null,
121 supportingText = state.usernameError?.let { { Text(it) } },
124 Spacer(modifier = Modifier.height(12.dp))
127 value = state.password,
128 onValueChange = viewModel::onPasswordChange,
129 label = { Text("Password") },
131 modifier = Modifier.fillMaxWidth(),
132 visualTransformation = if (passwordVisible) {
133 VisualTransformation.None
135 PasswordVisualTransformation()
138 IconButton(onClick = { passwordVisible = !passwordVisible }) {
140 imageVector = if (passwordVisible) {
141 Icons.Filled.VisibilityOff
143 Icons.Filled.Visibility
145 contentDescription = if (passwordVisible) "Hide password" else "Show password",
149 keyboardOptions = KeyboardOptions(
150 keyboardType = KeyboardType.Password,
151 imeAction = ImeAction.Done,
153 keyboardActions = KeyboardActions(
155 focusManager.clearFocus()
159 isError = state.passwordError != null,
160 supportingText = state.passwordError?.let { { Text(it) } },
163 Spacer(modifier = Modifier.height(8.dp))
166 modifier = Modifier.fillMaxWidth(),
167 verticalAlignment = Alignment.CenterVertically,
170 checked = state.allowInsecure,
171 onCheckedChange = viewModel::onAllowInsecureChange,
174 text = "Allow insecure (HTTP) connections",
175 style = MaterialTheme.typography.bodyMedium,
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])\\..*"))
188 if (!isPrivateNetwork) {
190 colors = CardDefaults.cardColors(
191 containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
193 modifier = Modifier.fillMaxWidth(),
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,
206 Spacer(modifier = Modifier.height(24.dp))
209 onClick = viewModel::onLogin,
212 .defaultMinSize(minHeight = 50.dp),
213 enabled = !state.isLoading,
215 if (state.isLoading) {
216 CircularProgressIndicator(
217 modifier = Modifier.size(20.dp),
219 color = MaterialTheme.colorScheme.onPrimary,
221 Spacer(modifier = Modifier.width(8.dp))
226 if (state.hasSavedCredentials && state.password.isEmpty()) {
227 Spacer(modifier = Modifier.height(8.dp))
229 onClick = viewModel::loginWithSavedCredentials,
232 .defaultMinSize(minHeight = 50.dp),
233 enabled = !state.isLoading,
235 Text("Use saved credentials")
239 state.error?.let { errorText ->
240 Spacer(modifier = Modifier.height(16.dp))
242 colors = CardDefaults.cardColors(
243 containerColor = MaterialTheme.colorScheme.errorContainer,
245 modifier = Modifier.fillMaxWidth(),
249 modifier = Modifier.padding(16.dp),
250 color = MaterialTheme.colorScheme.onErrorContainer,
251 style = MaterialTheme.typography.bodyMedium,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list