From 72e6df93cb4d017bee73168b9365c938ab1d0f2d Mon Sep 17 00:00:00 2001 From: pahill Date: Thu, 17 Apr 2025 16:10:24 +0200 Subject: [PATCH 1/4] Convert to Jetpack Compose and StateFlow. --- .idea/misc.xml | 1 - app/build.gradle.kts | 23 +++ .../androidapp/ui/login/LoginActivity.kt | 135 ++++------------- .../androidapp/ui/login/LoginScreen.kt | 138 ++++++++++++++++++ .../androidapp/ui/login/LoginViewModel.kt | 33 +++-- gradle/libs.versions.toml | 13 +- 6 files changed, 219 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginScreen.kt diff --git a/.idea/misc.xml b/.idea/misc.xml index a8cf6ba..9997112 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9d0644..f656592 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { @@ -39,6 +40,11 @@ android { } buildFeatures { viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.7.8" } namespace = "com.jetbrains.simplelogin.androidapp" } @@ -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) diff --git a/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginActivity.kt b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginActivity.kt index 217c7ea..efb408a 100644 --- a/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginActivity.kt +++ b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginActivity.kt @@ -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) {} - }) -} \ No newline at end of file diff --git a/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginScreen.kt b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginScreen.kt new file mode 100644 index 0000000..fa0265f --- /dev/null +++ b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginScreen.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginViewModel.kt b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginViewModel.kt index 492bad1..f4250c8 100644 --- a/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/jetbrains/simplelogin/androidapp/ui/login/LoginViewModel.kt @@ -1,37 +1,42 @@ package com.jetbrains.simplelogin.androidapp.ui.login -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.jetbrains.simplelogin.androidapp.data.LoginRepository import com.jetbrains.simplelogin.androidapp.data.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import com.jetbrains.simplelogin.androidapp.R import com.jetbrains.simplelogin.androidapp.data.LoginDataValidator class LoginViewModel(private val loginRepository: LoginRepository, private val dataValidator: LoginDataValidator) : ViewModel() { - private val _loginForm = MutableLiveData() - val loginFormState: LiveData = _loginForm + private val _loginFormState = MutableStateFlow(LoginFormState(null, null)) + val loginFormState: StateFlow = _loginFormState.asStateFlow() - private val _loginResult = MutableLiveData() - val loginResult: LiveData = _loginResult + private val _loginResult = MutableStateFlow(null) + val loginResult: StateFlow = _loginResult.asStateFlow() fun login(username: String, password: String) { // can be launched in a separate asynchronous job - val result = loginRepository.login(username, password) - - if (result is Result.Success) { - _loginResult.value = LoginResult(success = LoggedInUserView(displayName = result.data.displayName)) - } else { - _loginResult.value = LoginResult(error = R.string.login_failed) + viewModelScope.launch { + val result = loginRepository.login(username, password) + + if (result is Result.Success) { + _loginResult.value = LoginResult(success = LoggedInUserView(displayName = result.data.displayName)) + } else { + _loginResult.value = LoginResult(error = R.string.login_failed) + } } } fun loginDataChanged(username: String, password: String) { - _loginForm.value = LoginFormState( + _loginFormState.value = LoginFormState( usernameError = (dataValidator.checkUsername(username) as? LoginDataValidator.Result.Error)?.message, passwordError = (dataValidator.checkPassword(password) as? LoginDataValidator.Result.Error)?.message ) } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca5b642..8c7d7f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -android-gradlePlugin = "8.8.2" +android-gradlePlugin = "8.8.0-alpha05" android-compileSdk = "35" android-minSdk = "24" android-targetSdk = "35" @@ -19,12 +19,18 @@ kotlin = "2.1.10" ktor = "3.1.0" kotlinxCoroutinesCore = "1.10.1" lifecycleLivedataKtx = "2.8.7" +lifecycleRuntimeKtx = "2.8.7" +lifecycleViewmodelCompose = "2.8.7" +material3 = "1.3.0" voyagerNavigator = "1.0.0" [libraries] androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleLivedataKtx" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "imageLoader" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -36,9 +42,14 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } +compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesCore" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } From 66b0d074ec00b63aa22252d5d7a9ea2bfe052330 Mon Sep 17 00:00:00 2001 From: pahill Date: Sat, 19 Apr 2025 10:21:06 +0200 Subject: [PATCH 2/4] Update Gradle settings, dependencies, and cleanup unused code Revised Gradle properties, upgraded dependencies, and migrated to a local JVM path for consistency. Removed outdated or unused plugins, libraries, and code (e.g., view binding, constraint layout). Simplified logic in `LoginActivity` and `LoginScreen` for improved readability. --- .idea/gradle.xml | 2 +- app/build.gradle.kts | 3 --- .../androidapp/ui/login/LoginActivity.kt | 6 ++--- .../androidapp/ui/login/LoginScreen.kt | 1 - gradle/libs.versions.toml | 22 ++++--------------- gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 9 insertions(+), 27 deletions(-) diff --git a/.idea/gradle.xml b/.idea/gradle.xml index dec7287..639c779 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,7 +6,7 @@