From c125a47700b244498693828530b7db67540a297b Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Sun, 19 Oct 2025 11:02:25 +0100 Subject: [PATCH 1/8] feat(auth_registration): Implement user registration screen and logic - Add a new `RegisterScreen` Composable for the user registration UI. - Create `RegisterViewModel` to manage the registration state (`RegisterUiState`) and handle user interactions via a `RegisterAction` sealed interface. - Extend the `AuthRepository` with a `register` function to support the new registration flow. - Refactor the annotated string for the sign-up/log-in link to be reusable across both `LoginScreen` and `RegisterScreen`, dynamically changing its text. - Move the `LoginRequest` data class into a `dto` subpackage for better code organization. - Add a new string resource for the registration screen's greeting. --- .../main/java/com/dmd/tasky/MainActivity.kt | 4 +- .../data/remote/{ => dto}/LoginRequest.kt | 3 +- .../feature/auth/domain/AuthRepository.kt | 4 +- .../auth/presentation/login/LoginScreen.kt | 47 +++- .../presentation/register/RegisterAction.kt | 9 + .../presentation/register/RegisterScreen.kt | 213 ++++++++++++++++++ .../register/RegisterViewModel.kt | 85 +++++++ features/auth/src/main/res/values/strings.xml | 2 + 8 files changed, 354 insertions(+), 13 deletions(-) rename features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/{ => dto}/LoginRequest.kt (72%) create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt diff --git a/app/src/main/java/com/dmd/tasky/MainActivity.kt b/app/src/main/java/com/dmd/tasky/MainActivity.kt index 8ac7db2..7b75d4b 100644 --- a/app/src/main/java/com/dmd/tasky/MainActivity.kt +++ b/app/src/main/java/com/dmd/tasky/MainActivity.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import com.dmd.tasky.feature.auth.presentation.login.TaskyLoginScreen +import com.dmd.tasky.feature.auth.presentation.register.TaskyRegisterContent +import com.dmd.tasky.feature.auth.presentation.register.TaskyRegisterScreen import com.dmd.tasky.ui.theme.TaskyTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +22,7 @@ class MainActivity : ComponentActivity() { setContent { TaskyTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - TaskyLoginScreen( + TaskyRegisterScreen( modifier = Modifier.padding(innerPadding), ) } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/LoginRequest.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/LoginRequest.kt similarity index 72% rename from features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/LoginRequest.kt rename to features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/LoginRequest.kt index 57aba5a..9a10652 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/LoginRequest.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/LoginRequest.kt @@ -1,5 +1,4 @@ -package com.dmd.tasky.feature.auth.data.remote - +package com.dmd.tasky.feature.auth.data.remote.dto import kotlinx.serialization.Serializable diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/AuthRepository.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/AuthRepository.kt index 8e55b53..d6a7a2e 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/AuthRepository.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/AuthRepository.kt @@ -1,7 +1,9 @@ package com.dmd.tasky.feature.auth.domain import com.dmd.tasky.feature.auth.domain.model.LoginResult +import com.dmd.tasky.feature.auth.domain.model.RegisterResult interface AuthRepository { suspend fun login(email: String, password: String): LoginResult -} \ No newline at end of file + suspend fun register(fullName: String, email: String, password: String): RegisterResult +} diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt index 9d3476c..6bbda78 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt @@ -1,13 +1,27 @@ package com.dmd.tasky.feature.auth.presentation.login import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -30,6 +44,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.dmd.tasky.feature.auth.R +import com.dmd.tasky.feature.auth.presentation.register.RegisterUiState @Composable fun TaskyLoginScreen( @@ -51,7 +66,7 @@ fun TaskyLoginContent( modifier: Modifier = Modifier ) { var passwordVisible by remember { mutableStateOf(false) } - + Scaffold { paddingValues -> Column( modifier = modifier @@ -129,7 +144,10 @@ fun TaskyLoginContent( .align(Alignment.CenterHorizontally) ) BasicText( - text = annotatedString(onAction(LoginAction.SignUpClicked)), + text = annotatedString( + onAction(LoginAction.SignUpClicked), + state.javaClass.name + ), style = MaterialTheme.typography.labelSmall, modifier = Modifier .padding(top = 20.dp) @@ -156,7 +174,7 @@ fun LoginInputField( value = value, label = { Text(text) }, maxLines = 1, - visualTransformation = hidePassword?: VisualTransformation.None, + visualTransformation = hidePassword ?: VisualTransformation.None, onValueChange = onValueChange, trailingIcon = trailingIcon, modifier = modifier @@ -187,12 +205,18 @@ fun LoginButton( } -fun annotatedString(onAction: Unit) = buildAnnotatedString { - append("DON’T HAVE AN ACCOUNT? ") +fun annotatedString(onAction: Unit, state: String) = buildAnnotatedString { + if (state == LoginUiState::class.qualifiedName) { + append("DON’T HAVE AN ACCOUNT? ") + } + if (state == RegisterUiState::class.qualifiedName) { + append("ALREADY HAVE AN ACCOUNT? ") + } + val signUp = LinkAnnotation.Clickable( tag = "SIGN UP", - linkInteractionListener = {onAction} + linkInteractionListener = { onAction } ) pushLink(signUp) @@ -202,7 +226,12 @@ fun annotatedString(onAction: Unit) = buildAnnotatedString { fontStyle = FontStyle.Italic ) ) { - append("SIGN UP") + if (state == LoginUiState::class.qualifiedName) { + append("SIGN UP") + } + if (state == RegisterUiState::class.qualifiedName) { + append("LOG IN") + } } pop() } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt new file mode 100644 index 0000000..868e046 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt @@ -0,0 +1,9 @@ +package com.dmd.tasky.feature.auth.presentation.register + +sealed interface RegisterAction { + data class FullNameChanged(val fullName: String) : RegisterAction + data class EmailChanged(val email: String) : RegisterAction + data class PasswordChanged(val password: String) : RegisterAction + data object RegisterClicked : RegisterAction + data object LoginClicked : RegisterAction +} diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt new file mode 100644 index 0000000..c20aaf2 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -0,0 +1,213 @@ +package com.dmd.tasky.feature.auth.presentation.register + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.dmd.tasky.feature.auth.R +import com.dmd.tasky.feature.auth.presentation.login.LoginInputField +import com.dmd.tasky.feature.auth.presentation.login.annotatedString + + +@Composable +fun TaskyRegisterScreen( + modifier: Modifier = Modifier, + viewModel: RegisterViewModel = hiltViewModel(), +) { + val registerUiState = viewModel.state + TaskyRegisterContent( + state = registerUiState, + onAction = viewModel::onAction, + modifier = modifier + ) +} + +@Composable +fun TaskyRegisterContent( + state: RegisterUiState, + onAction: (RegisterAction) -> Unit, + modifier: Modifier = Modifier, +) { + + var passwordVisible by remember { mutableStateOf(false) } + + Scaffold { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .background(color = Color.Black), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.register_greeting), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(top = 70.dp) + .align(Alignment.CenterHorizontally), + color = Color.White + ) + Spacer(modifier = Modifier.height(36.dp)) + + Card( + modifier = Modifier + .fillMaxSize(), + shape = RoundedCornerShape( + topStart = 25.dp, + topEnd = 25.dp, + ), + ) { + LoginInputField( + text = "Full Name", + value = state.fullName, + onValueChange = { onAction(RegisterAction.FullNameChanged(it)) }, + modifier = Modifier + .padding( + top = 28.dp, + start = 16.dp, + end = 16.dp + ) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + hidePassword = null, + trailingIcon = null + ) + LoginInputField( + text = "Email", + value = state.email, + onValueChange = { onAction(RegisterAction.EmailChanged(it)) }, + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp + ) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + hidePassword = null, + trailingIcon = null + ) + + LoginInputField( + text = "Password", + value = state.password, + onValueChange = { onAction(RegisterAction.PasswordChanged(it)) }, + hidePassword = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp + ) + .fillMaxWidth() + .align(Alignment.CenterHorizontally), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + ) + Button( + onClick = { onAction(RegisterAction.RegisterClicked) }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("REGISTER") + } + } + +// LoginButton( +// text = "GET STARTED", +// onClick = { onAction(RegisterAction.RegisterClicked) }, +// modifier = Modifier +// .padding( +// top = 32.dp, +// start = 16.dp, +// end = 16.dp +// ) +// .fillMaxWidth() +// .align(Alignment.CenterHorizontally) +// ) + + BasicText( + text = annotatedString( + onAction(RegisterAction.LoginClicked), + state.javaClass.name + ), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .padding(top = 20.dp) + .align(Alignment.CenterHorizontally), + ) + + if (state.error != null) { + Text( + text = "Error: ${state.error}", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + if (state.registrationSuccess) { + Text( + text = "Registration Successful!", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + } + } +} + + +@Preview(showBackground = false, backgroundColor = 0XFF16161C) +@Composable +fun TaskyRegisterContentPreview() { + TaskyRegisterContent( + state = RegisterUiState(), + onAction = {} + ) +} \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt new file mode 100644 index 0000000..8451c38 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt @@ -0,0 +1,85 @@ +package com.dmd.tasky.feature.auth.presentation.register + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dmd.tasky.feature.auth.domain.AuthRepository +import com.dmd.tasky.feature.auth.domain.model.RegisterResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class RegisterViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + var state by mutableStateOf(RegisterUiState()) + private set + + fun onAction(action: RegisterAction) { + when (action) { + is RegisterAction.FullNameChanged -> { + state = state.copy(fullName = action.fullName) + } + is RegisterAction.EmailChanged -> { + state = state.copy(email = action.email) + } + is RegisterAction.PasswordChanged -> { + state = state.copy(password = action.password) + } + is RegisterAction.RegisterClicked -> { + register() + } + is RegisterAction.LoginClicked -> { + TODO("Navigate to login") + } + } + } + + private fun register() { + viewModelScope.launch { + state = state.copy(isLoading = true, error = null) + + Timber.d("📝 Starting registration...") + Timber.d(" Full Name: '${state.fullName}' (length: ${state.fullName.length})") + Timber.d(" Email: '${state.email}'") + Timber.d(" Password: '${state.password}' (length: ${state.password.length})") + + val result = authRepository.register( + fullName = state.fullName, + email = state.email, + password = state.password + ) + + state = state.copy(isLoading = false) + + when (result) { + is RegisterResult.Success -> { + Timber.d("Registration successful in ViewModel!") + state = state.copy(registrationSuccess = true) + } + is RegisterResult.Error -> { + Timber.e("Registration error: ${result.message}") + state = state.copy(error = result.message) + } + is RegisterResult.UserAlreadyExists -> { + Timber.d("User already exists") + state = state.copy(error = "This email is already registered. Try logging in?") + } + } + } + } +} + +data class RegisterUiState( + val fullName: String = "", + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val registrationSuccess: Boolean = false +) diff --git a/features/auth/src/main/res/values/strings.xml b/features/auth/src/main/res/values/strings.xml index e160cbd..081dc23 100644 --- a/features/auth/src/main/res/values/strings.xml +++ b/features/auth/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ Welcome Back! DON’T HAVE AN ACCOUNT? SIGN UP + + Create your account \ No newline at end of file From 22dd8c07aec11c6196092db08fb81b5914419cf9 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Tue, 21 Oct 2025 23:02:54 +0100 Subject: [PATCH 2/8] feat(auth): Implement user registration and add tests - Implement the user registration feature, including the repository layer, API endpoint, and domain models. - Add a `register` function to `DefaultAuthRepository` with comprehensive error handling for cases like existing users (HTTP 409) and validation errors (HTTP 400). - Introduce a `RegisterResult` sealed class to model the different outcomes of the registration process. - Add a `register` endpoint to the `AuthApi` interface and a corresponding `RegisterRequest` DTO. - Create unit tests for `RegisterViewModel` using a `FakeAuthRepository` to verify state changes for success, user already exists, and error scenarios. - Relocate `LoginViewModelTest` to a new `login` subpackage and update it to use the `LoginAction` sealed interface, aligning with MVI patterns. --- .../tasky/feature/auth/data/remote/AuthApi.kt | 8 +- .../auth/data/remote/dto/RegisterRequest.kt | 10 ++ .../data/repository/DefaultAuthRepository.kt | 57 +++++++++- .../auth/domain/model/RegisterResult.kt | 7 ++ .../auth/src/test/java/FakeAuthRepository.kt | 25 +++++ .../{ => login}/LoginViewModelTest.kt | 34 +++--- .../register/RegisterViewModelTest.kt | 104 ++++++++++++++++++ 7 files changed, 226 insertions(+), 19 deletions(-) create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/RegisterRequest.kt create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt create mode 100644 features/auth/src/test/java/FakeAuthRepository.kt rename features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/{ => login}/LoginViewModelTest.kt (59%) create mode 100644 features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/AuthApi.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/AuthApi.kt index 0f359e2..c58a9c0 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/AuthApi.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/AuthApi.kt @@ -1,9 +1,15 @@ package com.dmd.tasky.feature.auth.data.remote +import com.dmd.tasky.feature.auth.data.remote.dto.LoginRequest +import com.dmd.tasky.feature.auth.data.remote.dto.RegisterRequest +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST interface AuthApi { @POST("auth/login") suspend fun login(@Body request: LoginRequest): AuthResponse -} \ No newline at end of file + + @POST("auth/register") + suspend fun register(@Body request: RegisterRequest): Response +} diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/RegisterRequest.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/RegisterRequest.kt new file mode 100644 index 0000000..107d742 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/remote/dto/RegisterRequest.kt @@ -0,0 +1,10 @@ +package com.dmd.tasky.feature.auth.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + val fullName: String, + val email: String, + val password: String +) diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt index e1712f4..f8a8aa5 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt @@ -1,10 +1,14 @@ package com.dmd.tasky.feature.auth.data.repository import com.dmd.tasky.feature.auth.data.remote.AuthApi -import com.dmd.tasky.feature.auth.data.remote.LoginRequest +import com.dmd.tasky.feature.auth.data.remote.dto.LoginRequest +import com.dmd.tasky.feature.auth.data.remote.dto.RegisterRequest import com.dmd.tasky.feature.auth.domain.AuthRepository import com.dmd.tasky.feature.auth.domain.model.LoginResult +import com.dmd.tasky.feature.auth.domain.model.RegisterResult import kotlinx.coroutines.CancellationException +import retrofit2.HttpException +import timber.log.Timber class DefaultAuthRepository(private val api: AuthApi) : AuthRepository { override suspend fun login(email: String, password: String): LoginResult { @@ -16,4 +20,53 @@ class DefaultAuthRepository(private val api: AuthApi) : AuthRepository { LoginResult.Error(e.message ?: "Unknown error") } } -} \ No newline at end of file + + override suspend fun register( + fullName: String, + email: String, + password: String + ): RegisterResult { + return try { + Timber.d("Attempting register with: name='$fullName', email='$email'") + + val response = api.register( + RegisterRequest( + fullName = fullName, + email = email, + password = password + ) + ) + + Timber.d("Register successful! Status code: ${response.code()}") + RegisterResult.Success + + } catch (e: HttpException) { + val code = e.code() + val errorBody = e.response()?.errorBody()?.string() + + Timber.e("HTTP Error: Code=$code, Body=$errorBody") + + when (code) { + 409 -> { + Timber.d("409 Conflict - User already exists") + RegisterResult.UserAlreadyExists + } + 400 -> { + Timber.d("400 Bad Request - Validation error: $errorBody") + RegisterResult.Error(errorBody ?: "Validation failed") + } + else -> { + Timber.d("Unexpected error code: $code") + RegisterResult.Error(errorBody ?: "Unknown error") + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + + Timber.e("Exception during register: ${e.javaClass.simpleName}: ${e.message}") + e.printStackTrace() + + RegisterResult.Error(e.message ?: "Unknown error") + } + } +} diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt new file mode 100644 index 0000000..848f370 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt @@ -0,0 +1,7 @@ +package com.dmd.tasky.feature.auth.domain.model + +sealed class RegisterResult { + object Success : RegisterResult() + data class Error(val message: String) : RegisterResult() + object UserAlreadyExists : RegisterResult() +} \ No newline at end of file diff --git a/features/auth/src/test/java/FakeAuthRepository.kt b/features/auth/src/test/java/FakeAuthRepository.kt new file mode 100644 index 0000000..3a8632b --- /dev/null +++ b/features/auth/src/test/java/FakeAuthRepository.kt @@ -0,0 +1,25 @@ +import com.dmd.tasky.feature.auth.domain.AuthRepository +import com.dmd.tasky.feature.auth.domain.model.LoginResult +import com.dmd.tasky.feature.auth.domain.model.RegisterResult +import kotlinx.coroutines.delay + +class FakeAuthRepository : AuthRepository { + + var registerResult: RegisterResult = RegisterResult.Success + + override suspend fun register( + fullName: String, + email: String, + password: String + ): RegisterResult { + delay(500) + return registerResult + } + + override suspend fun login( + email: String, + password: String + ): LoginResult { + throw UnsupportedOperationException("Not needed for register tests") + } +} \ No newline at end of file diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/LoginViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt similarity index 59% rename from features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/LoginViewModelTest.kt rename to features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt index 55ad027..b08f1cb 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/LoginViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt @@ -1,13 +1,13 @@ -package com.dmd.tasky.feature.auth.presentation +package com.dmd.tasky.feature.auth.presentation.login import com.dmd.tasky.feature.auth.domain.AuthRepository import com.dmd.tasky.feature.auth.domain.model.LoginResult -import com.dmd.tasky.feature.auth.presentation.login.LoginViewModel +import com.dmd.tasky.feature.auth.presentation.MainCoroutineRule import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals +import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test @@ -15,6 +15,8 @@ import org.junit.Test @ExperimentalCoroutinesApi class LoginViewModelTest { + // This is with Mocks + @get:Rule val mainCoroutineRule = MainCoroutineRule() @@ -30,15 +32,15 @@ class LoginViewModelTest { @Test fun `onEmailChanged should update email`() { val email = "test@test.com" - loginViewModel.onEmailChanged(email) - assertEquals(email, loginViewModel.state.email) + loginViewModel.onAction(LoginAction.EmailChanged(email)) + Assert.assertEquals(email, loginViewModel.state.email) } @Test fun `onPasswordChanged should update password`() { val password = "password" - loginViewModel.onPasswordChanged(password) - assertEquals(password, loginViewModel.state.password) + loginViewModel.onAction(LoginAction.PasswordChanged(password)) + Assert.assertEquals(password, loginViewModel.state.password) } @Test @@ -46,10 +48,10 @@ class LoginViewModelTest { val token = "token" coEvery { authRepository.login(any(), any()) } returns LoginResult.Success(token) - loginViewModel.onLoginClicked() + loginViewModel.onAction(LoginAction.LoginClicked) - assertEquals(null, loginViewModel.state.error) - assertEquals(false, loginViewModel.state.isLoading) + Assert.assertEquals(null, loginViewModel.state.error) + Assert.assertEquals(false, loginViewModel.state.isLoading) } @@ -58,19 +60,19 @@ class LoginViewModelTest { val errorMessage = "error" coEvery { authRepository.login(any(), any()) } returns LoginResult.Error(errorMessage) - loginViewModel.onLoginClicked() + loginViewModel.onAction(LoginAction.LoginClicked) - assertEquals(false, loginViewModel.state.isLoading) - assertEquals(errorMessage, loginViewModel.state.error) + Assert.assertEquals(false, loginViewModel.state.isLoading) + Assert.assertEquals(errorMessage, loginViewModel.state.error) } @Test fun `login with invalid credentials should update state correctly`() = runTest { coEvery { authRepository.login(any(), any()) } returns LoginResult.InvalidCredentials - loginViewModel.onLoginClicked() + loginViewModel.onAction(LoginAction.LoginClicked) - assertEquals(false, loginViewModel.state.isLoading) - assertEquals("Invalid credentials", loginViewModel.state.error) + Assert.assertEquals(false, loginViewModel.state.isLoading) + Assert.assertEquals("Invalid credentials", loginViewModel.state.error) } } \ No newline at end of file diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt new file mode 100644 index 0000000..4a21656 --- /dev/null +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -0,0 +1,104 @@ +package com.dmd.tasky.feature.auth.presentation.register + +import FakeAuthRepository +import com.dmd.tasky.feature.auth.domain.model.RegisterResult +import com.dmd.tasky.feature.auth.presentation.MainCoroutineRule +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class RegisterViewModelTest { + + // This is with Fakes + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private lateinit var registerViewModel: RegisterViewModel + private lateinit var authRepository: FakeAuthRepository + + @Before + fun setUp() { + authRepository = FakeAuthRepository() + registerViewModel = RegisterViewModel(authRepository) + } + + @Test + fun `Initial state verification`() { + assert(registerViewModel.state == RegisterUiState()) + } + + @Test + fun `onAction FullNameChanged updates state`() { + registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) + assert(registerViewModel.state.fullName == "Test") + } + + @Test + fun `onAction EmailChanged updates state`() { + registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) + assert(registerViewModel.state.email == "Test@test.com") + } + + @Test + fun `onAction PasswordChanged updates state`() { + registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) + assert(registerViewModel.state.password == "Test") + + } + + @Test + fun `RegisterClicked success scenario`() = runTest { + authRepository.registerResult = RegisterResult.Success + registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) + registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) + registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) + registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) + + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + assertEquals(true, registerViewModel.state.registrationSuccess) + assertEquals(false, registerViewModel.state.isLoading) + assertEquals(null, registerViewModel.state.error) + + } + + + @Test + fun `RegisterClicked user already exists scenario`() = runTest { + authRepository.registerResult = RegisterResult.UserAlreadyExists + registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) + registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) + registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) + + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + assertEquals(false, registerViewModel.state.registrationSuccess) + assertEquals(false, registerViewModel.state.isLoading) + assertEquals("This email is already registered. Try logging in?", registerViewModel.state.error) + } + + @Test + fun `RegisterClicked generic error scenario`() = runTest { + + authRepository.registerResult = RegisterResult.Error("Test error") + + registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) + registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) + registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) + + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + + assertEquals(false, registerViewModel.state.registrationSuccess) + assertEquals(false, registerViewModel.state.isLoading) + assertEquals("Test error", registerViewModel.state.error) + } +} \ No newline at end of file From a00eac33579a7790582a199d9784a95716db8742 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Tue, 21 Oct 2025 23:02:54 +0100 Subject: [PATCH 3/8] feat(auth): Implement user registration and add tests - Implement the user registration feature, including the repository layer, API endpoint, and domain models. - Add a `register` function to `DefaultAuthRepository` with comprehensive error handling for cases like existing users (HTTP 409) and validation errors (HTTP 400). - Introduce a `RegisterResult` sealed class to model the different outcomes of the registration process. - Add a `register` endpoint to the `AuthApi` interface and a corresponding `RegisterRequest` DTO. - Create unit tests for `RegisterViewModel` using a `FakeAuthRepository` to verify state changes for success, user already exists, and error scenarios. - Relocate `LoginViewModelTest` to a new `login` subpackage and update it to use the `LoginAction` sealed interface, aligning with MVI patterns. - Add a new unit test to `RegisterViewModelTest` to verify that a previous registration error is cleared from the state when a new registration attempt is initiated. --- .../register/RegisterViewModelTest.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt index 4a21656..f45c885 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -81,7 +81,10 @@ class RegisterViewModelTest { advanceUntilIdle() assertEquals(false, registerViewModel.state.registrationSuccess) assertEquals(false, registerViewModel.state.isLoading) - assertEquals("This email is already registered. Try logging in?", registerViewModel.state.error) + assertEquals( + "This email is already registered. Try logging in?", + registerViewModel.state.error + ) } @Test @@ -101,4 +104,23 @@ class RegisterViewModelTest { assertEquals(false, registerViewModel.state.isLoading) assertEquals("Test error", registerViewModel.state.error) } + + @Test + fun `RegisterClicked clears previous error`() = runTest { + authRepository.registerResult = RegisterResult.Error("Test error") + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + assertEquals("Test error", registerViewModel.state.error) + + authRepository.registerResult = RegisterResult.Success + registerViewModel.onAction(RegisterAction.RegisterClicked) + + assertEquals(null, registerViewModel.state.error) + + advanceUntilIdle() + assertEquals(true, registerViewModel.state.registrationSuccess) + + + } } \ No newline at end of file From e3ab8c919637625a1646371589661e4eafc6e7c3 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Tue, 21 Oct 2025 23:02:54 +0100 Subject: [PATCH 4/8] feat(auth): Implement user registration and add tests - Implement the user registration feature, including the repository layer, API endpoint, and domain models. - Add a `register` function to `DefaultAuthRepository` with comprehensive error handling for cases like existing users (HTTP 409) and validation errors (HTTP 400). - Introduce a `RegisterResult` sealed class to model the different outcomes of the registration process. - Add a `register` endpoint to the `AuthApi` interface and a corresponding `RegisterRequest` DTO. - Create unit tests for `RegisterViewModel` using a `FakeAuthRepository` to verify state changes for success, user already exists, and error scenarios. - Relocate `LoginViewModelTest` to a new `login` subpackage and update it to use the `LoginAction` sealed interface, aligning with MVI patterns. - Add a new unit test to `RegisterViewModelTest` to verify that a previous registration error is cleared from the state when a new registration attempt is initiated. --- .../presentation/register/RegisterScreen.kt | 40 ++++++------------- .../register/RegisterViewModelTest.kt | 24 ++++++++++- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt index c20aaf2..9ded94f 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -8,15 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -38,6 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.dmd.tasky.feature.auth.R +import com.dmd.tasky.feature.auth.presentation.login.LoginButton import com.dmd.tasky.feature.auth.presentation.login.LoginInputField import com.dmd.tasky.feature.auth.presentation.login.annotatedString @@ -143,33 +141,19 @@ fun TaskyRegisterContent( } } ) - Button( + // TODO(Add enabled flag to button) + LoginButton( + text = "GET STARTED", onClick = { onAction(RegisterAction.RegisterClicked) }, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary + modifier = Modifier + .padding( + top = 32.dp, + start = 16.dp, + end = 16.dp ) - } else { - Text("REGISTER") - } - } - -// LoginButton( -// text = "GET STARTED", -// onClick = { onAction(RegisterAction.RegisterClicked) }, -// modifier = Modifier -// .padding( -// top = 32.dp, -// start = 16.dp, -// end = 16.dp -// ) -// .fillMaxWidth() -// .align(Alignment.CenterHorizontally) -// ) + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + ) BasicText( text = annotatedString( diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt index 4a21656..f45c885 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -81,7 +81,10 @@ class RegisterViewModelTest { advanceUntilIdle() assertEquals(false, registerViewModel.state.registrationSuccess) assertEquals(false, registerViewModel.state.isLoading) - assertEquals("This email is already registered. Try logging in?", registerViewModel.state.error) + assertEquals( + "This email is already registered. Try logging in?", + registerViewModel.state.error + ) } @Test @@ -101,4 +104,23 @@ class RegisterViewModelTest { assertEquals(false, registerViewModel.state.isLoading) assertEquals("Test error", registerViewModel.state.error) } + + @Test + fun `RegisterClicked clears previous error`() = runTest { + authRepository.registerResult = RegisterResult.Error("Test error") + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + assertEquals("Test error", registerViewModel.state.error) + + authRepository.registerResult = RegisterResult.Success + registerViewModel.onAction(RegisterAction.RegisterClicked) + + assertEquals(null, registerViewModel.state.error) + + advanceUntilIdle() + assertEquals(true, registerViewModel.state.registrationSuccess) + + + } } \ No newline at end of file From 1233eccc779f5ef8cbf6e31367f697dc41a4d8c5 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Fri, 24 Oct 2025 12:05:33 +0100 Subject: [PATCH 5/8] feat(auth): Implement password visibility toggle **Addressing comments on MR and some refactoring** - Move the password visibility state from a local `remember` variable in the `LoginScreen` and `RegisterScreen` Composables into their respective `UiState` classes (`LoginUiState`, `RegisterUiState`). - Introduce a `PasswordVisibilityChanged` action to both `LoginAction` and `RegisterAction` to handle the toggle event. - Update `LoginViewModel` and `RegisterViewModel` to process the new action and update the `passwordVisible` flag in the state. - Make the `TaskyLoginContent` and `TaskyRegisterContent` Composables private. - Enhance the `@Preview` functions for both login and registration screens to be interactive, allowing state changes for input fields and password visibility to be reflected in the preview. - Remove a duplicate line in `RegisterViewModelTest`. --- .../main/java/com/dmd/tasky/MainActivity.kt | 2 - .../auth/presentation/login/LoginAction.kt | 1 + .../auth/presentation/login/LoginScreen.kt | 34 +++++++++++---- .../auth/presentation/login/LoginViewModel.kt | 8 ++++ .../presentation/register/RegisterAction.kt | 1 + .../presentation/register/RegisterScreen.kt | 42 +++++++++++++++---- .../register/RegisterViewModel.kt | 18 ++++++-- .../register/RegisterViewModelTest.kt | 1 - 8 files changed, 83 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/dmd/tasky/MainActivity.kt b/app/src/main/java/com/dmd/tasky/MainActivity.kt index 7b75d4b..ec3f306 100644 --- a/app/src/main/java/com/dmd/tasky/MainActivity.kt +++ b/app/src/main/java/com/dmd/tasky/MainActivity.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import com.dmd.tasky.feature.auth.presentation.login.TaskyLoginScreen -import com.dmd.tasky.feature.auth.presentation.register.TaskyRegisterContent import com.dmd.tasky.feature.auth.presentation.register.TaskyRegisterScreen import com.dmd.tasky.ui.theme.TaskyTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginAction.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginAction.kt index 8446eac..5b81476 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginAction.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginAction.kt @@ -5,4 +5,5 @@ sealed interface LoginAction { data class PasswordChanged(val password: String) : LoginAction data object LoginClicked : LoginAction data object SignUpClicked : LoginAction + data object PasswordVisibilityChanged : LoginAction } \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt index 6bbda78..c944191 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt @@ -60,12 +60,11 @@ fun TaskyLoginScreen( } @Composable -fun TaskyLoginContent( +private fun TaskyLoginContent( state: LoginUiState, onAction: (LoginAction) -> Unit, modifier: Modifier = Modifier ) { - var passwordVisible by remember { mutableStateOf(false) } Scaffold { paddingValues -> Column( @@ -114,7 +113,7 @@ fun TaskyLoginContent( text = "Password", value = state.password, onValueChange = { onAction(LoginAction.PasswordChanged(it)) }, - hidePassword = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier .padding( start = 16.dp, @@ -123,10 +122,10 @@ fun TaskyLoginContent( .fillMaxWidth() .align(Alignment.CenterHorizontally), trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconButton(onClick = { onAction(LoginAction.PasswordVisibilityChanged) }) { Icon( - imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = if (passwordVisible) "Hide password" else "Show password" + imageVector = if (state.passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (state.passwordVisible) "Hide password" else "Show password" ) } } @@ -239,8 +238,27 @@ fun annotatedString(onAction: Unit, state: String) = buildAnnotatedString { @Preview(showBackground = false, backgroundColor = 0XFF16161C) @Composable fun TaskyLoginContentPreview() { + var state by remember { mutableStateOf(LoginUiState()) } + TaskyLoginContent( - state = LoginUiState(), - onAction = {} + state = state, + onAction = { action -> + when (action) { + is LoginAction.EmailChanged -> { + state = state.copy(email = action.email) + } + + is LoginAction.PasswordChanged -> { + state = state.copy(password = action.password) + } + + is LoginAction.PasswordVisibilityChanged -> { + state = state.copy(passwordVisible = !state.passwordVisible) + } + + is LoginAction.LoginClicked -> {} + is LoginAction.SignUpClicked -> {} + } + } ) } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt index 6309c38..6cd3b35 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt @@ -25,12 +25,19 @@ class LoginViewModel @Inject constructor( is LoginAction.EmailChanged -> { state = state.copy(email = action.email) } + is LoginAction.PasswordChanged -> { state = state.copy(password = action.password) } + + is LoginAction.PasswordVisibilityChanged -> { + state = state.copy(passwordVisible = !state.passwordVisible) + } + is LoginAction.LoginClicked -> { login() } + is LoginAction.SignUpClicked -> { } } @@ -66,5 +73,6 @@ data class LoginUiState( val email: String = "", val password: String = "", val isLoading: Boolean = false, + var passwordVisible: Boolean = false, val error: String? = null ) \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt index 868e046..d05f802 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt @@ -4,6 +4,7 @@ sealed interface RegisterAction { data class FullNameChanged(val fullName: String) : RegisterAction data class EmailChanged(val email: String) : RegisterAction data class PasswordChanged(val password: String) : RegisterAction + data object PasswordVisibilityChanged : RegisterAction data object RegisterClicked : RegisterAction data object LoginClicked : RegisterAction } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt index 9ded94f..8b999d7 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -46,6 +46,7 @@ fun TaskyRegisterScreen( viewModel: RegisterViewModel = hiltViewModel(), ) { val registerUiState = viewModel.state + TaskyRegisterContent( state = registerUiState, onAction = viewModel::onAction, @@ -54,14 +55,12 @@ fun TaskyRegisterScreen( } @Composable -fun TaskyRegisterContent( +private fun TaskyRegisterContent( state: RegisterUiState, onAction: (RegisterAction) -> Unit, modifier: Modifier = Modifier, ) { - var passwordVisible by remember { mutableStateOf(false) } - Scaffold { paddingValues -> Column( modifier = modifier @@ -124,7 +123,7 @@ fun TaskyRegisterContent( text = "Password", value = state.password, onValueChange = { onAction(RegisterAction.PasswordChanged(it)) }, - hidePassword = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), modifier = Modifier .padding( start = 16.dp, @@ -133,10 +132,10 @@ fun TaskyRegisterContent( .fillMaxWidth() .align(Alignment.CenterHorizontally), trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconButton(onClick = { onAction(RegisterAction.PasswordVisibilityChanged) }) { Icon( - imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = if (passwordVisible) "Hide password" else "Show password" + imageVector = if (state.passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (state.passwordVisible) "Hide password" else "Show password" ) } } @@ -190,8 +189,33 @@ fun TaskyRegisterContent( @Preview(showBackground = false, backgroundColor = 0XFF16161C) @Composable fun TaskyRegisterContentPreview() { + + var state by remember { mutableStateOf(RegisterUiState()) } + TaskyRegisterContent( - state = RegisterUiState(), - onAction = {} + state = state, + onAction = { action -> + when (action) { + is RegisterAction.FullNameChanged -> { + state = state.copy(fullName = action.fullName) + } + + is RegisterAction.EmailChanged -> { + state = state.copy(email = action.email) + } + + is RegisterAction.PasswordChanged -> { + state = state.copy(password = action.password) + } + + is RegisterAction.PasswordVisibilityChanged -> { + state = state.copy(passwordVisible = !state.passwordVisible) + } + + is RegisterAction.RegisterClicked -> {} + is RegisterAction.LoginClicked -> {} + + } + } ) } \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt index 8451c38..04f97c8 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt @@ -16,7 +16,6 @@ import javax.inject.Inject class RegisterViewModel @Inject constructor( private val authRepository: AuthRepository ) : ViewModel() { - var state by mutableStateOf(RegisterUiState()) private set @@ -25,15 +24,23 @@ class RegisterViewModel @Inject constructor( is RegisterAction.FullNameChanged -> { state = state.copy(fullName = action.fullName) } + is RegisterAction.EmailChanged -> { state = state.copy(email = action.email) } + is RegisterAction.PasswordChanged -> { state = state.copy(password = action.password) } + + is RegisterAction.PasswordVisibilityChanged -> { + state = state.copy(passwordVisible = !state.passwordVisible) + } + is RegisterAction.RegisterClicked -> { register() } + is RegisterAction.LoginClicked -> { TODO("Navigate to login") } @@ -43,12 +50,12 @@ class RegisterViewModel @Inject constructor( private fun register() { viewModelScope.launch { state = state.copy(isLoading = true, error = null) - + Timber.d("📝 Starting registration...") Timber.d(" Full Name: '${state.fullName}' (length: ${state.fullName.length})") Timber.d(" Email: '${state.email}'") Timber.d(" Password: '${state.password}' (length: ${state.password.length})") - + val result = authRepository.register( fullName = state.fullName, email = state.email, @@ -56,16 +63,18 @@ class RegisterViewModel @Inject constructor( ) state = state.copy(isLoading = false) - + when (result) { is RegisterResult.Success -> { Timber.d("Registration successful in ViewModel!") state = state.copy(registrationSuccess = true) } + is RegisterResult.Error -> { Timber.e("Registration error: ${result.message}") state = state.copy(error = result.message) } + is RegisterResult.UserAlreadyExists -> { Timber.d("User already exists") state = state.copy(error = "This email is already registered. Try logging in?") @@ -79,6 +88,7 @@ data class RegisterUiState( val fullName: String = "", val email: String = "", val password: String = "", + var passwordVisible: Boolean = false, val isLoading: Boolean = false, val error: String? = null, val registrationSuccess: Boolean = false diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt index f45c885..91f7ff0 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -56,7 +56,6 @@ class RegisterViewModelTest { fun `RegisterClicked success scenario`() = runTest { authRepository.registerResult = RegisterResult.Success registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) - registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) From 1f72627ba6b04dd7c624ef261a28a1fa1ff64b75 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Sun, 26 Oct 2025 09:58:59 +0000 Subject: [PATCH 6/8] refactor(auth): Standardize error handling with a generic Result class - Replace `LoginResult` and `RegisterResult` sealed classes with a generic `Result` type from a new `:core:domain:util` module. - Introduce a new `AuthError` sealed interface to represent specific authentication and network errors in a structured way (e.g., `INVALID_CREDENTIALS`, `USER_ALREADY_EXISTS`, `NO_INTERNET`). - Add a `toUiMessage()` extension function to map `AuthError` enums to user-friendly string messages. - Refactor `DefaultAuthRepository` to return the new `Result` type and provide more granular error handling for `HttpException`, `SocketTimeoutException`, and `IOException`. - Update `LoginViewModel` and `RegisterViewModel` to use `onSuccess` and `onError` helpers for processing the repository's `Result`, simplifying state updates. - Update unit tests for ViewModels and the `FakeAuthRepository` to align with the new error handling mechanism. --- features/auth/build.gradle.kts | 5 ++ .../data/repository/DefaultAuthRepository.kt | 62 +++++++++++-------- .../feature/auth/domain/model/AuthError.kt | 40 ++++++++++++ .../feature/auth/domain/model/LoginResult.kt | 7 --- .../auth/domain/model/RegisterResult.kt | 7 --- .../auth/presentation/login/LoginViewModel.kt | 28 ++++----- .../register/RegisterViewModel.kt | 27 +++----- .../auth/src/test/java/FakeAuthRepository.kt | 7 ++- .../presentation/login/LoginViewModelTest.kt | 22 +++++-- .../register/RegisterViewModelTest.kt | 24 ++++--- 10 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt delete mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/LoginResult.kt delete mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt diff --git a/features/auth/build.gradle.kts b/features/auth/build.gradle.kts index fae5d1a..e270d3f 100644 --- a/features/auth/build.gradle.kts +++ b/features/auth/build.gradle.kts @@ -59,6 +59,9 @@ android { dependencies { + // Modules + implementation(project(":core:domain:util")) + // Credential Manager implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) @@ -95,7 +98,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + debugImplementation(libs.androidx.ui.tooling) } \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt index f8a8aa5..d8c1dc2 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/data/repository/DefaultAuthRepository.kt @@ -1,23 +1,43 @@ package com.dmd.tasky.feature.auth.data.repository +import com.dmd.tasky.core.domain.util.Result import com.dmd.tasky.feature.auth.data.remote.AuthApi import com.dmd.tasky.feature.auth.data.remote.dto.LoginRequest import com.dmd.tasky.feature.auth.data.remote.dto.RegisterRequest import com.dmd.tasky.feature.auth.domain.AuthRepository +import com.dmd.tasky.feature.auth.domain.model.AuthError import com.dmd.tasky.feature.auth.domain.model.LoginResult import com.dmd.tasky.feature.auth.domain.model.RegisterResult import kotlinx.coroutines.CancellationException import retrofit2.HttpException import timber.log.Timber +import java.io.IOException +import java.net.SocketTimeoutException class DefaultAuthRepository(private val api: AuthApi) : AuthRepository { override suspend fun login(email: String, password: String): LoginResult { return try { val response = api.login(LoginRequest(email, password)) - LoginResult.Success(response.accessToken) + Result.Success(response.accessToken) + } catch (e: HttpException) { + val code = e.code() + val errorBody = e.response()?.errorBody()?.string() + Timber.e("HTTP Error: Code=$code, Body=$errorBody") + when (code) { + in 500..599 -> Result.Error(AuthError.Network.SERVER_ERROR) + 401 -> Result.Error(AuthError.Auth.INVALID_CREDENTIALS) + else -> Result.Error(AuthError.Network.UNKNOWN) + } + } catch (e: SocketTimeoutException) { + Timber.e("Timeout error: ${e.message}") + Result.Error(AuthError.Network.TIMEOUT) + } catch (e: IOException) { + Timber.e("Network error: ${e.message}") + Result.Error(AuthError.Network.NO_INTERNET) } catch (e: Exception) { - if(e is CancellationException) throw e - LoginResult.Error(e.message ?: "Unknown error") + Timber.e("Exception during login: ${e.message}") + if (e is CancellationException) throw e + Result.Error(AuthError.Network.UNKNOWN) } } @@ -28,7 +48,6 @@ class DefaultAuthRepository(private val api: AuthApi) : AuthRepository { ): RegisterResult { return try { Timber.d("Attempting register with: name='$fullName', email='$email'") - val response = api.register( RegisterRequest( fullName = fullName, @@ -36,37 +55,28 @@ class DefaultAuthRepository(private val api: AuthApi) : AuthRepository { password = password ) ) - Timber.d("Register successful! Status code: ${response.code()}") - RegisterResult.Success - + Result.Success(Unit) } catch (e: HttpException) { val code = e.code() val errorBody = e.response()?.errorBody()?.string() - Timber.e("HTTP Error: Code=$code, Body=$errorBody") - when (code) { - 409 -> { - Timber.d("409 Conflict - User already exists") - RegisterResult.UserAlreadyExists - } - 400 -> { - Timber.d("400 Bad Request - Validation error: $errorBody") - RegisterResult.Error(errorBody ?: "Validation failed") - } - else -> { - Timber.d("Unexpected error code: $code") - RegisterResult.Error(errorBody ?: "Unknown error") - } + 409 -> Result.Error(AuthError.Auth.USER_ALREADY_EXISTS) + 400 -> Result.Error(AuthError.Auth.VALIDATION_FAILED) + in 500..599 -> Result.Error(AuthError.Network.SERVER_ERROR) + else -> Result.Error(AuthError.Network.UNKNOWN) } + } catch (e: SocketTimeoutException) { + Timber.e("Timeout error: ${e.message}") + Result.Error(AuthError.Network.TIMEOUT) + } catch (e: IOException) { + Timber.e("Network error: ${e.message}") + Result.Error(AuthError.Network.NO_INTERNET) } catch (e: Exception) { + Timber.e("Exception during register: ${e.message}") if (e is CancellationException) throw e - - Timber.e("Exception during register: ${e.javaClass.simpleName}: ${e.message}") - e.printStackTrace() - - RegisterResult.Error(e.message ?: "Unknown error") + Result.Error(AuthError.Network.UNKNOWN) } } } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt new file mode 100644 index 0000000..5c343f6 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt @@ -0,0 +1,40 @@ +package com.dmd.tasky.feature.auth.domain.model + +import com.dmd.tasky.core.domain.util.EmptyResult +import com.dmd.tasky.core.domain.util.Error +import com.dmd.tasky.core.domain.util.Result + +sealed interface AuthError : Error { + enum class Network : AuthError { + NO_INTERNET, // IOException + TIMEOUT, // SocketTimeoutException + SERVER_ERROR, // HTTP 500/502/503 + UNKNOWN // Unexpected exceptions + } + + enum class Auth : AuthError { + INVALID_CREDENTIALS, // 401 for login + USER_ALREADY_EXISTS, // 409 for register + VALIDATION_FAILED // 400 for register + } +} + +fun AuthError.toUiMessage(): String { + return when (this) { + AuthError.Auth.INVALID_CREDENTIALS -> "Invalid email or password" + AuthError.Auth.USER_ALREADY_EXISTS -> "User with this email already exists" + AuthError.Auth.VALIDATION_FAILED -> "Validation failed" + + AuthError.Network.NO_INTERNET -> "No Internet connection" + AuthError.Network.SERVER_ERROR -> "Server error" + AuthError.Network.TIMEOUT -> "Request timed out" + AuthError.Network.UNKNOWN -> "Unknown network error" + } +} + + +typealias LoginResult = Result + +typealias RegisterResult = EmptyResult + +typealias LogoutResult = EmptyResult \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/LoginResult.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/LoginResult.kt deleted file mode 100644 index 1022879..0000000 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/LoginResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.dmd.tasky.feature.auth.domain.model - -sealed class LoginResult { - data class Success(val token: String) : LoginResult() - data class Error(val message: String) : LoginResult() - object InvalidCredentials : LoginResult() -} \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt deleted file mode 100644 index 848f370..0000000 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/RegisterResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.dmd.tasky.feature.auth.domain.model - -sealed class RegisterResult { - object Success : RegisterResult() - data class Error(val message: String) : RegisterResult() - object UserAlreadyExists : RegisterResult() -} \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt index 6cd3b35..70130fa 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt @@ -5,8 +5,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.dmd.tasky.core.domain.util.onError +import com.dmd.tasky.core.domain.util.onSuccess import com.dmd.tasky.feature.auth.domain.AuthRepository -import com.dmd.tasky.feature.auth.domain.model.LoginResult +import com.dmd.tasky.feature.auth.domain.model.toUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -47,24 +49,16 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { state = state.copy(isLoading = true) Timber.d("Login started") - val result = authRepository.login(state.email, state.password) - - state = state.copy(isLoading = false) - when (result) { - is LoginResult.Success -> { - Timber.d("Login success") + authRepository.login(state.email, state.password) + .onSuccess { token -> + Timber.d("Login successful") + state = state.copy(isLoading = false, error = null) + // TODO: Save token, navigate to next screen } - - is LoginResult.Error -> { - Timber.e(result.message) - state = state.copy(error = result.message) + .onError { error -> + Timber.e("Login failed: $error") + state = state.copy(isLoading = false, error = error.toUiMessage()) } - - is LoginResult.InvalidCredentials -> { - Timber.d("Invalid credentials") - state = state.copy(error = "Invalid credentials") - } - } } } } diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt index 04f97c8..d5abdfe 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt @@ -5,8 +5,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.dmd.tasky.core.domain.util.onError +import com.dmd.tasky.core.domain.util.onSuccess import com.dmd.tasky.feature.auth.domain.AuthRepository -import com.dmd.tasky.feature.auth.domain.model.RegisterResult +import com.dmd.tasky.feature.auth.domain.model.toUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -56,30 +58,19 @@ class RegisterViewModel @Inject constructor( Timber.d(" Email: '${state.email}'") Timber.d(" Password: '${state.password}' (length: ${state.password.length})") - val result = authRepository.register( + authRepository.register( fullName = state.fullName, email = state.email, password = state.password ) - - state = state.copy(isLoading = false) - - when (result) { - is RegisterResult.Success -> { + .onSuccess { Timber.d("Registration successful in ViewModel!") - state = state.copy(registrationSuccess = true) - } - - is RegisterResult.Error -> { - Timber.e("Registration error: ${result.message}") - state = state.copy(error = result.message) + state = state.copy(registrationSuccess = true, isLoading = false) } - - is RegisterResult.UserAlreadyExists -> { - Timber.d("User already exists") - state = state.copy(error = "This email is already registered. Try logging in?") + .onError { error -> + Timber.e("Registration error: $error") + state = state.copy(error = error.toUiMessage(), isLoading = false) } - } } } } diff --git a/features/auth/src/test/java/FakeAuthRepository.kt b/features/auth/src/test/java/FakeAuthRepository.kt index 3a8632b..e3814e5 100644 --- a/features/auth/src/test/java/FakeAuthRepository.kt +++ b/features/auth/src/test/java/FakeAuthRepository.kt @@ -1,11 +1,16 @@ +import androidx.compose.material3.RangeSlider +import com.dmd.tasky.core.domain.util.Result import com.dmd.tasky.feature.auth.domain.AuthRepository +import com.dmd.tasky.feature.auth.domain.model.AuthError import com.dmd.tasky.feature.auth.domain.model.LoginResult import com.dmd.tasky.feature.auth.domain.model.RegisterResult import kotlinx.coroutines.delay class FakeAuthRepository : AuthRepository { - var registerResult: RegisterResult = RegisterResult.Success + var registerResult: RegisterResult = Result.Success(Unit) + + override suspend fun register( fullName: String, diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt index b08f1cb..988d4d1 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt @@ -1,6 +1,8 @@ package com.dmd.tasky.feature.auth.presentation.login +import com.dmd.tasky.core.domain.util.Result import com.dmd.tasky.feature.auth.domain.AuthRepository +import com.dmd.tasky.feature.auth.domain.model.AuthError import com.dmd.tasky.feature.auth.domain.model.LoginResult import com.dmd.tasky.feature.auth.presentation.MainCoroutineRule import io.mockk.coEvery @@ -46,7 +48,7 @@ class LoginViewModelTest { @Test fun `login success should update state correctly`() = runTest { val token = "token" - coEvery { authRepository.login(any(), any()) } returns LoginResult.Success(token) + coEvery { authRepository.login(any(), any()) } returns Result.Success(token) loginViewModel.onAction(LoginAction.LoginClicked) @@ -57,8 +59,13 @@ class LoginViewModelTest { @Test fun `login error should update state correctly`() = runTest { - val errorMessage = "error" - coEvery { authRepository.login(any(), any()) } returns LoginResult.Error(errorMessage) + val errorMessage = "Unknown network error" + coEvery { + authRepository.login( + any(), + any() + ) + } returns Result.Error(AuthError.Network.UNKNOWN) loginViewModel.onAction(LoginAction.LoginClicked) @@ -68,11 +75,16 @@ class LoginViewModelTest { @Test fun `login with invalid credentials should update state correctly`() = runTest { - coEvery { authRepository.login(any(), any()) } returns LoginResult.InvalidCredentials + coEvery { + authRepository.login( + any(), + any() + ) + } returns Result.Error(AuthError.Auth.INVALID_CREDENTIALS) loginViewModel.onAction(LoginAction.LoginClicked) Assert.assertEquals(false, loginViewModel.state.isLoading) - Assert.assertEquals("Invalid credentials", loginViewModel.state.error) + Assert.assertEquals("Invalid email or password", loginViewModel.state.error) } } \ No newline at end of file diff --git a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt index 91f7ff0..3eb93bc 100644 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -1,7 +1,8 @@ package com.dmd.tasky.feature.auth.presentation.register import FakeAuthRepository -import com.dmd.tasky.feature.auth.domain.model.RegisterResult +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.feature.auth.domain.model.AuthError import com.dmd.tasky.feature.auth.presentation.MainCoroutineRule import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,7 +55,7 @@ class RegisterViewModelTest { @Test fun `RegisterClicked success scenario`() = runTest { - authRepository.registerResult = RegisterResult.Success + authRepository.registerResult = Result.Success(Unit) registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) @@ -71,7 +72,7 @@ class RegisterViewModelTest { @Test fun `RegisterClicked user already exists scenario`() = runTest { - authRepository.registerResult = RegisterResult.UserAlreadyExists + authRepository.registerResult = Result.Error(AuthError.Auth.USER_ALREADY_EXISTS) registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) registerViewModel.onAction(RegisterAction.PasswordChanged("Test")) @@ -81,7 +82,7 @@ class RegisterViewModelTest { assertEquals(false, registerViewModel.state.registrationSuccess) assertEquals(false, registerViewModel.state.isLoading) assertEquals( - "This email is already registered. Try logging in?", + "User with this email already exists", registerViewModel.state.error ) } @@ -89,7 +90,7 @@ class RegisterViewModelTest { @Test fun `RegisterClicked generic error scenario`() = runTest { - authRepository.registerResult = RegisterResult.Error("Test error") + authRepository.registerResult = Result.Error(AuthError.Network.UNKNOWN) registerViewModel.onAction(RegisterAction.FullNameChanged("Test")) registerViewModel.onAction(RegisterAction.EmailChanged("Test@test.com")) @@ -101,25 +102,22 @@ class RegisterViewModelTest { assertEquals(false, registerViewModel.state.registrationSuccess) assertEquals(false, registerViewModel.state.isLoading) - assertEquals("Test error", registerViewModel.state.error) + assertEquals("Unknown network error", registerViewModel.state.error) } @Test fun `RegisterClicked clears previous error`() = runTest { - authRepository.registerResult = RegisterResult.Error("Test error") + authRepository.registerResult = Result.Error(AuthError.Network.UNKNOWN) registerViewModel.onAction(RegisterAction.RegisterClicked) advanceUntilIdle() - assertEquals("Test error", registerViewModel.state.error) + assertEquals("Unknown network error", registerViewModel.state.error) - authRepository.registerResult = RegisterResult.Success + authRepository.registerResult = Result.Success(Unit) registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() assertEquals(null, registerViewModel.state.error) - - advanceUntilIdle() assertEquals(true, registerViewModel.state.registrationSuccess) - - } } \ No newline at end of file From 32c0b7780279ea3c68807a6691ae5dd5e893b224 Mon Sep 17 00:00:00 2001 From: Demis Lavrentidis Date: Sat, 1 Nov 2025 11:03:31 +0000 Subject: [PATCH 7/8] refactor(auth): Clean up RegisterScreen layout - Refactor the layout structure within `RegisterScreen` for better organization and spacing. - Wrap the contents of the `Card` in a `Column` to centralize horizontal padding and simplify child modifiers. - Adjust the header text's padding and remove an unnecessary `Spacer`. - Group the error and success message `Text` composables within a `Column` to manage their spacing consistently. - Remove the vertical centering arrangement from the root `Column` to allow content to align to the top. --- .../presentation/register/RegisterScreen.kt | 175 +++++++++--------- 1 file changed, 85 insertions(+), 90 deletions(-) diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt index 8b999d7..c4daf10 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -3,10 +3,8 @@ package com.dmd.tasky.feature.auth.presentation.register import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText @@ -68,18 +66,17 @@ private fun TaskyRegisterContent( .padding(paddingValues) .background(color = Color.Black), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center +// verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.register_greeting), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, modifier = Modifier - .padding(top = 70.dp) + .padding(top = 40.dp, bottom = 40.dp) .align(Alignment.CenterHorizontally), color = Color.White ) - Spacer(modifier = Modifier.height(36.dp)) Card( modifier = Modifier @@ -89,96 +86,94 @@ private fun TaskyRegisterContent( topEnd = 25.dp, ), ) { - LoginInputField( - text = "Full Name", - value = state.fullName, - onValueChange = { onAction(RegisterAction.FullNameChanged(it)) }, + Column( modifier = Modifier - .padding( - top = 28.dp, - start = 16.dp, - end = 16.dp - ) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - hidePassword = null, - trailingIcon = null - ) - LoginInputField( - text = "Email", - value = state.email, - onValueChange = { onAction(RegisterAction.EmailChanged(it)) }, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp - ) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - hidePassword = null, - trailingIcon = null - ) - - LoginInputField( - text = "Password", - value = state.password, - onValueChange = { onAction(RegisterAction.PasswordChanged(it)) }, - hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp - ) - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - trailingIcon = { - IconButton(onClick = { onAction(RegisterAction.PasswordVisibilityChanged) }) { - Icon( - imageVector = if (state.passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = if (state.passwordVisible) "Hide password" else "Show password" + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + LoginInputField( + text = "Full Name", + value = state.fullName, + onValueChange = { onAction(RegisterAction.FullNameChanged(it)) }, + modifier = Modifier + .padding( + top = 28.dp, ) - } - } - ) - // TODO(Add enabled flag to button) - LoginButton( - text = "GET STARTED", - onClick = { onAction(RegisterAction.RegisterClicked) }, - modifier = Modifier - .padding( - top = 32.dp, - start = 16.dp, - end = 16.dp - ) - .fillMaxWidth() - .align(Alignment.CenterHorizontally) - ) - - BasicText( - text = annotatedString( - onAction(RegisterAction.LoginClicked), - state.javaClass.name - ), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .padding(top = 20.dp) - .align(Alignment.CenterHorizontally), - ) - - if (state.error != null) { - Text( - text = "Error: ${state.error}", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(bottom = 16.dp) + .fillMaxWidth(), + hidePassword = null, + trailingIcon = null + ) + LoginInputField( + text = "Email", + value = state.email, + onValueChange = { onAction(RegisterAction.EmailChanged(it)) }, + modifier = Modifier + .fillMaxWidth(), + hidePassword = null, + trailingIcon = null ) - } - if (state.registrationSuccess) { - Text( - text = "Registration Successful!", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 16.dp) + LoginInputField( + text = "Password", + value = state.password, + onValueChange = { onAction(RegisterAction.PasswordChanged(it)) }, + hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth(), + trailingIcon = { + IconButton(onClick = { onAction(RegisterAction.PasswordVisibilityChanged) }) { + Icon( + imageVector = if (state.passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (state.passwordVisible) "Hide password" else "Show password" + ) + } + } ) + // TODO(Add enabled flag to button) + LoginButton( + text = "GET STARTED", + onClick = { onAction(RegisterAction.RegisterClicked) }, + modifier = Modifier + .padding( + top = 32.dp, + start = 16.dp, + end = 16.dp + ) + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + ) + BasicText( + text = annotatedString( + onAction(RegisterAction.LoginClicked), + state.javaClass.name + ), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .padding(top = 20.dp) + .align(Alignment.CenterHorizontally), + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (state.error != null) { + Text( + text = "Error: ${state.error}", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + if (state.registrationSuccess) { + Text( + text = "Registration Successful!", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + } } } } From f3b09fa663a190082665cc1674230408e097229c Mon Sep 17 00:00:00 2001 From: Demis Date: Fri, 7 Nov 2025 12:45:07 +0000 Subject: [PATCH 8/8] refactor(auth): Standardize error messages using `UiText` and string resources - Introduce a `UiText` sealed class in the `:core:domain:util` module to handle both dynamic strings and string resources, abstracting Android `Context` away from ViewModels. - Replace hardcoded error `String`s in `AuthError.kt` with a `toUiText()` extension function that maps `AuthError` enums to `StringResource` instances from `strings.xml`. - Update `LoginViewModel` and `RegisterViewModel` to use `UiText` for error states, removing direct dependency on Android-specific string formatting. - Add an `asString()` Composable extension function to resolve `UiText` objects into displayable strings within the UI layer. - Enable Gradle's typesafe project accessors and update module dependencies in `build.gradle.kts` files to use the new syntax (e.g., `projects.features.auth`). - Rename `LoginInputField` to `TaskyTextInputField` and `LoginButton` to `TaskyButton` for more generic naming and reuse. - Add a new `@Preview` for the error state in the `RegisterScreen`. --- app/build.gradle.kts | 2 +- .../com/dmd/tasky/core/domain/util/UiText.kt | 19 ++++++++ features/auth/build.gradle.kts | 5 +- .../feature/auth/domain/model/AuthError.kt | 13 ----- .../auth/presentation/login/LoginScreen.kt | 24 ++++++---- .../auth/presentation/login/LoginViewModel.kt | 7 +-- .../presentation/register/RegisterScreen.kt | 48 +++++++++++++------ .../register/RegisterViewModel.kt | 7 +-- .../feature/auth/presentation/toUiText.kt | 31 ++++++++++++ features/auth/src/main/res/values/strings.xml | 9 ++++ settings.gradle.kts | 2 + 11 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 core/domain/util/src/main/java/com/dmd/tasky/core/domain/util/UiText.kt create mode 100644 features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/toUiText.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ed6add..2a38ecb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,7 @@ android { dependencies { - implementation(project(":features:auth")) + implementation(projects.features.auth) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/core/domain/util/src/main/java/com/dmd/tasky/core/domain/util/UiText.kt b/core/domain/util/src/main/java/com/dmd/tasky/core/domain/util/UiText.kt new file mode 100644 index 0000000..d484237 --- /dev/null +++ b/core/domain/util/src/main/java/com/dmd/tasky/core/domain/util/UiText.kt @@ -0,0 +1,19 @@ +package com.dmd.tasky.core.domain.util + +import android.content.Context +import androidx.annotation.StringRes + +sealed class UiText { + data class DynamicString(val value: String) : UiText() + class StringResource( + @StringRes val resId: Int, + vararg val args: Any + ) : UiText() + + fun asString(context: Context): String { + return when (this) { + is DynamicString -> value + is StringResource -> context.getString(resId, *args) + } + } +} \ No newline at end of file diff --git a/features/auth/build.gradle.kts b/features/auth/build.gradle.kts index e270d3f..dc06ffc 100644 --- a/features/auth/build.gradle.kts +++ b/features/auth/build.gradle.kts @@ -32,7 +32,7 @@ android { getByName("debug") { - val apiKey = localProperties.getProperty("apiKey", "") + val apiKey = localProperties.getProperty("apiKey", "") buildConfigField("String", "API_KEY", apiKey) buildConfigField("String", "BASE_URL", "\"https://tasky.pl-coding.com/\"") } @@ -54,13 +54,14 @@ android { buildFeatures { compose = true buildConfig = true + android.androidResources.enable = true } } dependencies { // Modules - implementation(project(":core:domain:util")) + implementation(projects.core.domain.util) // Credential Manager implementation(libs.androidx.credentials) diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt index 5c343f6..842f06a 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt @@ -19,19 +19,6 @@ sealed interface AuthError : Error { } } -fun AuthError.toUiMessage(): String { - return when (this) { - AuthError.Auth.INVALID_CREDENTIALS -> "Invalid email or password" - AuthError.Auth.USER_ALREADY_EXISTS -> "User with this email already exists" - AuthError.Auth.VALIDATION_FAILED -> "Validation failed" - - AuthError.Network.NO_INTERNET -> "No Internet connection" - AuthError.Network.SERVER_ERROR -> "Server error" - AuthError.Network.TIMEOUT -> "Request timed out" - AuthError.Network.UNKNOWN -> "Unknown network error" - } -} - typealias LoginResult = Result diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt index c944191..f5e69e4 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginScreen.kt @@ -94,8 +94,8 @@ private fun TaskyLoginContent( topEnd = 25.dp, ), ) { - LoginInputField( - text = "Email", + TaskyTextInputField( + hint = "Email", value = state.email, onValueChange = { onAction(LoginAction.EmailChanged(it)) }, modifier = Modifier @@ -109,8 +109,8 @@ private fun TaskyLoginContent( hidePassword = null, trailingIcon = null ) - LoginInputField( - text = "Password", + TaskyTextInputField( + hint = "Password", value = state.password, onValueChange = { onAction(LoginAction.PasswordChanged(it)) }, hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), @@ -125,12 +125,16 @@ private fun TaskyLoginContent( IconButton(onClick = { onAction(LoginAction.PasswordVisibilityChanged) }) { Icon( imageVector = if (state.passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = if (state.passwordVisible) "Hide password" else "Show password" + contentDescription = if (state.passwordVisible) { + stringResource(R.string.content_description_hide_password) + } else { + stringResource(R.string.content_description_show_password) + } ) } } ) - LoginButton( + TaskyButton( text = "LOG IN", onClick = { onAction(LoginAction.LoginClicked) }, modifier = Modifier @@ -161,8 +165,8 @@ private fun TaskyLoginContent( } @Composable -fun LoginInputField( - text: String, +fun TaskyTextInputField( + hint: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, @@ -171,7 +175,7 @@ fun LoginInputField( ) { OutlinedTextField( value = value, - label = { Text(text) }, + label = { Text(hint) }, maxLines = 1, visualTransformation = hidePassword ?: VisualTransformation.None, onValueChange = onValueChange, @@ -181,7 +185,7 @@ fun LoginInputField( } @Composable -fun LoginButton( +fun TaskyButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt index 70130fa..a032d3f 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModel.kt @@ -8,7 +8,8 @@ import androidx.lifecycle.viewModelScope import com.dmd.tasky.core.domain.util.onError import com.dmd.tasky.core.domain.util.onSuccess import com.dmd.tasky.feature.auth.domain.AuthRepository -import com.dmd.tasky.feature.auth.domain.model.toUiMessage +import com.dmd.tasky.core.domain.util.UiText +import com.dmd.tasky.feature.auth.presentation.toUiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -57,7 +58,7 @@ class LoginViewModel @Inject constructor( } .onError { error -> Timber.e("Login failed: $error") - state = state.copy(isLoading = false, error = error.toUiMessage()) + state = state.copy(isLoading = false, error = error.toUiText()) } } } @@ -68,5 +69,5 @@ data class LoginUiState( val password: String = "", val isLoading: Boolean = false, var passwordVisible: Boolean = false, - val error: String? = null + val error: UiText? = null ) \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt index c4daf10..129b306 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -32,9 +32,11 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.dmd.tasky.core.domain.util.UiText import com.dmd.tasky.feature.auth.R -import com.dmd.tasky.feature.auth.presentation.login.LoginButton -import com.dmd.tasky.feature.auth.presentation.login.LoginInputField +import com.dmd.tasky.feature.auth.presentation.asString +import com.dmd.tasky.feature.auth.presentation.login.TaskyButton +import com.dmd.tasky.feature.auth.presentation.login.TaskyTextInputField import com.dmd.tasky.feature.auth.presentation.login.annotatedString @@ -66,14 +68,13 @@ private fun TaskyRegisterContent( .padding(paddingValues) .background(color = Color.Black), horizontalAlignment = Alignment.CenterHorizontally, -// verticalArrangement = Arrangement.Center ) { Text( text = stringResource(R.string.register_greeting), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, modifier = Modifier - .padding(top = 40.dp, bottom = 40.dp) + .padding(vertical = 40.dp) .align(Alignment.CenterHorizontally), color = Color.White ) @@ -91,8 +92,8 @@ private fun TaskyRegisterContent( .fillMaxSize() .padding(horizontal = 16.dp) ) { - LoginInputField( - text = "Full Name", + TaskyTextInputField( + hint = "Full Name", value = state.fullName, onValueChange = { onAction(RegisterAction.FullNameChanged(it)) }, modifier = Modifier @@ -103,8 +104,8 @@ private fun TaskyRegisterContent( hidePassword = null, trailingIcon = null ) - LoginInputField( - text = "Email", + TaskyTextInputField( + hint = "Email", value = state.email, onValueChange = { onAction(RegisterAction.EmailChanged(it)) }, modifier = Modifier @@ -113,8 +114,8 @@ private fun TaskyRegisterContent( trailingIcon = null ) - LoginInputField( - text = "Password", + TaskyTextInputField( + hint = "Password", value = state.password, onValueChange = { onAction(RegisterAction.PasswordChanged(it)) }, hidePassword = if (state.passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), @@ -130,7 +131,7 @@ private fun TaskyRegisterContent( } ) // TODO(Add enabled flag to button) - LoginButton( + TaskyButton( text = "GET STARTED", onClick = { onAction(RegisterAction.RegisterClicked) }, modifier = Modifier @@ -157,9 +158,9 @@ private fun TaskyRegisterContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (state.error != null) { + state.error?.let { error -> Text( - text = "Error: ${state.error}", + text = error.asString(), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 16.dp) ) @@ -213,4 +214,23 @@ fun TaskyRegisterContentPreview() { } } ) -} \ No newline at end of file +} + +@Preview(name = "Register Error State", showBackground = false, backgroundColor = 0XFF16161C) +@Composable +fun TaskyRegisterErrorPreview() { + val errorState = RegisterUiState( + fullName = "John Doe", + email = "john@example.com", + password = "secret", + passwordVisible = false, + error = UiText.StringResource(R.string.error_email_already_exists), + registrationSuccess = false + ) + + TaskyRegisterContent( + state = errorState, + onAction = {} + ) +} + diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt index d5abdfe..5a7cf40 100644 --- a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.dmd.tasky.core.domain.util.UiText import com.dmd.tasky.core.domain.util.onError import com.dmd.tasky.core.domain.util.onSuccess import com.dmd.tasky.feature.auth.domain.AuthRepository -import com.dmd.tasky.feature.auth.domain.model.toUiMessage +import com.dmd.tasky.feature.auth.presentation.toUiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -69,7 +70,7 @@ class RegisterViewModel @Inject constructor( } .onError { error -> Timber.e("Registration error: $error") - state = state.copy(error = error.toUiMessage(), isLoading = false) + state = state.copy(error = error.toUiText(), isLoading = false) } } } @@ -81,6 +82,6 @@ data class RegisterUiState( val password: String = "", var passwordVisible: Boolean = false, val isLoading: Boolean = false, - val error: String? = null, + val error: UiText? = null, val registrationSuccess: Boolean = false ) diff --git a/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/toUiText.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/toUiText.kt new file mode 100644 index 0000000..4f5cd9a --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/toUiText.kt @@ -0,0 +1,31 @@ +package com.dmd.tasky.feature.auth.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.dmd.tasky.core.domain.util.UiText +import com.dmd.tasky.core.domain.util.UiText.DynamicString +import com.dmd.tasky.core.domain.util.UiText.StringResource +import com.dmd.tasky.feature.auth.domain.model.AuthError +import com.dmd.tasky.feature.auth.R + + +fun AuthError.toUiText(): UiText { + return when (this) { + AuthError.Auth.INVALID_CREDENTIALS -> StringResource(R.string.error_invalid_credentials) + AuthError.Auth.USER_ALREADY_EXISTS -> StringResource(R.string.error_email_already_exists) + AuthError.Auth.VALIDATION_FAILED -> StringResource(R.string.error_validation_failed) + + AuthError.Network.NO_INTERNET -> StringResource(R.string.error_no_internet_connection) + AuthError.Network.SERVER_ERROR -> StringResource(R.string.server_error) + AuthError.Network.TIMEOUT -> StringResource(R.string.error_request_timed_out) + AuthError.Network.UNKNOWN -> StringResource(R.string.unknown_network_error) + } +} + +@Composable +fun UiText.asString(): String { + return when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args) + } +} \ No newline at end of file diff --git a/features/auth/src/main/res/values/strings.xml b/features/auth/src/main/res/values/strings.xml index 081dc23..01be986 100644 --- a/features/auth/src/main/res/values/strings.xml +++ b/features/auth/src/main/res/values/strings.xml @@ -4,4 +4,13 @@ DON’T HAVE AN ACCOUNT? SIGN UP Create your account + Invalid email or password + User with this email already exists + Validation failed + No Internet connection + Server error + Request timed out + Unknown network error + Hide password + Show password \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c988247..04b5dda 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,8 @@ dependencyResolutionManagement { } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "Tasky" include(":app") include(":features:auth")