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/app/src/main/java/com/dmd/tasky/MainActivity.kt b/app/src/main/java/com/dmd/tasky/MainActivity.kt index 8ac7db2..ec3f306 100644 --- a/app/src/main/java/com/dmd/tasky/MainActivity.kt +++ b/app/src/main/java/com/dmd/tasky/MainActivity.kt @@ -8,7 +8,7 @@ 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.TaskyRegisterScreen import com.dmd.tasky.ui.theme.TaskyTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +20,7 @@ class MainActivity : ComponentActivity() { setContent { TaskyTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - TaskyLoginScreen( + TaskyRegisterScreen( modifier = Modifier.padding(innerPadding), ) } 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 fae5d1a..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,11 +54,15 @@ android { buildFeatures { compose = true buildConfig = true + android.androidResources.enable = true } } dependencies { + // Modules + implementation(projects.core.domain.util) + // Credential Manager implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) @@ -95,7 +99,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/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/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/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..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,19 +1,82 @@ 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.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.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) } } -} \ 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()}") + 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 -> 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 + Result.Error(AuthError.Network.UNKNOWN) + } + } +} 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/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..842f06a --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/domain/model/AuthError.kt @@ -0,0 +1,27 @@ +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 + } +} + + +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/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 9d3476c..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 @@ -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( @@ -45,13 +60,12 @@ 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( modifier = modifier @@ -80,8 +94,8 @@ fun TaskyLoginContent( topEnd = 25.dp, ), ) { - LoginInputField( - text = "Email", + TaskyTextInputField( + hint = "Email", value = state.email, onValueChange = { onAction(LoginAction.EmailChanged(it)) }, modifier = Modifier @@ -95,11 +109,11 @@ fun TaskyLoginContent( hidePassword = null, trailingIcon = null ) - LoginInputField( - text = "Password", + TaskyTextInputField( + hint = "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, @@ -108,15 +122,19 @@ 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) { + 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 @@ -129,7 +147,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) @@ -144,8 +165,8 @@ fun TaskyLoginContent( } @Composable -fun LoginInputField( - text: String, +fun TaskyTextInputField( + hint: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, @@ -154,9 +175,9 @@ fun LoginInputField( ) { OutlinedTextField( value = value, - label = { Text(text) }, + label = { Text(hint) }, maxLines = 1, - visualTransformation = hidePassword?: VisualTransformation.None, + visualTransformation = hidePassword ?: VisualTransformation.None, onValueChange = onValueChange, trailingIcon = trailingIcon, modifier = modifier @@ -164,7 +185,7 @@ fun LoginInputField( } @Composable -fun LoginButton( +fun TaskyButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier @@ -187,12 +208,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 +229,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() } @@ -210,8 +242,27 @@ fun annotatedString(onAction: Unit) = 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..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 @@ -5,8 +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.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.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 @@ -25,12 +28,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 -> { } } @@ -40,24 +50,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.toUiText()) } - - is LoginResult.InvalidCredentials -> { - Timber.d("Invalid credentials") - state = state.copy(error = "Invalid credentials") - } - } } } } @@ -66,5 +68,6 @@ data class LoginUiState( val email: String = "", val password: String = "", val isLoading: Boolean = false, - val error: String? = null + var passwordVisible: Boolean = false, + 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/RegisterAction.kt b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt new file mode 100644 index 0000000..d05f802 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterAction.kt @@ -0,0 +1,10 @@ +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 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 new file mode 100644 index 0000000..129b306 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterScreen.kt @@ -0,0 +1,236 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.Card +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.core.domain.util.UiText +import com.dmd.tasky.feature.auth.R +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 + + +@Composable +fun TaskyRegisterScreen( + modifier: Modifier = Modifier, + viewModel: RegisterViewModel = hiltViewModel(), +) { + val registerUiState = viewModel.state + + TaskyRegisterContent( + state = registerUiState, + onAction = viewModel::onAction, + modifier = modifier + ) +} + +@Composable +private fun TaskyRegisterContent( + state: RegisterUiState, + onAction: (RegisterAction) -> Unit, + modifier: Modifier = Modifier, +) { + + Scaffold { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .background(color = Color.Black), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.register_greeting), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(vertical = 40.dp) + .align(Alignment.CenterHorizontally), + color = Color.White + ) + + Card( + modifier = Modifier + .fillMaxSize(), + shape = RoundedCornerShape( + topStart = 25.dp, + topEnd = 25.dp, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + TaskyTextInputField( + hint = "Full Name", + value = state.fullName, + onValueChange = { onAction(RegisterAction.FullNameChanged(it)) }, + modifier = Modifier + .padding( + top = 28.dp, + ) + .fillMaxWidth(), + hidePassword = null, + trailingIcon = null + ) + TaskyTextInputField( + hint = "Email", + value = state.email, + onValueChange = { onAction(RegisterAction.EmailChanged(it)) }, + modifier = Modifier + .fillMaxWidth(), + hidePassword = null, + trailingIcon = null + ) + + TaskyTextInputField( + hint = "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) + TaskyButton( + 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) + ) { + state.error?.let { error -> + Text( + text = error.asString(), + 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() { + + var state by remember { mutableStateOf(RegisterUiState()) } + + TaskyRegisterContent( + 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 -> {} + + } + } + ) +} + +@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 new file mode 100644 index 0000000..5a7cf40 --- /dev/null +++ b/features/auth/src/main/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModel.kt @@ -0,0 +1,87 @@ +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.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.presentation.toUiText +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.PasswordVisibilityChanged -> { + state = state.copy(passwordVisible = !state.passwordVisible) + } + + 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})") + + authRepository.register( + fullName = state.fullName, + email = state.email, + password = state.password + ) + .onSuccess { + Timber.d("Registration successful in ViewModel!") + state = state.copy(registrationSuccess = true, isLoading = false) + } + .onError { error -> + Timber.e("Registration error: $error") + state = state.copy(error = error.toUiText(), isLoading = false) + } + } + } +} + +data class RegisterUiState( + val fullName: String = "", + val email: String = "", + val password: String = "", + var passwordVisible: Boolean = false, + val isLoading: Boolean = false, + 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 e160cbd..01be986 100644 --- a/features/auth/src/main/res/values/strings.xml +++ b/features/auth/src/main/res/values/strings.xml @@ -2,4 +2,15 @@ Welcome Back! 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/features/auth/src/test/java/FakeAuthRepository.kt b/features/auth/src/test/java/FakeAuthRepository.kt new file mode 100644 index 0000000..e3814e5 --- /dev/null +++ b/features/auth/src/test/java/FakeAuthRepository.kt @@ -0,0 +1,30 @@ +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 = Result.Success(Unit) + + + + 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/LoginViewModelTest.kt deleted file mode 100644 index 55ad027..0000000 --- a/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/LoginViewModelTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.dmd.tasky.feature.auth.presentation - -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 io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class LoginViewModelTest { - - @get:Rule - val mainCoroutineRule = MainCoroutineRule() - - private lateinit var loginViewModel: LoginViewModel - private lateinit var authRepository: AuthRepository - - @Before - fun setUp() { - authRepository = mockk() - loginViewModel = LoginViewModel(authRepository) - } - - @Test - fun `onEmailChanged should update email`() { - val email = "test@test.com" - loginViewModel.onEmailChanged(email) - assertEquals(email, loginViewModel.state.email) - } - - @Test - fun `onPasswordChanged should update password`() { - val password = "password" - loginViewModel.onPasswordChanged(password) - assertEquals(password, loginViewModel.state.password) - } - - @Test - fun `login success should update state correctly`() = runTest { - val token = "token" - coEvery { authRepository.login(any(), any()) } returns LoginResult.Success(token) - - loginViewModel.onLoginClicked() - - assertEquals(null, loginViewModel.state.error) - assertEquals(false, loginViewModel.state.isLoading) - } - - - @Test - fun `login error should update state correctly`() = runTest { - val errorMessage = "error" - coEvery { authRepository.login(any(), any()) } returns LoginResult.Error(errorMessage) - - loginViewModel.onLoginClicked() - - assertEquals(false, loginViewModel.state.isLoading) - 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() - - assertEquals(false, loginViewModel.state.isLoading) - 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/login/LoginViewModelTest.kt b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt new file mode 100644 index 0000000..988d4d1 --- /dev/null +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/login/LoginViewModelTest.kt @@ -0,0 +1,90 @@ +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 +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class LoginViewModelTest { + + // This is with Mocks + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private lateinit var loginViewModel: LoginViewModel + private lateinit var authRepository: AuthRepository + + @Before + fun setUp() { + authRepository = mockk() + loginViewModel = LoginViewModel(authRepository) + } + + @Test + fun `onEmailChanged should update email`() { + val email = "test@test.com" + loginViewModel.onAction(LoginAction.EmailChanged(email)) + Assert.assertEquals(email, loginViewModel.state.email) + } + + @Test + fun `onPasswordChanged should update password`() { + val password = "password" + loginViewModel.onAction(LoginAction.PasswordChanged(password)) + Assert.assertEquals(password, loginViewModel.state.password) + } + + @Test + fun `login success should update state correctly`() = runTest { + val token = "token" + coEvery { authRepository.login(any(), any()) } returns Result.Success(token) + + loginViewModel.onAction(LoginAction.LoginClicked) + + Assert.assertEquals(null, loginViewModel.state.error) + Assert.assertEquals(false, loginViewModel.state.isLoading) + } + + + @Test + fun `login error should update state correctly`() = runTest { + val errorMessage = "Unknown network error" + coEvery { + authRepository.login( + any(), + any() + ) + } returns Result.Error(AuthError.Network.UNKNOWN) + + loginViewModel.onAction(LoginAction.LoginClicked) + + 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 Result.Error(AuthError.Auth.INVALID_CREDENTIALS) + + loginViewModel.onAction(LoginAction.LoginClicked) + + Assert.assertEquals(false, loginViewModel.state.isLoading) + 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 new file mode 100644 index 0000000..3eb93bc --- /dev/null +++ b/features/auth/src/test/java/com/dmd/tasky/feature/auth/presentation/register/RegisterViewModelTest.kt @@ -0,0 +1,123 @@ +package com.dmd.tasky.feature.auth.presentation.register + +import FakeAuthRepository +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 +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 = Result.Success(Unit) + 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 = Result.Error(AuthError.Auth.USER_ALREADY_EXISTS) + 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( + "User with this email already exists", + registerViewModel.state.error + ) + } + + @Test + fun `RegisterClicked generic error scenario`() = runTest { + + authRepository.registerResult = Result.Error(AuthError.Network.UNKNOWN) + + 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("Unknown network error", registerViewModel.state.error) + } + + @Test + fun `RegisterClicked clears previous error`() = runTest { + authRepository.registerResult = Result.Error(AuthError.Network.UNKNOWN) + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + assertEquals("Unknown network error", registerViewModel.state.error) + + authRepository.registerResult = Result.Success(Unit) + registerViewModel.onAction(RegisterAction.RegisterClicked) + advanceUntilIdle() + + assertEquals(null, registerViewModel.state.error) + assertEquals(true, registerViewModel.state.registrationSuccess) + } +} \ 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")