diff --git a/app/src/main/java/io/element/android/x/Navigation.kt b/app/src/main/java/io/element/android/x/Navigation.kt index 6604136649..974c366a07 100644 --- a/app/src/main/java/io/element/android/x/Navigation.kt +++ b/app/src/main/java/io/element/android/x/Navigation.kt @@ -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 diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginActions.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginActions.kt deleted file mode 100644 index f9d8cbbb62..0000000000 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginActions.kt +++ /dev/null @@ -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 -} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt index 2bc570d369..42b7cf792f 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/LoginScreen.kt @@ -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) + ) + } } } -} \ No newline at end of file +} + +@Composable +@Preview +private fun LoginContentPreview() { + ElementXTheme(darkTheme = false) { + LoginContent( + state = LoginViewState(), + homeserver = "matrix.org", + ) + } +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt index 89d07bfda7..f3a3210148 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/LoginViewModel.kt @@ -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(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) } - } - - private fun handleSetPassword(action: LoginActions.SetPassword) { - setState { copy(password = action.password) } + fun onSetPassword(password: String) { + setState { + copy( + password = password, + isLoggedIn = Uninitialized, + ) + } } - private fun handleSetName(action: LoginActions.SetLogin) { - setState { copy(login = action.login) } + fun onSetName(name: String) { + setState { + copy( + login = name, + isLoggedIn = Uninitialized, + ) + } } } \ No newline at end of file diff --git a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt index 018c9d08f2..09496c5662 100644 --- a/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt +++ b/features/login/src/main/java/io/element/android/x/features/login/LoginViewState.kt @@ -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 = Uninitialized, ) : MavericksState { - - val submitEnabled = homeserver.isNotEmpty() && login.isNotEmpty() && password.isNotEmpty() - - + val submitEnabled = login.isNotEmpty() && password.isNotEmpty() && isLoggedIn !is Loading } diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt new file mode 100644 index 0000000000..4e2336ad50 --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerScreen.kt @@ -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 it’s 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"), + ) +} diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt new file mode 100644 index 0000000000..9875546f5e --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewModel.kt @@ -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(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) + } + } + } +} \ No newline at end of file diff --git a/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt new file mode 100644 index 0000000000..fd0439a0fa --- /dev/null +++ b/features/login/src/main/java/io/element/android/x/features/login/changeserver/ChangeServerViewState.kt @@ -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 = Uninitialized, +) : MavericksState { + val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading +} \ No newline at end of file diff --git a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingActions.kt b/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingActions.kt deleted file mode 100644 index d95c6f3a4a..0000000000 --- a/features/onboarding/src/main/java/io/element/android/x/features/onboarding/OnBoardingActions.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.element.android.x.features.onboarding - -sealed interface OnBoardingActions { - data class GoToPage(val page: Int) : OnBoardingActions -} diff --git a/libraries/core/build.gradle b/libraries/core/build.gradle index 2363a4bdc7..2274102865 100644 --- a/libraries/core/build.gradle +++ b/libraries/core/build.gradle @@ -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' } \ No newline at end of file diff --git a/libraries/core/src/main/java/io/element/android/x/core/compose/Keyboard.kt b/libraries/core/src/main/java/io/element/android/x/core/compose/Keyboard.kt new file mode 100644 index 0000000000..100d0ba86f --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/compose/Keyboard.kt @@ -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 { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val isResumed = lifecycle.currentState == Lifecycle.State.RESUMED + return rememberUpdatedState(if (WindowInsets.isImeVisible && isResumed) Keyboard.Opened else Keyboard.Closed) +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt index 3252a9ebad..72a7bfc573 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/Matrix.kt @@ -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.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)