Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Convert to Jetpack Compose and StateFlow.
  • Loading branch information
pahill committed Apr 17, 2025
commit 72e6df93cb4d017bee73168b9365c938ab1d0f2d
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.

23 changes: 23 additions & 0 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 @@ -39,6 +40,11 @@ android {
}
buildFeatures {
viewBinding = true
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.7.8"
}
namespace = "com.jetbrains.simplelogin.androidapp"
}
Expand All @@ -51,6 +57,23 @@ dependencies {
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(color = MaterialTheme.colorScheme.background) {
LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
// Show welcome message
val user = loginViewModel.loginResult.value?.success
user?.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,138 @@
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()
val context = LocalContext.current

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