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)
31 onLoginSuccess: () -> Unit,
32 sessionExpired: Boolean = false,
33 viewModel: LoginViewModel = hiltViewModel(),
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) {
46 LaunchedEffect(sessionExpired) {
48 errorHandler.showError("Session expired. Please log in again.")
57 .verticalScroll(rememberScrollState())
59 horizontalAlignment = Alignment.CenterHorizontally,
60 verticalArrangement = Arrangement.Center,
64 style = MaterialTheme.typography.headlineLarge,
65 fontWeight = FontWeight.Bold,
66 color = MaterialTheme.colorScheme.primary,
69 Spacer(modifier = Modifier.height(8.dp))
73 style = MaterialTheme.typography.titleMedium,
74 color = MaterialTheme.colorScheme.onSurfaceVariant,
77 Spacer(modifier = Modifier.height(48.dp))
80 value = state.serverUrl,
81 onValueChange = viewModel::onServerUrlChange,
82 label = { Text("Server URL") },
83 placeholder = { Text("https://demo.parseable.com") },
85 modifier = Modifier.fillMaxWidth(),
86 keyboardOptions = KeyboardOptions(
87 keyboardType = KeyboardType.Uri,
88 imeAction = ImeAction.Next,
90 keyboardActions = KeyboardActions(
91 onNext = { focusManager.moveFocus(FocusDirection.Down) },
93 isError = state.serverUrlError != null,
94 supportingText = state.serverUrlError?.let { { Text(it) } },
97 Spacer(modifier = Modifier.height(12.dp))
100 value = state.username,
101 onValueChange = viewModel::onUsernameChange,
102 label = { Text("Username") },
104 modifier = Modifier.fillMaxWidth(),
105 keyboardOptions = KeyboardOptions(
106 imeAction = ImeAction.Next,
108 keyboardActions = KeyboardActions(
109 onNext = { focusManager.moveFocus(FocusDirection.Down) },
111 isError = state.usernameError != null,
112 supportingText = state.usernameError?.let { { Text(it) } },
115 Spacer(modifier = Modifier.height(12.dp))
118 value = state.password,
119 onValueChange = viewModel::onPasswordChange,
120 label = { Text("Password") },
122 modifier = Modifier.fillMaxWidth(),
123 visualTransformation = if (passwordVisible) {
124 VisualTransformation.None
126 PasswordVisualTransformation()
129 IconButton(onClick = { passwordVisible = !passwordVisible }) {
131 imageVector = if (passwordVisible) {
132 Icons.Filled.VisibilityOff
134 Icons.Filled.Visibility
136 contentDescription = if (passwordVisible) "Hide password" else "Show password",
140 keyboardOptions = KeyboardOptions(
141 keyboardType = KeyboardType.Password,
142 imeAction = ImeAction.Done,
144 keyboardActions = KeyboardActions(
146 focusManager.clearFocus()
150 isError = state.passwordError != null,
151 supportingText = state.passwordError?.let { { Text(it) } },
154 Spacer(modifier = Modifier.height(8.dp))
157 modifier = Modifier.fillMaxWidth(),
158 verticalAlignment = Alignment.CenterVertically,
161 checked = state.allowInsecure,
162 onCheckedChange = viewModel::onAllowInsecureChange,
165 text = "Allow insecure (HTTP) connections",
166 style = MaterialTheme.typography.bodyMedium,
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])\\..*"))
179 if (!isPrivateNetwork) {
181 colors = CardDefaults.cardColors(
182 containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
184 modifier = Modifier.fillMaxWidth(),
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,
197 Spacer(modifier = Modifier.height(24.dp))
200 onClick = viewModel::onLogin,
203 .defaultMinSize(minHeight = 50.dp),
204 enabled = !state.isLoading,
206 if (state.isLoading) {
207 CircularProgressIndicator(
208 modifier = Modifier.size(20.dp),
210 color = MaterialTheme.colorScheme.onPrimary,
212 Spacer(modifier = Modifier.width(8.dp))
217 if (state.hasSavedCredentials && state.password.isEmpty()) {
218 Spacer(modifier = Modifier.height(8.dp))
220 onClick = viewModel::loginWithSavedCredentials,
223 .defaultMinSize(minHeight = 50.dp),
224 enabled = !state.isLoading,
226 Text("Use saved credentials")
230 state.error?.let { errorText ->
231 Spacer(modifier = Modifier.height(16.dp))
233 colors = CardDefaults.cardColors(
234 containerColor = MaterialTheme.colorScheme.errorContainer,
236 modifier = Modifier.fillMaxWidth(),
240 modifier = Modifier.padding(16.dp),
241 color = MaterialTheme.colorScheme.onErrorContainer,
242 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