Login: change server screen WIP

This commit is contained in:
Benoit Marty
2022-11-15 15:22:43 +01:00
parent 8d861690b7
commit e3fbaba3ae
12 changed files with 398 additions and 117 deletions

View File

@@ -5,11 +5,9 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import io.element.android.x.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.destinations.*
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.onboarding.OnBoardingScreen
import io.element.android.x.features.roomlist.RoomListScreen
@@ -32,6 +30,10 @@ fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) {
@Composable
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
LoginScreen(
homeserver = "matrix.org",
onChangeServer = {
navigator.navigate(ChangeServerScreenNavigationDestination)
},
onLoginWithSuccess = {
navigator.navigate(RoomListScreenNavigationDestination) {
popUpTo(OnBoardingScreenNavigationDestination) {
@@ -42,6 +44,17 @@ fun LoginScreenNavigation(navigator: DestinationsNavigator) {
)
}
// TODO Create a subgraph in Login module
@Destination
@Composable
fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
ChangeServerScreen(
onChangeServerSuccess = {
navigator.popBackStack()
}
)
}
@RootNavGraph(start = true)
@Destination
@Composable

View File

@@ -1,8 +0,0 @@
package io.element.android.x.features.login
sealed interface LoginActions {
data class SetHomeserver(val homeserver: String) : LoginActions
data class SetLogin(val login: String) : LoginActions
data class SetPassword(val password: String) : LoginActions
object Submit : LoginActions
}

View File

@@ -2,132 +2,184 @@
package io.element.android.x.features.login
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.components.VectorButton
import io.element.android.x.designsystem.ElementXTheme
@Composable
fun LoginScreen(
viewModel: LoginViewModel = mavericksViewModel(),
onLoginWithSuccess: () -> Unit = { }
homeserver: String,
onChangeServer: () -> Unit = { },
onLoginWithSuccess: () -> Unit = { },
) {
val state: LoginViewState by viewModel.collectAsState()
LaunchedEffect(key1 = Unit) {
viewModel.homeserver = homeserver
}
LoginContent(
state = state,
onHomeserverChanged = { viewModel.handle(LoginActions.SetHomeserver(it)) },
onLoginChanged = { viewModel.handle(LoginActions.SetLogin(it)) },
onPasswordChanged = { viewModel.handle(LoginActions.SetPassword(it)) },
onSubmitClicked = { viewModel.handle(LoginActions.Submit) },
homeserver = homeserver,
onChangeServer = onChangeServer,
onLoginChanged = viewModel::onSetName,
onPasswordChanged = viewModel::onSetPassword,
onSubmitClicked = viewModel::onSubmit,
onLoginWithSuccess = onLoginWithSuccess
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginContent(
state: LoginViewState,
onHomeserverChanged: (String) -> Unit,
onLoginChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onSubmitClicked: () -> Unit,
onLoginWithSuccess: () -> Unit
homeserver: String = "",
onChangeServer: () -> Unit = {},
onLoginChanged: (String) -> Unit = {},
onPasswordChanged: (String) -> Unit = {},
onSubmitClicked: () -> Unit = {},
onLoginWithSuccess: () -> Unit = {},
) {
Surface(color = MaterialTheme.colorScheme.background) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val isError = state.isLoggedIn is Fail
Image(
painterResource(id = R.drawable.element_logo_green),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(40.dp)
)
OutlinedTextField(
value = state.homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = {
onHomeserverChanged(it)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
),
)
OutlinedTextField(
value = state.login,
// Title
Text(
text = "Welcome back",
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
onValueChange = {
onLoginChanged(it)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
.padding(horizontal = 16.dp, vertical = 48.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
OutlinedTextField(
value = state.password,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
onValueChange = {
onPasswordChanged(it)
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Send,
),
)
if (isError) {
Text(
text = (state.isLoggedIn as? Fail)?.toString().orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
// Form
Column(
//modifier = Modifier.weight(1f),
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = { /* no op */ },
enabled = false,
label = {
Text(text = "Server")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
),
)
Button(
onClick = onChangeServer,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(top = 8.dp, end = 8.dp),
content = {
Text(text = "Change")
}
)
}
OutlinedTextField(
value = state.login,
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
label = {
Text(text = "Email or username")
},
onValueChange = onLoginChanged,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
)
OutlinedTextField(
value = state.password,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
onValueChange = onPasswordChanged,
label = {
Text(text = "Password")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Send,
),
)
if (isError) {
Text(
text = (state.isLoggedIn as? Fail)?.toString().orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
}
VectorButton(
text = "Submit",
onClick = {
onSubmitClicked()
},
// Submit
Button(
onClick = onSubmitClicked,
enabled = state.submitEnabled,
modifier = Modifier
.align(Alignment.End)
.padding(top = 16.dp)
)
if (state.isLoggedIn is Loading) {
// FIXME This does not work, we never enter this if block
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
.fillMaxWidth()
.padding(vertical = 32.dp)
) {
Text(text = "Continue")
}
if (state.isLoggedIn is Success) {
onLoginWithSuccess()
}
}
if (state.isLoggedIn is Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
@Composable
@Preview
private fun LoginContentPreview() {
ElementXTheme(darkTheme = false) {
LoginContent(
state = LoginViewState(),
homeserver = "matrix.org",
)
}
}

View File

@@ -1,27 +1,28 @@
package io.element.android.x.features.login
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.matrix.MatrixInstance
import kotlinx.coroutines.launch
class LoginViewModel(initialState: LoginViewState) :
MavericksViewModel<LoginViewState>(initialState) {
lateinit var homeserver: String
private val matrix = MatrixInstance.getInstance()
fun handle(action: LoginActions) {
when (action) {
is LoginActions.SetHomeserver -> handleSetHomeserver(action)
is LoginActions.SetLogin -> handleSetName(action)
is LoginActions.SetPassword -> handleSetPassword(action)
LoginActions.Submit -> handleSubmit()
fun onSubmit() = withState { state ->
setState {
copy(isLoggedIn = Loading())
}
}
private fun handleSubmit() = withState { state ->
viewModelScope.launch {
suspend {
matrix.login(state.homeserver, state.login, state.password)
// Ensure the server is passed to the Rust SDK
matrix.setHomeserver(homeserver)
matrix.login(state.login, state.password)
matrix.activeClient().startSync()
}.execute {
copy(isLoggedIn = it)
@@ -29,15 +30,21 @@ class LoginViewModel(initialState: LoginViewState) :
}
}
private fun handleSetHomeserver(action: LoginActions.SetHomeserver) {
setState { copy(homeserver = action.homeserver) }
fun onSetPassword(password: String) {
setState {
copy(
password = password,
isLoggedIn = Uninitialized,
)
}
}
private fun handleSetPassword(action: LoginActions.SetPassword) {
setState { copy(password = action.password) }
}
private fun handleSetName(action: LoginActions.SetLogin) {
setState { copy(login = action.login) }
fun onSetName(name: String) {
setState {
copy(
login = name,
isLoggedIn = Uninitialized,
)
}
}
}

View File

@@ -1,17 +1,14 @@
package io.element.android.x.features.login
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
data class LoginViewState(
val homeserver: String = "matrix.org",
val login: String = "",
val password: String = "",
val isLoggedIn: Async<Unit> = Uninitialized,
) : MavericksState {
val submitEnabled = homeserver.isNotEmpty() && login.isNotEmpty() && password.isNotEmpty()
val submitEnabled = login.isNotEmpty() && password.isNotEmpty() && isLoggedIn !is Loading
}

View File

@@ -0,0 +1,149 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.login.changeserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
@Composable
fun ChangeServerScreen(
viewModel: ChangeServerViewModel = mavericksViewModel(),
onChangeServerSuccess: () -> Unit = { }
) {
val state: ChangeServerViewState by viewModel.collectAsState()
ChangeServerContent(
state = state,
onChangeServer = viewModel::setServer,
onChangeServerSubmit = viewModel::setServerSubmit,
onChangeServerSuccess = onChangeServerSuccess
)
}
@Composable
fun ChangeServerContent(
state: ChangeServerViewState,
onChangeServer: (String) -> Unit = {},
onChangeServerSubmit: () -> Unit = {},
onChangeServerSuccess: () -> Unit = {},
) {
Surface(color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.fillMaxSize()) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
)
{
val isError = state.changeServerAction is Fail
Text(
modifier = Modifier
.padding(top = 99.dp)
.size(width = 81.dp, height = 73.dp)
.align(Alignment.CenterHorizontally)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
),
text = "\uDBC2\uDEAC",
fontSize = 34.sp,
textAlign = TextAlign.Center,
)
Text(
text = "Your server",
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 38.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
Text(
text = "A server is a home for all your data.\n" +
"You choose your server and its easy to make one.", // TODO "Learn more.",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary
)
OutlinedTextField(
value = state.homeserver,
modifier = Modifier
.fillMaxWidth()
.padding(top = 200.dp),
onValueChange = onChangeServer,
label = {
Text(text = "Server")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Send,
),
)
if (isError) {
Text(
text = (state.changeServerAction as? Fail)?.toString().orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Button(
onClick = onChangeServerSubmit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(top = 44.dp)
) {
Text(text = "Continue")
}
if (state.changeServerAction is Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
@Composable
@Preview
private fun ChangeServerContentPreview() {
ChangeServerContent(
state = ChangeServerViewState(homeserver = "matrix.org"),
)
}

View File

@@ -0,0 +1,32 @@
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import io.element.android.x.matrix.MatrixInstance
import kotlinx.coroutines.launch
class ChangeServerViewModel(initialState: ChangeServerViewState) :
MavericksViewModel<ChangeServerViewState>(initialState) {
private val matrix = MatrixInstance.getInstance()
fun setServer(server: String) {
setState {
copy(homeserver = server)
}
}
fun setServerSubmit() = withState { state ->
setState {
copy(changeServerAction = Loading())
}
viewModelScope.launch {
suspend {
matrix.setHomeserver(state.homeserver)
}.execute { it ->
copy(changeServerAction = it)
}
}
}
}

View File

@@ -0,0 +1,13 @@
package io.element.android.x.features.login.changeserver
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
data class ChangeServerViewState(
val homeserver: String = "matrix.org",
val changeServerAction: Async<Unit> = Uninitialized,
) : MavericksState {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
}

View File

@@ -1,5 +0,0 @@
package io.element.android.x.features.onboarding
sealed interface OnBoardingActions {
data class GoToPage(val page: Int) : OnBoardingActions
}

View File

@@ -48,4 +48,5 @@ dependencies {
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation 'com.airbnb.android:mavericks-compose:3.0.1'
implementation 'androidx.compose.foundation:foundation-layout:1.3.1'
}

View File

@@ -0,0 +1,26 @@
package io.element.android.x.core.compose
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
/**
* Inspired from https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose
*/
enum class Keyboard {
Opened, Closed
}
// Note: it does not work as expected...
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun keyboardAsState(): State<Keyboard> {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val isResumed = lifecycle.currentState == Lifecycle.State.RESUMED
return rememberUpdatedState(if (WindowInsets.isImeVisible && isResumed) Keyboard.Opened else Keyboard.Closed)
}

View File

@@ -7,7 +7,6 @@ import io.element.android.x.matrix.util.logError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client
@@ -27,6 +26,7 @@ class Matrix(
private val baseFolder = File(context.filesDir, "matrix")
private val sessionStore = SessionStore(context)
private val matrixClient = MutableStateFlow<Optional<MatrixClient>>(Optional.empty())
private val authService = AuthenticationService(baseFolder.absolutePath)
init {
sessionStore.isLoggedIn()
@@ -70,10 +70,14 @@ class Matrix(
}
}
suspend fun login(homeserver: String, username: String, password: String): MatrixClient =
suspend fun setHomeserver(homeserver: String) {
withContext(coroutineDispatchers.io) {
val authService = AuthenticationService(baseFolder.absolutePath)
authService.configureHomeserver(homeserver)
}
}
suspend fun login(username: String, password: String): MatrixClient =
withContext(coroutineDispatchers.io) {
val client = authService.login(username, password, "MatrixRustSDKSample", null)
sessionStore.storeData(client.session())
createMatrixClient(client)