Skip to content

Commit

Permalink
Merge pull request #5 from ItamiOMW/recipes-feature
Browse files Browse the repository at this point in the history
1) Recipes feature implemented;
  • Loading branch information
ItamiOMW authored May 1, 2024
2 parents 4b8f1d8 + 55c9a8e commit 8152ed0
Show file tree
Hide file tree
Showing 28 changed files with 1,699 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.itami.calorie_tracker.core.presentation.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.itami.calorie_tracker.core.presentation.theme.CalorieTrackerTheme

@Composable
fun SelectableButton(
selected: Boolean,
text: String,
modifier: Modifier = Modifier,
outlineColor: Color = CalorieTrackerTheme.colors.primary,
containerColor: Color = CalorieTrackerTheme.colors.primary,
contentColor: Color = CalorieTrackerTheme.colors.onPrimary,
textStyle: TextStyle = CalorieTrackerTheme.typography.labelLarge,
onClick: () -> Unit,
) {
Surface(
modifier = modifier,
color = if (selected) containerColor else Color.Transparent,
contentColor = contentColor,
border = if (!selected) BorderStroke(1.dp, outlineColor) else null,
shape = CalorieTrackerTheme.shapes.extraLarge,
onClick = onClick
) {
Text(
text = text,
style = textStyle,
textAlign = TextAlign.Center,
color = if (selected) contentColor else outlineColor,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = CalorieTrackerTheme.padding.extraSmall)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.itami.calorie_tracker.core.utils

import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp

/*
To force layout to go beyond the borders of its parent.
Thanks to https://stackoverflow.com/a/77274557
*/

fun Modifier.fillWidthOfParent(parentPadding: Dp) = this.then(
layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
maxWidth = constraints.maxWidth + 2 * parentPadding.roundToPx(),
),
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ object Constants {
const val MIN_AGE = 7
const val MAX_AGE = 130

const val MAX_CALORIES_PER_SERVING = 1500
const val MAX_TIME_COOKING_MIN = 180

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.itami.calorie_tracker.recipes_feature.data.mapper

import com.itami.calorie_tracker.recipes_feature.data.remote.response.RecipeResponse
import com.itami.calorie_tracker.recipes_feature.domain.model.Recipe

fun RecipeResponse.toRecipe() = Recipe(
id = this.id,
name = this.name,
recipeText = this.recipeText,
caloriesPerServing = this.caloriePerServing,
proteinsPerServing = this.proteinsPerServing,
fatsPerServing = this.fatsPerServing,
carbsPerServing = this.carbsPerServing,
timeMinutes = this.timeMinutes,
imageUrl = this.imageUrl
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.itami.calorie_tracker.recipes_feature.data.remote

import com.itami.calorie_tracker.core.data.remote.response.ApiResponse
import com.itami.calorie_tracker.core.data.remote.response.ErrorResponse
import com.itami.calorie_tracker.recipes_feature.data.remote.response.RecipeResponse
import com.itami.calorie_tracker.recipes_feature.domain.model.CaloriesFilter
import com.itami.calorie_tracker.recipes_feature.domain.model.TimeFilter

interface RecipesApiService {

suspend fun getRecipes(
token: String,
query: String,
page: Int = 1,
pageSize: Int = 10,
timeFilters: List<TimeFilter>,
caloriesFilters: List<CaloriesFilter>,
): ApiResponse<List<RecipeResponse>, ErrorResponse>

suspend fun getRecipeById(
token: String,
id: Int,
): ApiResponse<RecipeResponse, ErrorResponse>

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.itami.calorie_tracker.recipes_feature.data.remote

import com.itami.calorie_tracker.core.data.remote.response.ApiResponse
import com.itami.calorie_tracker.core.data.remote.response.ErrorResponse
import com.itami.calorie_tracker.core.data.remote.safeRequest
import com.itami.calorie_tracker.recipes_feature.data.remote.response.RecipeResponse
import com.itami.calorie_tracker.recipes_feature.domain.model.CaloriesFilter
import com.itami.calorie_tracker.recipes_feature.domain.model.TimeFilter
import io.ktor.client.HttpClient
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.parameter
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import javax.inject.Inject

class RecipesApiServiceImpl @Inject constructor(
private val httpClient: HttpClient,
) : RecipesApiService {

companion object {

private const val RECIPES = "/api/v1/recipes"

private const val PAGE_PARAM = "page"
private const val PAGE_SIZE_PARAM = "pageSize"
private const val QUERY_PARAM = "query"
private const val CALORIES_FILTERS_PARAM = "caloriesFilters"
private const val TIME_FILTERS_PARAM = "timeFilters"
}

override suspend fun getRecipes(
token: String,
query: String,
page: Int,
pageSize: Int,
timeFilters: List<TimeFilter>,
caloriesFilters: List<CaloriesFilter>,
): ApiResponse<List<RecipeResponse>, ErrorResponse> {
return httpClient.safeRequest {
url(RECIPES)
method = HttpMethod.Get
bearerAuth(token)
parameter(QUERY_PARAM, query)
parameter(PAGE_PARAM, page)
parameter(PAGE_SIZE_PARAM, pageSize)
if (timeFilters.isNotEmpty()) {
parameter(TIME_FILTERS_PARAM, timeFilters.joinToString(","))
}
if (caloriesFilters.isNotEmpty()) {
parameter(CALORIES_FILTERS_PARAM, caloriesFilters.joinToString(","))
}
}
}

override suspend fun getRecipeById(
token: String,
id: Int,
): ApiResponse<RecipeResponse, ErrorResponse> {
return httpClient.safeRequest {
url("$RECIPES/$id")
method = HttpMethod.Get
bearerAuth(token)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.itami.calorie_tracker.recipes_feature.data.remote.response

import kotlinx.serialization.Serializable

@Serializable
data class RecipeResponse(
val id: Int,
val name: String,
val recipeText: String,
val caloriePerServing: Int,
val proteinsPerServing: Int,
val fatsPerServing: Int,
val carbsPerServing: Int,
val timeMinutes: Int,
val imageUrl: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.itami.calorie_tracker.recipes_feature.data.repository

import android.content.Context
import com.itami.calorie_tracker.R
import com.itami.calorie_tracker.core.data.auth.AuthManager
import com.itami.calorie_tracker.core.data.remote.response.ApiResponse
import com.itami.calorie_tracker.core.domain.exceptions.AppException
import com.itami.calorie_tracker.core.utils.AppResponse
import com.itami.calorie_tracker.recipes_feature.data.mapper.toRecipe
import com.itami.calorie_tracker.recipes_feature.data.remote.RecipesApiService
import com.itami.calorie_tracker.recipes_feature.domain.model.CaloriesFilter
import com.itami.calorie_tracker.recipes_feature.domain.model.Recipe
import com.itami.calorie_tracker.recipes_feature.domain.model.TimeFilter
import com.itami.calorie_tracker.recipes_feature.domain.repository.RecipesRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.HttpStatusCode
import javax.inject.Inject

class RecipesRepositoryImpl @Inject constructor(
private val recipesApiService: RecipesApiService,
private val authManager: AuthManager,
@ApplicationContext private val context: Context,
) : RecipesRepository {

private val jwtToken get() = authManager.token

override suspend fun getRecipes(
query: String,
page: Int,
pageSize: Int,
timeFilters: List<TimeFilter>,
caloriesFilters: List<CaloriesFilter>,
): AppResponse<List<Recipe>> {
val token = jwtToken ?: return AppResponse.failed(AppException.UnauthorizedException)
return when (val response = recipesApiService.getRecipes(
token = token,
query = query,
page = page,
pageSize = pageSize,
timeFilters = timeFilters,
caloriesFilters = caloriesFilters
)) {
is ApiResponse.Success -> {
val recipes = response.body.map { it.toRecipe() }
return AppResponse.success(recipes)
}

is ApiResponse.Error.HttpClientError -> {
when (response.code) {
HttpStatusCode.TooManyRequests.value -> {
AppResponse.failed(
appException = AppException.TooManyRequestsException,
message = context.getString(R.string.error_too_many_requests)
)
}

else -> {
AppResponse.failed(
AppException.GeneralException,
message = context.getString(R.string.error_unknown)
)
}
}
}

is ApiResponse.Error.HttpServerError -> {
AppResponse.failed(
appException = AppException.ServerError,
message = context.getString(R.string.error_server)
)
}

is ApiResponse.Error.NetworkError -> {
AppResponse.failed(AppException.NetworkException)
}

is ApiResponse.Error.SerializationError -> {
AppResponse.failed(
AppException.GeneralException,
message = context.getString(R.string.error_unknown)
)
}
}
}

override suspend fun getRecipeById(recipeId: Int): AppResponse<Recipe> {
val token = jwtToken ?: return AppResponse.failed(AppException.UnauthorizedException)
return when (val response = recipesApiService.getRecipeById(token, recipeId)) {
is ApiResponse.Success -> {
val recipe = response.body.toRecipe()
return AppResponse.success(recipe)
}

is ApiResponse.Error.HttpClientError -> {
when (response.code) {
HttpStatusCode.TooManyRequests.value -> {
AppResponse.failed(
appException = AppException.TooManyRequestsException,
message = context.getString(R.string.error_too_many_requests)
)
}

else -> {
AppResponse.failed(
AppException.GeneralException,
message = context.getString(R.string.error_unknown)
)
}
}
}

is ApiResponse.Error.HttpServerError -> {
AppResponse.failed(
appException = AppException.ServerError,
message = context.getString(R.string.error_server)
)
}

is ApiResponse.Error.NetworkError -> {
AppResponse.failed(AppException.NetworkException)
}

is ApiResponse.Error.SerializationError -> {
AppResponse.failed(
AppException.GeneralException,
message = context.getString(R.string.error_unknown)
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.itami.calorie_tracker.recipes_feature.di

import com.itami.calorie_tracker.recipes_feature.data.remote.RecipesApiService
import com.itami.calorie_tracker.recipes_feature.data.remote.RecipesApiServiceImpl
import com.itami.calorie_tracker.recipes_feature.data.repository.RecipesRepositoryImpl
import com.itami.calorie_tracker.recipes_feature.domain.repository.RecipesRepository
import com.itami.calorie_tracker.recipes_feature.domain.use_case.GetRecipeByIdUseCase
import com.itami.calorie_tracker.recipes_feature.domain.use_case.GetRecipesUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object RecipesModule {

@Provides
@Singleton
fun provideGetRecipesUseCase(
recipesRepository: RecipesRepository,
) = GetRecipesUseCase(recipesRepository)

@Provides
@Singleton
fun provideGetRecipeByIdUseCase(
recipesRepository: RecipesRepository,
) = GetRecipeByIdUseCase(recipesRepository)


@Provides
@Singleton
fun provideRecipesRepository(
recipesRepositoryImpl: RecipesRepositoryImpl,
): RecipesRepository = recipesRepositoryImpl

@Provides
@Singleton
fun provideRecipesApiService(
recipesApiServiceImpl: RecipesApiServiceImpl
): RecipesApiService = recipesApiServiceImpl

}
Loading

0 comments on commit 8152ed0

Please sign in to comment.