Skip to content
Merged
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/dmd/tasky/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,7 +20,7 @@ class MainActivity : ComponentActivity() {
setContent {
TaskyTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TaskyLoginScreen(
TaskyRegisterScreen(
modifier = Modifier.padding(innerPadding),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 7 additions & 1 deletion features/auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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/\"")
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
}

@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): Response<Unit>
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

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

}
Original file line number Diff line number Diff line change
@@ -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
}
suspend fun register(fullName: String, email: String, password: String): RegisterResult
}
Original file line number Diff line number Diff line change
@@ -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<String, AuthError>

typealias RegisterResult = EmptyResult<AuthError>

typealias LogoutResult = EmptyResult<AuthError>

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading