From 0e3471c99662ab029789a28dc624b4ee46fbcbdf Mon Sep 17 00:00:00 2001 From: Purvesh Dodiya Date: Mon, 17 Apr 2023 17:09:26 +0530 Subject: [PATCH] Login screen added. --- app/build.gradle | 9 ++ app/src/main/AndroidManifest.xml | 4 + .../com/example/myapplication/MainActivity.kt | 13 +- .../myapplication/data/LoginDataSource.kt | 24 ++++ .../myapplication/data/LoginRepository.kt | 46 +++++++ .../com/example/myapplication/data/Result.kt | 18 +++ .../myapplication/data/model/LoggedInUser.kt | 9 ++ .../ui/login/LoggedInUserView.kt | 9 ++ .../myapplication/ui/login/LoginActivity.kt | 130 ++++++++++++++++++ .../myapplication/ui/login/LoginFormState.kt | 10 ++ .../myapplication/ui/login/LoginResult.kt | 9 ++ .../myapplication/ui/login/LoginViewModel.kt | 55 ++++++++ .../ui/login/LoginViewModelFactory.kt | 25 ++++ .../res/layout-w1240dp/activity_login.xml | 69 ++++++++++ .../main/res/layout-w936dp/activity_login.xml | 76 ++++++++++ app/src/main/res/layout/activity_login.xml | 69 ++++++++++ app/src/main/res/layout/activity_main.xml | 47 +++++-- app/src/main/res/values-land/dimens.xml | 3 + app/src/main/res/values-w1240dp/dimens.xml | 3 + app/src/main/res/values-w600dp/dimens.xml | 3 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 9 ++ 22 files changed, 629 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/example/myapplication/data/LoginDataSource.kt create mode 100644 app/src/main/java/com/example/myapplication/data/LoginRepository.kt create mode 100644 app/src/main/java/com/example/myapplication/data/Result.kt create mode 100644 app/src/main/java/com/example/myapplication/data/model/LoggedInUser.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoggedInUserView.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoginActivity.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoginFormState.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoginResult.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoginViewModel.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/login/LoginViewModelFactory.kt create mode 100644 app/src/main/res/layout-w1240dp/activity_login.xml create mode 100644 app/src/main/res/layout-w936dp/activity_login.xml create mode 100644 app/src/main/res/layout/activity_login.xml create mode 100644 app/src/main/res/values-land/dimens.xml create mode 100644 app/src/main/res/values-w1240dp/dimens.xml create mode 100644 app/src/main/res/values-w600dp/dimens.xml create mode 100644 app/src/main/res/values/dimens.xml diff --git a/app/build.gradle b/app/build.gradle index 68cbcef..ae7d410 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,6 +30,12 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildFeatures { + viewBinding true + } + dataBinding { + enabled = true + } } dependencies { @@ -38,6 +44,9 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.8.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.annotation:annotation:1.6.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5ec84d..33e1d70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,10 @@ android:supportsRtl="true" android:theme="@style/Theme.MyApplication" tools:targetApi="31"> + diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index 10b2733..c0cd03f 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -1,11 +1,22 @@ package com.example.myapplication +import android.content.Intent import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.view.LayoutInflater +import androidx.databinding.DataBindingUtil +import com.example.myapplication.databinding.ActivityMainBinding +import com.example.myapplication.ui.login.LoginActivity class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + binding = DataBindingUtil.setContentView(this, R.layout.activity_main) + setContentView(binding.root) + binding.loginButton.setOnClickListener { + startActivity(Intent(this, LoginActivity::class.java)) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/data/LoginDataSource.kt b/app/src/main/java/com/example/myapplication/data/LoginDataSource.kt new file mode 100644 index 0000000..eaf76a5 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/LoginDataSource.kt @@ -0,0 +1,24 @@ +package com.example.myapplication.data + +import com.example.myapplication.data.model.LoggedInUser +import java.io.IOException + +/** + * Class that handles authentication w/ login credentials and retrieves user information. + */ +class LoginDataSource { + + fun login(username: String, password: String): Result { + return try { + // TODO: handle loggedInUser authentication + val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe") + Result.Success(fakeUser) + } catch (e: Throwable) { + Result.Error(IOException("Error logging in", e)) + } + } + + fun logout() { + // TODO: revoke authentication + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/data/LoginRepository.kt b/app/src/main/java/com/example/myapplication/data/LoginRepository.kt new file mode 100644 index 0000000..58b2540 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/LoginRepository.kt @@ -0,0 +1,46 @@ +package com.example.myapplication.data + +import com.example.myapplication.data.model.LoggedInUser + +/** + * Class that requests authentication and user information from the remote data source and + * maintains an in-memory cache of login status and user credentials information. + */ + +class LoginRepository(val dataSource: LoginDataSource) { + + // in-memory cache of the loggedInUser object + var user: LoggedInUser? = null + private set + + val isLoggedIn: Boolean + get() = user != null + + init { + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + user = null + } + + fun logout() { + user = null + dataSource.logout() + } + + fun login(username: String, password: String): Result { + // handle login + val result = dataSource.login(username, password) + + if (result is Result.Success) { + setLoggedInUser(result.data) + } + + return result + } + + private fun setLoggedInUser(loggedInUser: LoggedInUser) { + this.user = loggedInUser + // If user credentials will be cached in local storage, it is recommended it be encrypted + // @see https://developer.android.com/training/articles/keystore + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/data/Result.kt b/app/src/main/java/com/example/myapplication/data/Result.kt new file mode 100644 index 0000000..e0cd6ea --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/Result.kt @@ -0,0 +1,18 @@ +package com.example.myapplication.data + +/** + * A generic class that holds a value with its loading status. + * @param + */ +sealed class Result { + + data class Success(val data: T) : Result() + data class Error(val exception: Exception) : Result() + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data]" + is Error -> "Error[exception=$exception]" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/data/model/LoggedInUser.kt b/app/src/main/java/com/example/myapplication/data/model/LoggedInUser.kt new file mode 100644 index 0000000..7f1ee1f --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/model/LoggedInUser.kt @@ -0,0 +1,9 @@ +package com.example.myapplication.data.model + +/** + * Data class that captures user information for logged in users retrieved from LoginRepository + */ +data class LoggedInUser( + val userId: String, + val displayName: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoggedInUserView.kt b/app/src/main/java/com/example/myapplication/ui/login/LoggedInUserView.kt new file mode 100644 index 0000000..83695c0 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoggedInUserView.kt @@ -0,0 +1,9 @@ +package com.example.myapplication.ui.login + +/** + * User details post authentication that is exposed to the UI + */ +data class LoggedInUserView( + val displayName: String + //... other data fields that may be accessible to the UI +) diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginActivity.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginActivity.kt new file mode 100644 index 0000000..ed5db4b --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginActivity.kt @@ -0,0 +1,130 @@ +package com.example.myapplication.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.example.myapplication.databinding.ActivityLoginBinding + +import com.example.myapplication.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 = getString(loginState.usernameError) + } + if (loginState.passwordError != null) { + password.error = getString(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() + ) + } + 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/example/myapplication/ui/login/LoginFormState.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginFormState.kt new file mode 100644 index 0000000..0483834 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginFormState.kt @@ -0,0 +1,10 @@ +package com.example.myapplication.ui.login + +/** + * Data validation state of the login form. + */ +data class LoginFormState( + val usernameError: Int? = null, + val passwordError: Int? = null, + val isDataValid: Boolean = false +) diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginResult.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginResult.kt new file mode 100644 index 0000000..d929c63 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginResult.kt @@ -0,0 +1,9 @@ +package com.example.myapplication.ui.login + +/** + * Authentication result : success (user details) or error message. + */ +data class LoginResult( + val success: LoggedInUserView? = null, + val error: Int? = null +) diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginViewModel.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..097c9b0 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginViewModel.kt @@ -0,0 +1,55 @@ +package com.example.myapplication.ui.login + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import android.util.Patterns +import com.example.myapplication.data.LoginRepository +import com.example.myapplication.data.Result + +import com.example.myapplication.R + +class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() { + + private val _loginForm = MutableLiveData() + val loginFormState: LiveData = _loginForm + + private val _loginResult = MutableLiveData() + val loginResult: LiveData = _loginResult + + 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) + } + } + + fun loginDataChanged(username: String, password: String) { + if (!isUserNameValid(username)) { + _loginForm.value = LoginFormState(usernameError = R.string.invalid_username) + } else if (!isPasswordValid(password)) { + _loginForm.value = LoginFormState(passwordError = R.string.invalid_password) + } else { + _loginForm.value = LoginFormState(isDataValid = true) + } + } + + // A placeholder username validation check + private fun isUserNameValid(username: String): Boolean { + return if (username.contains('@')) { + Patterns.EMAIL_ADDRESS.matcher(username).matches() + } else { + username.isNotBlank() + } + } + + // A placeholder password validation check + private fun isPasswordValid(password: String): Boolean { + return password.length > 5 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginViewModelFactory.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginViewModelFactory.kt new file mode 100644 index 0000000..710538a --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginViewModelFactory.kt @@ -0,0 +1,25 @@ +package com.example.myapplication.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.myapplication.data.LoginDataSource +import com.example.myapplication.data.LoginRepository + +/** + * ViewModel provider factory to instantiate LoginViewModel. + * Required given LoginViewModel has a non-empty constructor + */ +class LoginViewModelFactory : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { + return LoginViewModel( + loginRepository = LoginRepository( + dataSource = LoginDataSource() + ) + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/res/layout-w1240dp/activity_login.xml b/app/src/main/res/layout-w1240dp/activity_login.xml new file mode 100644 index 0000000..2445403 --- /dev/null +++ b/app/src/main/res/layout-w1240dp/activity_login.xml @@ -0,0 +1,69 @@ + + + + + + + +