app/src/main/java/com/geekorum/ttrss/accounts/LoginScreen.kt
changeset 1026 3e7798e59599
child 1110 2aad46759ea5
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/app/src/main/java/com/geekorum/ttrss/accounts/LoginScreen.kt	Sat Apr 29 14:07:14 2023 -0400
@@ -0,0 +1,375 @@
+/*
+ * Geekttrss is a RSS feed reader application on the Android Platform.
+ *
+ * Copyright (C) 2017-2023 by Frederic-Charles Barthelery.
+ *
+ * This file is part of Geekttrss.
+ *
+ * Geekttrss is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Geekttrss is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Geekttrss.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.geekorum.ttrss.accounts
+
+import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Password
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Web
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.geekorum.ttrss.R
+import com.geekorum.ttrss.ui.AppTheme
+
+@Composable
+internal fun LoginScreen(windowSizeClass: WindowSizeClass, viewModel: LoginViewModel = hiltViewModel()) {
+    val scaffoldState = rememberScaffoldState()
+    LoginScreen(
+        windowSizeClass = windowSizeClass,
+        loginInProgress = viewModel.loginInProgress,
+        loginFormUiState = viewModel.loginFormUiState,
+        scaffoldState = scaffoldState,
+        onLoginClick = {
+            viewModel.confirmLogin()
+        })
+
+    viewModel.snackbarErrorMessageId?.let { messageId ->
+        val message = stringResource(messageId)
+        LaunchedEffect(message) {
+            scaffoldState.snackbarHostState.showSnackbar(message)
+            viewModel.clearSnackbarMessage()
+        }
+    }
+}
+
+@Composable
+fun LoginScreen(
+    windowSizeClass: WindowSizeClass,
+    loginInProgress: Boolean,
+    loginFormUiState: LoginFormUiState,
+    scaffoldState: ScaffoldState = rememberScaffoldState(),
+    onLoginClick: () -> Unit
+) {
+    val useTabletLayout = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium
+    Scaffold(
+        scaffoldState = scaffoldState,
+        topBar = {
+            TopAppBar(
+                elevation = if (useTabletLayout) 0.dp else AppBarDefaults.TopAppBarElevation,
+                title = {
+                    Text(stringResource(R.string.app_name))
+                })
+        },
+    ) {
+        if (useTabletLayout) {
+            TabletLayoutContent(Modifier.padding(it)) {
+                Crossfade(
+                    loginInProgress,
+                    label = "CircularProgressCrossfade"
+                ) { showLoginProgress ->
+                    if (showLoginProgress) {
+                        CircularProgressIndicator(
+                            Modifier
+                                .fillMaxWidth()
+                                .wrapContentWidth()
+                                .size(64.dp)
+                                .padding(top = 56.dp)
+                        )
+                    } else {
+                        LoginForm(
+                            loginFormUiState,
+                            onLoginClick = onLoginClick,
+                            Modifier.padding(16.dp)
+                        )
+                    }
+                }
+            }
+        } else {
+            Crossfade(
+                loginInProgress, label = "CircularProgressCrossfade",
+                modifier = Modifier.padding(it)
+            ) { showLoginProgress ->
+                if (showLoginProgress) {
+                    CircularProgressIndicator(
+                        Modifier
+                            .fillMaxWidth()
+                            .wrapContentWidth()
+                            .size(64.dp)
+                            .padding(top = 56.dp)
+                    )
+                } else {
+                    LoginForm(
+                        loginFormUiState,
+                        onLoginClick = onLoginClick,
+                        Modifier.padding(16.dp)
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun TabletLayoutContent(
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit
+) {
+    Box(
+        contentAlignment = Alignment.TopCenter,
+        modifier = modifier
+    ) {
+        Box(
+            modifier = Modifier
+                .height(136.dp) // 192.dp - 56.dp of appbar
+                .fillMaxWidth()
+                .background(MaterialTheme.colors.primarySurface)
+                .testTag("fakeAppBar")
+        )
+
+        Card(
+            Modifier
+                .width(560.dp)
+                .fillMaxSize()
+                .testTag("contentCard"),
+            content = content
+        )
+    }
+}
+
+@Stable
+interface LoginFormUiState {
+    var serverUrl: String
+    var username: String
+    var password: String
+    var useHttpAuthentication: Boolean
+    var httpAuthUsername: String
+    var httpAuthPassword: String
+    val canChangeUsernameOrUrl: Boolean
+    val serverUrlFieldErrorMsg: Int?
+    val usernameFieldErrorMsg: Int?
+    val passwordFieldErrorMsg: Int?
+    val loginButtonEnabled: Boolean
+}
+
+class MutableLoginFormUiState : LoginFormUiState {
+    override var serverUrl: String by mutableStateOf("")
+    override var username: String  by mutableStateOf("")
+    override var password: String by mutableStateOf("")
+    override var useHttpAuthentication: Boolean by mutableStateOf(false)
+    override var httpAuthUsername: String by mutableStateOf("")
+    override var httpAuthPassword: String by mutableStateOf("")
+    override var canChangeUsernameOrUrl: Boolean by mutableStateOf(true)
+    override var serverUrlFieldErrorMsg: Int? by mutableStateOf(null)
+    override var usernameFieldErrorMsg: Int? by mutableStateOf(null)
+    override var passwordFieldErrorMsg: Int? by mutableStateOf(null)
+    override var loginButtonEnabled: Boolean by mutableStateOf(false)
+}
+
+@Composable
+private fun LoginForm(
+    uiState: LoginFormUiState,
+    onLoginClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .verticalScroll(rememberScrollState()),
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        OutlinedTextFieldWithError(
+            value = uiState.serverUrl, onValueChange = uiState::serverUrl::set,
+            enabled = uiState.canChangeUsernameOrUrl,
+            label = {
+                Text(stringResource(R.string.prompt_url))
+            },
+            leadingIcon = {
+                Icon(Icons.Default.Web, contentDescription = null)
+            },
+            errorId = uiState.serverUrlFieldErrorMsg,
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Uri,
+                imeAction = ImeAction.Next
+            )
+        )
+
+        OutlinedTextFieldWithError(
+            value = uiState.username, onValueChange = uiState::username::set,
+            enabled = uiState.canChangeUsernameOrUrl,
+            label = {
+                Text(stringResource(R.string.prompt_username))
+            },
+            leadingIcon = {
+                Icon(Icons.Default.Person, contentDescription = null)
+            },
+            errorId = uiState.usernameFieldErrorMsg,
+            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+            modifier = Modifier.padding(top = 16.dp)
+        )
+
+        OutlinedTextFieldWithError(
+            value = uiState.password, onValueChange = uiState::password::set,
+            label = {
+                Text(stringResource(R.string.prompt_password))
+            },
+            leadingIcon = {
+                Icon(Icons.Default.Password, contentDescription = null)
+            },
+            errorId = uiState.passwordFieldErrorMsg,
+            visualTransformation = PasswordVisualTransformation(),
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Password,
+                imeAction = if (uiState.useHttpAuthentication) ImeAction.Next else ImeAction.Done
+            ),
+            keyboardActions = KeyboardActions(onDone = {
+                onLoginClick()
+            }),
+            modifier = Modifier.padding(top = 16.dp)
+        )
+
+        Row(
+            verticalAlignment = Alignment.CenterVertically, modifier = Modifier
+                .width(280.dp)
+                .padding(top = 16.dp)
+        ) {
+            Text(
+                stringResource(R.string.lbl_use_http_authentication),
+                style = MaterialTheme.typography.subtitle1,
+                modifier = Modifier.weight(1f)
+            )
+            Switch(checked = uiState.useHttpAuthentication,
+                onCheckedChange = { uiState.useHttpAuthentication = it })
+        }
+
+        AnimatedVisibility(uiState.useHttpAuthentication) {
+            Column {
+                OutlinedTextField(
+                    value = uiState.httpAuthUsername,
+                    onValueChange = uiState::httpAuthUsername::set,
+                    placeholder = {
+                        Text(stringResource(R.string.prompt_username))
+                    },
+                    leadingIcon = {
+                        Icon(Icons.Default.Person, contentDescription = null)
+                    },
+                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+                    modifier = Modifier.padding(top = 16.dp)
+                )
+
+                OutlinedTextField(
+                    value = uiState.httpAuthPassword,
+                    onValueChange = uiState::httpAuthPassword::set,
+                    placeholder = {
+                        Text(stringResource(R.string.prompt_password))
+                    },
+                    leadingIcon = {
+                        Icon(Icons.Default.Password, contentDescription = null)
+                    },
+                    visualTransformation = PasswordVisualTransformation(),
+                    singleLine = true,
+                    keyboardOptions = KeyboardOptions(
+                        keyboardType = KeyboardType.Password,
+                        imeAction = ImeAction.Done
+                    ),
+                    keyboardActions = KeyboardActions(onDone = {
+                        onLoginClick()
+                    }),
+                    modifier = Modifier.padding(top = 16.dp)
+                )
+            }
+        }
+
+        Button(
+            onClick = onLoginClick,
+            enabled = uiState.loginButtonEnabled,
+            modifier = Modifier.padding(vertical = 16.dp)
+        ) {
+            Text(stringResource(R.string.action_sign_in))
+        }
+    }
+}
+
+@Composable
+private fun OutlinedTextFieldWithError(
+    value: String,
+    onValueChange: (String) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    label: @Composable (() -> Unit)? = null,
+    placeholder: @Composable (() -> Unit)? = null,
+    leadingIcon: @Composable (() -> Unit)? = null,
+    @StringRes
+    errorId: Int? = null,
+    visualTransformation: VisualTransformation = VisualTransformation.None,
+    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+    keyboardActions: KeyboardActions = KeyboardActions.Default,
+) {
+    Column(modifier) {
+        OutlinedTextField(
+            value = value, onValueChange = onValueChange,
+            enabled = enabled,
+            label = label,
+            placeholder = placeholder,
+            leadingIcon = leadingIcon,
+            isError = errorId != null,
+            singleLine = true,
+            visualTransformation = visualTransformation,
+            keyboardOptions = keyboardOptions,
+            keyboardActions = keyboardActions
+        )
+        val errorMsg = errorId?.let { stringResource(id = errorId) } ?: ""
+        Text(
+            errorMsg,
+            style = MaterialTheme.typography.caption,
+            modifier = Modifier.padding(top = 4.dp)
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+@Preview
+@Preview(device = Devices.TABLET)
+@Composable
+fun PreviewLoginScreen() {
+    BoxWithConstraints {
+        val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight))
+        AppTheme {
+            LoginScreen(windowSizeClass, loginInProgress = false,
+                loginFormUiState = MutableLoginFormUiState(),
+                onLoginClick = {})
+        }
+    }
+}