Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 23 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.compose.compiler)
}

kotlin {
Expand Down Expand Up @@ -38,7 +39,11 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
viewBinding = true
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.7.8"
}
namespace = "com.jetbrains.simplelogin.androidapp"
}
Expand All @@ -48,9 +53,24 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.material)
implementation(libs.androidx.annotation)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)

// Compose
implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.runtime)
debugImplementation(libs.compose.ui.tooling)

// Coroutines
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,130 +1,49 @@
package com.jetbrains.simplelogin.androidapp.ui.login

import android.app.Activity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.Toast
import com.jetbrains.simplelogin.androidapp.databinding.ActivityLoginBinding

import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.lifecycle.ViewModelProvider
import com.jetbrains.simplelogin.androidapp.R

class LoginActivity : AppCompatActivity() {

private lateinit var loginViewModel: LoginViewModel
private lateinit var binding: ActivityLoginBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)

val username = binding.username
val password = binding.password
val login = binding.login
val loading = binding.loading

loginViewModel = ViewModelProvider(this, LoginViewModelFactory())
.get(LoginViewModel::class.java)

loginViewModel.loginFormState.observe(this@LoginActivity, Observer {
val loginState = it ?: return@Observer

// disable login button unless both username / password is valid
login.isEnabled = loginState.isDataValid

if (loginState.usernameError != null) {
username.error = loginState.usernameError
}
if (loginState.passwordError != null) {
password.error = loginState.passwordError
}
})

loginViewModel.loginResult.observe(this@LoginActivity, Observer {
val loginResult = it ?: return@Observer

loading.visibility = View.GONE
if (loginResult.error != null) {
showLoginFailed(loginResult.error)
}
if (loginResult.success != null) {
updateUiWithUser(loginResult.success)
}
setResult(Activity.RESULT_OK)

//Complete and destroy login activity once successful
finish()
})

username.afterTextChanged {
loginViewModel.loginDataChanged(
username.text.toString(),
password.text.toString()
)
}

password.apply {
afterTextChanged {
loginViewModel.loginDataChanged(
username.text.toString(),
password.text.toString()
)
}

setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE ->
loginViewModel.login(
username.text.toString(),
password.text.toString()
)
setContent {
MaterialTheme {
Surface() {
LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
// Show welcome message
val successResult = loginViewModel.loginResult.value?.success
successResult?.let {
val welcome = getString(R.string.welcome)
Toast.makeText(
applicationContext,
"$welcome ${it.displayName}",
Toast.LENGTH_LONG
).show()
}

// Complete the login process
setResult(Activity.RESULT_OK)
finish()
}
)
}
false
}

login.setOnClickListener {
loading.visibility = View.VISIBLE
loginViewModel.login(username.text.toString(), password.text.toString())
}
}
}

private fun updateUiWithUser(model: LoggedInUserView) {
val welcome = getString(R.string.welcome)
val displayName = model.displayName
// TODO : initiate successful logged in experience
Toast.makeText(
applicationContext,
"$welcome $displayName",
Toast.LENGTH_LONG
).show()
}

private fun showLoginFailed(@StringRes errorString: Int) {
Toast.makeText(applicationContext, errorString, Toast.LENGTH_SHORT).show()
}
}

/**
* Extension function to simplify setting an afterTextChanged action to EditText components.
*/
fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) {
this.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(editable: Editable?) {
afterTextChanged.invoke(editable.toString())
}

override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package com.jetbrains.simplelogin.androidapp.ui.login

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jetbrains.simplelogin.androidapp.R

@Composable
fun LoginScreen(
viewModel: LoginViewModel,
onLoginSuccess: () -> Unit
) {
val loginFormState by viewModel.loginFormState.collectAsStateWithLifecycle()
val loginResult by viewModel.loginResult.collectAsStateWithLifecycle()

var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val passwordFocusRequester = remember { FocusRequester() }

// Handle login result
LaunchedEffect(loginResult) {
loginResult?.let { result ->
if (result.success != null) {
// Show welcome message
onLoginSuccess()
}
}
}

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Username field
OutlinedTextField(
value = username,
onValueChange = {
username = it
viewModel.loginDataChanged(username, password)
},
label = { Text(stringResource(R.string.prompt_email)) },
isError = loginFormState.usernameError != null,
supportingText = {
loginFormState.usernameError?.let {
Text(it)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)

// Password field
OutlinedTextField(
value = password,
onValueChange = {
password = it
viewModel.loginDataChanged(username, password)
},
label = { Text(stringResource(R.string.prompt_password)) },
isError = loginFormState.passwordError != null,
supportingText = {
loginFormState.passwordError?.let {
Text(it)
}
},
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (loginFormState.isDataValid) {
viewModel.login(username, password)
}
}
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.focusRequester(passwordFocusRequester)
)

// Login button
Button(
onClick = { viewModel.login(username, password) },
enabled = loginFormState.isDataValid,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
Text(stringResource(R.string.action_sign_in))
}

// Loading indicator
if (loginResult != null && loginResult?.success == null && loginResult?.error == null) {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp)
)
}

// Error message
loginResult?.error?.let { errorId ->
Text(
text = stringResource(errorId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp)
)
}
}
}
Loading
Loading