diff --git a/api/src/models/error.rs b/api/src/models/error.rs index 8e232ec..a2081dd 100644 --- a/api/src/models/error.rs +++ b/api/src/models/error.rs @@ -61,6 +61,10 @@ impl AppError { Self::new(ErrorKind::NoSessionFound) } + pub fn no_user_found() -> Self { + Self::new(ErrorKind::NoUserFound) + } + pub fn token_key() -> Self { Self::new(ErrorKind::TokenKey) } @@ -149,6 +153,7 @@ impl Display for AppError { ), ErrorKind::NoDbRecordFound => write!(f, "No database record found"), ErrorKind::NoSessionFound => write!(f, "No session found"), + ErrorKind::NoUserFound => write!(f, "No user found for that email address"), ErrorKind::Sqlx(err) => write!(f, "{err}"), ErrorKind::TokenKey => write!( f, @@ -175,6 +180,7 @@ enum ErrorKind { MissingAuthorizationToken, NoDbRecordFound, NoSessionFound, + NoUserFound, Sqlx(SqlxError), TokenKey, } @@ -196,7 +202,7 @@ impl IntoResponse for AppError { StatusCode::UNAUTHORIZED, ApiResponse::new_with_error(self).into_json_response(), ), - &ErrorKind::NoDbRecordFound => ( + &ErrorKind::NoDbRecordFound | &ErrorKind::NoUserFound => ( StatusCode::NOT_FOUND, ApiResponse::new_with_error(self).into_json_response(), ), diff --git a/api/src/requests/auth/login/handler.rs b/api/src/requests/auth/login/handler.rs index cb310b4..8fd6be1 100644 --- a/api/src/requests/auth/login/handler.rs +++ b/api/src/requests/auth/login/handler.rs @@ -52,7 +52,7 @@ async fn auth_login_request( .await .map_err(|err| { if err.is_no_record_found() { - AppError::invalid_credentials() + AppError::no_user_found() } else { err } diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreen.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreen.kt index c28e20b..6c9e666 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreen.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreen.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.widget.Toast import androidx.activity.ComponentActivity import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -97,10 +98,13 @@ fun LoginScreen( .padding(16.dp) ) { val emailAddress = viewModel.emailAddress.collectAsState() + val isValidEmailAddress = viewModel.isValidEmailAddress.collectAsState(true) + val password = viewModel.password.collectAsState() + val isValidPassword = viewModel.isValidPassword.collectAsState(true) LoginComponent( - emailAddress = emailAddress, + emailAddress, onUpdateEmailAddress = { viewModel.onAction( LoginScreenStateAction.UpdateEmailAddress( @@ -108,7 +112,8 @@ fun LoginScreen( ) ) }, - password = password, + isValidEmailAddress, + password, onUpdatePassword = { viewModel.onAction( LoginScreenStateAction.UpdatePassword( @@ -116,6 +121,7 @@ fun LoginScreen( ) ) }, + isValidPassword, submitLoginRequest = { viewModel.onAction( LoginScreenStateAction.ValidateCredentials( @@ -155,8 +161,10 @@ private fun LoginScreenTopAppBar(modifier: Modifier = Modifier) { private fun LoginComponent( emailAddress: State, onUpdateEmailAddress: (String) -> Unit, + isValidEmailAddress: State, password: State, onUpdatePassword: (String) -> Unit, + isValidPassword: State, submitLoginRequest: () -> Unit, modifier: Modifier = Modifier ) { @@ -173,6 +181,7 @@ private fun LoginComponent( imeAction = ImeAction.Next ), onValueChange = onUpdateEmailAddress, + isError = !isValidEmailAddress.value, modifier = Modifier.fillMaxWidth() ) @@ -197,6 +206,7 @@ private fun LoginComponent( onSend = { submitLoginRequest() } ), onValueChange = onUpdatePassword, + isError = !isValidPassword.value, modifier = Modifier .fillMaxWidth() .padding(PaddingValues(top = 4.dp)) diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreenViewModel.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreenViewModel.kt index 7298fcf..57ec043 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreenViewModel.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/presentation/login/LoginScreenViewModel.kt @@ -1,12 +1,14 @@ package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.InvalidCredentialsException import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.LoginCredentialsValidationResult import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.SubmitLoginCredentialsUseCase +import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.UserNotFoundException import ing.bikeshedengineer.debtpirate.app.screen.auth.usecase.ValidateLoginCredentialsUseCase -import ing.bikeshedengineer.debtpirate.domain.repository.InvalidCredentialsException import ing.bikeshedengineer.debtpirate.domain.usecase.UpdateStoreDataUseCase import ing.bikeshedengineer.debtpirate.navigation.Destination import ing.bikeshedengineer.debtpirate.navigation.Navigator @@ -14,6 +16,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,11 +28,19 @@ class LoginScreenViewModel @Inject constructor( private val validateLoginCredentials: ValidateLoginCredentialsUseCase, private val updateStoreData: UpdateStoreDataUseCase ) : ViewModel() { + private val _isEmailAddressPristine = MutableStateFlow(true) private val _emailAddress = MutableStateFlow("") val emailAddress = _emailAddress.asStateFlow() + val isValidEmailAddress = _isEmailAddressPristine.combine(_emailAddress.map { it.isNotBlank() }) { isPristine, isValid -> + isPristine || isValid + } + private val _isPasswordPristine = MutableStateFlow(true) private val _password = MutableStateFlow("") val password = _password.asStateFlow() + val isValidPassword = _isPasswordPristine.combine(_password.map { it.isNotBlank() }) { isPristine, isValid -> + isPristine || isValid + } private val _toastMessages = MutableSharedFlow() val toastMessages = _toastMessages.asSharedFlow() @@ -37,10 +49,12 @@ class LoginScreenViewModel @Inject constructor( when (action) { is LoginScreenStateAction.UpdateEmailAddress -> { _emailAddress.value = action.emailAddress + _isEmailAddressPristine.value = false } is LoginScreenStateAction.UpdatePassword -> { _password.value = action.password + _isPasswordPristine.value = false } is LoginScreenStateAction.ValidateCredentials -> { @@ -58,13 +72,18 @@ class LoginScreenViewModel @Inject constructor( } private fun onValidateLoginCredentials(emailAddress: String, password: String) { - when (validateLoginCredentials(emailAddress, password)) { + _isEmailAddressPristine.value = false + _isPasswordPristine.value = false + + val validationResult = validateLoginCredentials(emailAddress, password) + when (validationResult) { is LoginCredentialsValidationResult.ValidCredentials -> { onAction(LoginScreenStateAction.SubmitLoginRequest(emailAddress, password)) } else -> { // TODO... + Log.d("LoginScreenViewModel", "Invalid credentials: $validationResult") } } @@ -86,6 +105,10 @@ class LoginScreenViewModel @Inject constructor( _toastMessages.emit("Invalid Email Address or Password") } + is UserNotFoundException -> { + _toastMessages.emit("User Not Found") + } + else -> { _toastMessages.emit("Cannot Login, Please Try Again Later") } diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/SubmitLoginCredentialsUseCase.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/SubmitLoginCredentialsUseCase.kt index 42bd34b..0dd8f9a 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/SubmitLoginCredentialsUseCase.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/SubmitLoginCredentialsUseCase.kt @@ -4,12 +4,26 @@ import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostReque import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostResponse import ing.bikeshedengineer.debtpirate.data.repository.AuthRepository +class InvalidCredentialsException() : Exception("Invalid email address or password") +class UserNotFoundException() : Exception("User account not found") class SubmitLoginCredentialsUseCase( private val authRepository: AuthRepository ) { suspend operator fun invoke(emailAddress: String, password: String): AuthLoginPostResponse { val credentials = AuthLoginPostRequest(emailAddress, password) - return authRepository.submitAuthLoginRequest(credentials) + val response = authRepository.submitAuthLoginRequest(credentials) + + if (response.isSuccessful) { + val body = response.body() + return body!!.data!! + } else { + val statusCode = response.code() + if (statusCode == 404) { + throw UserNotFoundException() + } else { + throw InvalidCredentialsException() + } + } } } \ No newline at end of file diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/ValidateLoginCredentialsUseCase.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/ValidateLoginCredentialsUseCase.kt index 467ce5b..2c655a3 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/ValidateLoginCredentialsUseCase.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/app/screen/auth/usecase/ValidateLoginCredentialsUseCase.kt @@ -1,10 +1,10 @@ package ing.bikeshedengineer.debtpirate.app.screen.auth.usecase sealed class LoginCredentialsValidationResult { - object EmptyCredentials : LoginCredentialsValidationResult() - object EmptyEmailAddressField : LoginCredentialsValidationResult() - object EmptyPasswordField : LoginCredentialsValidationResult() - object ValidCredentials : LoginCredentialsValidationResult() + data object EmptyCredentials : LoginCredentialsValidationResult() + data object EmptyEmailAddressField : LoginCredentialsValidationResult() + data object EmptyPasswordField : LoginCredentialsValidationResult() + data object ValidCredentials : LoginCredentialsValidationResult() } class ValidateLoginCredentialsUseCase { diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/data/repository/AuthRepository.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/data/repository/AuthRepository.kt index c747019..c851a3e 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/data/repository/AuthRepository.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/data/repository/AuthRepository.kt @@ -1,9 +1,10 @@ package ing.bikeshedengineer.debtpirate.data.repository +import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostRequest import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostResponse - +import retrofit2.Response interface AuthRepository { - suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): AuthLoginPostResponse + suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): Response> } \ No newline at end of file diff --git a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/domain/repository/AuthRepositoryImpl.kt b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/domain/repository/AuthRepositoryImpl.kt index 54ab5c7..e8faef5 100644 --- a/app/app/src/main/java/ing/bikeshedengineer/debtpirate/domain/repository/AuthRepositoryImpl.kt +++ b/app/app/src/main/java/ing/bikeshedengineer/debtpirate/domain/repository/AuthRepositoryImpl.kt @@ -1,7 +1,5 @@ package ing.bikeshedengineer.debtpirate.domain.repository -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse import ing.bikeshedengineer.debtpirate.data.remote.endpoint.AuthEndpoint import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostRequest @@ -9,28 +7,16 @@ import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostRespo import ing.bikeshedengineer.debtpirate.data.repository.AuthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import retrofit2.Response import retrofit2.Retrofit -class InvalidCredentialsException(): Exception("Invalid email address or password") - class AuthRepositoryImpl(private val httpClient: Retrofit) : AuthRepository { private val authEndpoint: AuthEndpoint = this.httpClient.create(AuthEndpoint::class.java) - override suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): AuthLoginPostResponse { + override suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): Response> { return withContext(Dispatchers.IO) { val response = authEndpoint.submitAuthLoginRequest(credentials) - - if (response.isSuccessful) { - val body = response.body() - return@withContext body!!.data!! - } else { - val gson = Gson() - val errorType = object : TypeToken>() {}.type - val body = - gson.fromJson>(response.errorBody()!!.charStream(), errorType) - - throw InvalidCredentialsException() - } + return@withContext response } } } \ No newline at end of file diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index f928cbb..94287f7 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activityCompose = "1.9.3" -agp = "8.7.2" +agp = "8.7.3" appcompat = "1.7.0" composeBom = "2024.11.00" coreKtx = "1.15.0"