Skip to content

Update starter sample to Jetpack Compose, StateFlow, Material3 #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all 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.

23 changes: 20 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,8 +39,9 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
viewBinding = true
compose = true
}

namespace = "com.jetbrains.simplelogin.androidapp"
}

Expand All @@ -48,9 +50,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,51 @@
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 com.jetbrains.simplelogin.androidapp.R
import com.jetbrains.simplelogin.androidapp.data.LoginDataSource
import com.jetbrains.simplelogin.androidapp.data.LoginDataValidator
import com.jetbrains.simplelogin.androidapp.data.LoginRepository

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(
loginRepository = LoginRepository(
dataSource = LoginDataSource()
),
dataValidator = LoginDataValidator()
),
onLoginSuccess = {
// Show welcome message
val successResult = it.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: (LoginResult) -> 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(result)
}
}
}

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