Skip to content

Conversation

@DemisChan
Copy link
Owner

Refactor auth module to use generic Result type

Overview

Refactored authentication domain and data layers to use the shared Result<D, E> type from core:domain:util, replacing feature-specific LoginResult and RegisterResult sealed classes with a type-safe, generic error handling approach.

Key Changes

1. Created AuthError sealed interface

sealed interface AuthError : Error {
    enum class Network : AuthError {
        NO_INTERNET, TIMEOUT, SERVER_ERROR, UNKNOWN
    }
    enum class Auth : AuthError {
        INVALID_CREDENTIALS, USER_ALREADY_EXISTS, VALIDATION_FAILED
    }
}
  • Implements core Error marker interface for use with generic Result type
  • Network errors: IOException, SocketTimeoutException, HTTP 5xx
  • Auth errors: 401 Unauthorized, 409 Conflict, 400 Bad Request

2. Added type aliases for backward compatibility

typealias LoginResult = Result<String, AuthError>      // Returns auth token
typealias RegisterResult = EmptyResult<AuthError>      // Returns Unit

3. Updated repository layer

  • DefaultAuthRepository now returns Result.Success(data) / Result.Error(error)
  • Proper exception handling with specific error mapping:
    • HttpException → Mapped by status code (401, 409, 400, 5xx)
    • IOExceptionAuthError.Network.NO_INTERNET
    • SocketTimeoutExceptionAuthError.Network.TIMEOUT
    • Unknown exceptions → AuthError.Network.UNKNOWN

4. Refactored ViewModels to use functional API

authRepository.register(...)
    .onSuccess { 
        state = state.copy(registrationSuccess = true)
    }
    .onError { error -> 
        state = state.copy(error = error.toUiMessage())
    }

5. Centralized error-to-UI mapping

fun AuthError.toUiMessage(): String {
    return when (this) {
        AuthError.Auth.INVALID_CREDENTIALS -> "Invalid email or password"
        AuthError.Network.NO_INTERNET -> "No internet connection"
        // ... all error cases mapped once
    }
}

6. Updated test fakes

class FakeAuthRepository : AuthRepository {
    var registerResult: RegisterResult = Result.Success(Unit)
}
  • Tests updated to use Result.Success(Unit) and Result.Error(AuthError...)
  • All tests passing ✅

Benefits

  • Type-safe: Compiler catches unhandled error cases
  • Testable: Each error type can be tested independently
  • Maintainable: Error messages defined once, used everywhere
  • Scalable: Easy to add new error types across features

Files Changed

Added:

  • AuthError.kt - Error sealed interface + type aliases + toUiMessage()

Modified:

  • DefaultAuthRepository.kt - Updated to return Result types
  • LoginViewModel.kt - Refactored to use .onSuccess/.onError
  • RegisterViewModel.kt - Refactored to use .onSuccess/.onError
  • FakeAuthRepository.kt - Updated for new Result API
  • RegisterViewModelTest.kt - All tests updated and passing

Deleted:

  • LoginResult.kt - Replaced by type alias
  • RegisterResult.kt - Replaced by type alias

Testing

  • ✅ Manual testing: Login/Register flows work correctly
  • ✅ Error scenarios tested: Network errors, validation errors, user exists

Demis Lavrentidis added 7 commits October 19, 2025 11:02
- 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.
- 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.
- 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.
- 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.
**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`.
- Replace `LoginResult` and `RegisterResult` sealed classes with a generic `Result<T, Error>` 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.
Comment on lines 62 to 64
// Modules
implementation(project(":core:domain:util"))

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could enable type-safe project accessors to avoid hardcoding module paths like ":core:domain:util" and avoid typing errors while referencing them.

Once enabled, you can reference it like this instead:

implementation(projects.core.domain.util)

You can add this line to your settings.gradle.kts and that would be enough:

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

Comment on lines +44 to +81
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)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe look for a way to avoid duplicating this try/catch logic in every API call? 😊
We could extract it into a safeCall utility that wraps the network request and handles all the exceptions and response mapping in one place. Something like this:

inline fun <reified T> safeCall(
    execute: () -> Response<T>,
    mapHttpError: (Int) -> AuthError
): Result<T, AuthError> {
    val response = try {
        execute()
    } catch (e: UnknownHostException) {
        return Result.Error(AuthError.Network.NoInternet)
    } catch (e: SocketTimeoutException) {
        return Result.Error(AuthError.Network.Timeout)
    } catch (e: IOException) {
        return Result.Error(AuthError.Network.NoInternet)
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        return Result.Error(AuthError.Network.Unknown)
    }

    return when (response.code()) {
        in 200..299 -> Result.Success(response.body() as T)
        else -> Result.Error(mapHttpError(response.code()))
    }
}

That way, the repository methods become much cleaner - something like:

class DefaultAuthRepository(private val api: AuthApi) : AuthRepository {

    override suspend fun login(email: String, password: String): Result<String, AuthError> {
        return safeCall(
            execute = { api.login(LoginRequest(email, password)) },
            mapHttpError = { code ->
                when (code) {
                    400 -> AuthError.Auth.ValidationFailed
                    401 -> AuthError.Auth.InvalidCredentials
                    409 -> AuthError.Auth.UserAlreadyExists
                    in 500..599 -> AuthError.Network.ServerError
                    else -> AuthError.Network.Unknown
                }
            }
        )
    }
}

In the other hand, I’d stick with the approach Philipp video and you referenced in the slack - it’s much simpler since the backend already assumes a consistent response structure across features. That way, you can handle everything in the presentation layer instead of creating separate classes or enums for each feature.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if I stick to the approach on the video ...this is what i have done right?
if i understood correctly?

Comment on lines 22 to 33
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"
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: You could consider introducing Philipp’s UiText class - it’s a handy utility that lets you handle both string resources and dynamic text in a consistent way across the app, while also making localization much simpler.

sealed interface UiText {
  data class DynamicString(val value: String): UiText
  class StringResource(
    @StringRes val id: Int,
    val args: Array<Any> = arrayOf()
  ): UiText

  @Composable
  fun asString(): String {
    return when(this) {
      is DynamicString -> value
      is StringResource -> stringResource(id = id, *args)
    }
  }
  
  fun asString(context: Context): String {
    return when(this) {
      is DynamicString -> value
      is StringResource -> context.getString(id, *args)
    }
  }
}

Here’s how you could refactor your AuthError mapping to use it:

fun AuthError.toUiText(): UiText {
    return when (this) {
        AuthError.Auth.INVALID_CREDENTIALS ->
            UiText.StringResource(R.string.error_invalid_credentials)
        AuthError.Auth.USER_ALREADY_EXISTS ->
            UiText.StringResource(R.string.error_user_already_exists)
        AuthError.Auth.VALIDATION_FAILED ->
            UiText.StringResource(R.string.error_validation_failed)

        AuthError.Network.NO_INTERNET ->
            UiText.StringResource(R.string.error_no_internet)
        AuthError.Network.SERVER_ERROR ->
            UiText.StringResource(R.string.error_server)
        AuthError.Network.TIMEOUT ->
            UiText.StringResource(R.string.error_timeout)
        AuthError.Network.UNKNOWN ->
            UiText.StringResource(R.string.error_unknown)
    }
}

A final suggestion - we should make sure this AuthError-> UiText mapper lives in the presentation layer, since it depends on Android string resources (R.string) and UiText.

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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could move this contentDescription text to strings.xml resources 😊

),
) {
LoginInputField(
text = "Full Name",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: So far, the parameter name text makes it seem like it’s the actual input value🤔 - maybe renaming it to hint would make the intent clearer, since it’s really just the placeholder text shown in the field.

Comment on lines 107 to 141
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"
)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, this composable feels like handles internally both regular text inputs and password fields 🤔, which makes the logic a bit mixed and doesn't communicate well that handles both. It might be cleaner to split them into two separate composables - one for standard text inputs and another specifically for password fields. That way, each stays focused and easier to read without conditional logic for both scenarios.

topEnd = 25.dp,
),
) {
LoginInputField(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the intention is to reuse this composable in the register screen, it might be worth renaming it - the current name LoginInputField makes it sound like it’s only meant for the login screen 🤔. Giving it a more generic name, like TaskyTextField, would better communicate that it’s reusable across different authentication screens.

Comment on lines +78 to +83
coEvery {
authRepository.login(
any(),
any()
)
} returns Result.Error(AuthError.Auth.INVALID_CREDENTIALS)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally got away from using mocks as much as possible. They're good in two scenarios:

  1. You're testing legacy code which isn't written with testability in mind.
  2. You're testing code you don't own (e.g. you need to make a library's function return something specific and it's not extendable)

In all other cases, I found fakes/stubs much more reliable and less error prone. I can't count how often my tests broke at some point, not because there was a bug, but because the mock itself broke (for example when the mocked functionality changed/was extended, there were unmocked function calls, etc.).
Having a fake instead will make you implement new functionality before you can even run your tests and also doesn't rely on that much 3rd party library magic as it's just a plain Kotlin class.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have used fakes in my registerViewmodeltest and left this as is

Comment on lines +66 to +68
assertEquals(true, registerViewModel.state.registrationSuccess)
assertEquals(false, registerViewModel.state.isLoading)
assertEquals(null, registerViewModel.state.error)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend to look into assertion libraries, since the default assertions aren't so readable for more complex ones. My personal favorite is AssertK. You can add it like this:

  1. Version catalog declaration
versions]
...
assertk = "0.28.1"

libraries]
...
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
  1. Reference it in your Gradle file:
dependencies {
    ...
    testImplementation(libs.assertk)
    androidTestImplementation(libs.assertk)
}
  1. Then your assertions could look like this for example:
assertThat(initials).isEqualTo("PL")

assertThat(myList).hasSize(5)

assertThat(myList).contains(element)

assertThat(myBoolean).isTrue()

Reads like a book :D

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for my next refactor

- 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.
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(top = 70.dp)
.padding(top = 40.dp, bottom = 40.dp)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: If you have the same padding values for opposite ends, you can use one of the following overload functions:

Modifier.padding(vertical = 16.dp) // top + bottom
Modifier.padding(horizontal = 24.dp) // start + end

…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`.
@DemisChan DemisChan merged commit a0f5460 into main Nov 7, 2025
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants