inital login activity
This commit is contained in:
parent
aa788f9720
commit
746b670403
@ -47,6 +47,9 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation "com.github.tomakehurst:wiremock-jre8:2.26.2"
|
testImplementation "com.github.tomakehurst:wiremock-jre8:2.26.2"
|
||||||
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
|
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
|
||||||
|
implementation 'com.google.android.material:material:1.1.0'
|
||||||
|
implementation 'androidx.annotation:annotation:1.1.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||||
testImplementation 'junit:junit:4.13'
|
testImplementation 'junit:junit:4.13'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||||
|
@ -9,6 +9,9 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
<activity
|
||||||
|
android:name=".ui.login.LoginActivity"
|
||||||
|
android:label="@string/title_activity_login"></activity>
|
||||||
<activity android:name=".MainActivity">
|
<activity android:name=".MainActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
25
app/src/main/java/com/h/pixeldroid/data/LoginDataSource.kt
Normal file
25
app/src/main/java/com/h/pixeldroid/data/LoginDataSource.kt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package com.h.pixeldroid.data
|
||||||
|
|
||||||
|
import com.h.pixeldroid.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<LoggedInUser> {
|
||||||
|
try {
|
||||||
|
// TODO: handle loggedInUser authentication
|
||||||
|
val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
|
||||||
|
return Result.Success(fakeUser)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return Result.Error(IOException("Error logging in", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
// TODO: revoke authentication
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
46
app/src/main/java/com/h/pixeldroid/data/LoginRepository.kt
Normal file
46
app/src/main/java/com/h/pixeldroid/data/LoginRepository.kt
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package com.h.pixeldroid.data
|
||||||
|
|
||||||
|
import com.h.pixeldroid.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<LoggedInUser> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
18
app/src/main/java/com/h/pixeldroid/data/Result.kt
Normal file
18
app/src/main/java/com/h/pixeldroid/data/Result.kt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package com.h.pixeldroid.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic class that holds a value with its loading status.
|
||||||
|
* @param <T>
|
||||||
|
*/
|
||||||
|
sealed class Result<out T : Any> {
|
||||||
|
|
||||||
|
data class Success<out T : Any>(val data: T) : Result<T>()
|
||||||
|
data class Error(val exception: Exception) : Result<Nothing>()
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return when (this) {
|
||||||
|
is Success<*> -> "Success[data=$data]"
|
||||||
|
is Error -> "Error[exception=$exception]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.h.pixeldroid.data.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class that captures user information for logged in users retrieved from LoginRepository
|
||||||
|
*/
|
||||||
|
data class LoggedInUser(
|
||||||
|
val userId: String,
|
||||||
|
val displayName: String
|
||||||
|
)
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.h.pixeldroid.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
|
||||||
|
)
|
129
app/src/main/java/com/h/pixeldroid/ui/login/LoginActivity.kt
Normal file
129
app/src/main/java/com/h/pixeldroid/ui/login/LoginActivity.kt
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package com.h.pixeldroid.ui.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
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.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.Toast
|
||||||
|
|
||||||
|
import com.h.pixeldroid.R
|
||||||
|
|
||||||
|
class LoginActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var loginViewModel: LoginViewModel
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_login)
|
||||||
|
|
||||||
|
val username = findViewById<EditText>(R.id.username)
|
||||||
|
val password = findViewById<EditText>(R.id.password)
|
||||||
|
val login = findViewById<Button>(R.id.login)
|
||||||
|
val loading = findViewById<ProgressBar>(R.id.loading)
|
||||||
|
|
||||||
|
loginViewModel = ViewModelProviders.of(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) {}
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package com.h.pixeldroid.ui.login
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data validation state of the login form.
|
||||||
|
*/
|
||||||
|
data class LoginFormState(
|
||||||
|
val usernameError: Int? = null,
|
||||||
|
val passwordError: Int? = null,
|
||||||
|
val isDataValid: Boolean = false
|
||||||
|
)
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.h.pixeldroid.ui.login
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication result : success (user details) or error message.
|
||||||
|
*/
|
||||||
|
data class LoginResult(
|
||||||
|
val success: LoggedInUserView? = null,
|
||||||
|
val error: Int? = null
|
||||||
|
)
|
@ -0,0 +1,55 @@
|
|||||||
|
package com.h.pixeldroid.ui.login
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import android.util.Patterns
|
||||||
|
import com.h.pixeldroid.data.LoginRepository
|
||||||
|
import com.h.pixeldroid.data.Result
|
||||||
|
|
||||||
|
import com.h.pixeldroid.R
|
||||||
|
|
||||||
|
class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {
|
||||||
|
|
||||||
|
private val _loginForm = MutableLiveData<LoginFormState>()
|
||||||
|
val loginFormState: LiveData<LoginFormState> = _loginForm
|
||||||
|
|
||||||
|
private val _loginResult = MutableLiveData<LoginResult>()
|
||||||
|
val loginResult: LiveData<LoginResult> = _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
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package com.h.pixeldroid.ui.login
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.h.pixeldroid.data.LoginDataSource
|
||||||
|
import com.h.pixeldroid.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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(LoginViewModel::class.java)) {
|
||||||
|
return LoginViewModel(
|
||||||
|
loginRepository = LoginRepository(
|
||||||
|
dataSource = LoginDataSource()
|
||||||
|
)
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
78
app/src/main/res/layout/activity_login.xml
Normal file
78
app/src/main/res/layout/activity_login.xml
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||||
|
android:paddingTop="@dimen/activity_vertical_margin"
|
||||||
|
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||||
|
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||||
|
tools:context=".ui.login.LoginActivity">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/username"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_marginTop="96dp"
|
||||||
|
android:layout_marginEnd="24dp"
|
||||||
|
|
||||||
|
android:hint="@string/prompt_email"
|
||||||
|
android:inputType="textEmailAddress"
|
||||||
|
android:selectAllOnFocus="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/password"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="24dp"
|
||||||
|
|
||||||
|
android:hint="@string/prompt_password"
|
||||||
|
android:imeActionLabel="@string/action_sign_in_short"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:selectAllOnFocus="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/username" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/login"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="start"
|
||||||
|
android:layout_marginStart="48dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="48dp"
|
||||||
|
android:layout_marginBottom="64dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/action_sign_in"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/password"
|
||||||
|
app:layout_constraintVertical_bias="0.2" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/loading"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginStart="32dp"
|
||||||
|
android:layout_marginTop="64dp"
|
||||||
|
android:layout_marginEnd="32dp"
|
||||||
|
android:layout_marginBottom="64dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="@+id/password"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/password"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintVertical_bias="0.3" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
5
app/src/main/res/values/dimens.xml
Normal file
5
app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||||
|
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||||
|
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||||
|
</resources>
|
@ -1,3 +1,12 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">PixelDroid</string>
|
<string name="app_name">PixelDroid</string>
|
||||||
|
<string name="title_activity_login">Sign in</string>
|
||||||
|
<string name="prompt_email">Email</string>
|
||||||
|
<string name="prompt_password">Password</string>
|
||||||
|
<string name="action_sign_in">Sign in or register</string>
|
||||||
|
<string name="action_sign_in_short">Sign in</string>
|
||||||
|
<string name="welcome">"Welcome !"</string>
|
||||||
|
<string name="invalid_username">Not a valid username</string>
|
||||||
|
<string name="invalid_password">Password must be >5 characters</string>
|
||||||
|
<string name="login_failed">"Login failed"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user