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 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout-w936dp/activity_login.xml b/app/src/main/res/layout-w936dp/activity_login.xml
new file mode 100644
index 0000000..e022921
--- /dev/null
+++ b/app/src/main/res/layout-w936dp/activity_login.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..2445403
--- /dev/null
+++ b/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 17eab17..63686c1 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,18 +1,35 @@
-
+
+
-
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml
new file mode 100644
index 0000000..5f681ae
--- /dev/null
+++ b/app/src/main/res/values-land/dimens.xml
@@ -0,0 +1,3 @@
+
+ 48dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml
new file mode 100644
index 0000000..7e06511
--- /dev/null
+++ b/app/src/main/res/values-w1240dp/dimens.xml
@@ -0,0 +1,3 @@
+
+ 200dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml
new file mode 100644
index 0000000..5f681ae
--- /dev/null
+++ b/app/src/main/res/values-w600dp/dimens.xml
@@ -0,0 +1,3 @@
+
+ 48dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..e00c2dd
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 16dp
+ 16dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3de1a39..99b61b7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,12 @@
My Application
+ LoginActivity
+ Email
+ Password
+ Sign in or register
+ Sign in
+ "Welcome !"
+ Not a valid username
+ Password must be >5 characters
+ "Login failed"
\ No newline at end of file