Replace "Mealie" with "Mealient" everywhere

This commit is contained in:
Kirill Kamakin
2021-11-20 13:41:47 +03:00
parent d789bfcf97
commit 5866584d14
81 changed files with 283 additions and 284 deletions

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.data
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import gq.kirmanak.mealient.data.impl.RoomTypeConverters
import gq.kirmanak.mealient.data.recipes.db.RecipeDao
import gq.kirmanak.mealient.data.recipes.db.entity.*
import javax.inject.Singleton
@Database(
version = 1,
entities = [CategoryEntity::class, CategoryRecipeEntity::class, TagEntity::class, TagRecipeEntity::class, RecipeSummaryEntity::class, RecipeEntity::class, RecipeIngredientEntity::class, RecipeInstructionEntity::class],
exportSchema = false
)
@TypeConverters(RoomTypeConverters::class)
@Singleton
abstract class AppDb : RoomDatabase() {
abstract fun recipeDao(): RecipeDao
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.data
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface AppModule {
companion object {
@Provides
fun createDb(@ApplicationContext context: Context): AppDb {
return Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
}
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.auth
interface AuthDataSource {
/**
* Tries to acquire authentication token using the provided credentials on specified server.
*/
suspend fun authenticate(username: String, password: String, baseUrl: String): String
}

View File

@@ -0,0 +1,26 @@
package gq.kirmanak.mealient.data.auth
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi
@ExperimentalCoroutinesApi
@ExperimentalSerializationApi
@Module
@InstallIn(SingletonComponent::class)
interface AuthModule {
@Binds
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
@Binds
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
@Binds
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
}

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthRepo {
suspend fun authenticate(username: String, password: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
fun authenticationStatuses(): Flow<Boolean>
fun logout()
}

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.data.auth
import gq.kirmanak.mealient.data.auth.impl.GetTokenResponse
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface AuthService {
@FormUrlEncoded
@POST("/api/auth/token")
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String? = null,
@Field("scope") scope: String? = null,
@Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null
): GetTokenResponse
}

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
fun storeAuthData(token: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
fun tokenObservable(): Flow<String?>
fun clearAuthData()
}

View File

@@ -0,0 +1,26 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthService
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import kotlinx.serialization.ExperimentalSerializationApi
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder
) : AuthDataSource {
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>()
val response = authService.getToken(username, password)
Timber.d("authenticate() response is $response")
return response.accessToken
}
}

View File

@@ -0,0 +1,18 @@
package gq.kirmanak.mealient.data.auth.impl
import okhttp3.Interceptor
import okhttp3.Response
const val AUTHORIZATION_HEADER = "Authorization"
class AuthOkHttpInterceptor(token: String) : Interceptor {
private val headerValue = "Bearer $token"
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request()
.newBuilder()
.addHeader(AUTHORIZATION_HEADER, headerValue)
.build()
return chain.proceed(newRequest)
}
}

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage
) : AuthRepo {
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
) {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val url = if (baseUrl.startsWith("http")) baseUrl else "https://$baseUrl"
val accessToken = dataSource.authenticate(username, password, url)
Timber.d("authenticate result is $accessToken")
storage.storeAuthData(accessToken, url)
}
override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
override suspend fun getToken(): String? {
Timber.v("getToken() called")
return storage.getToken()
}
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.tokenObservable().map { it != null }
}
override fun logout() {
Timber.v("logout() called")
storage.clearAuthData()
}
}

View File

@@ -0,0 +1,83 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.data.auth.AuthStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_KEY = "AUTH_TOKEN"
private const val BASE_URL_KEY = "BASE_URL"
@ExperimentalCoroutinesApi
class AuthStorageImpl @Inject constructor(@ApplicationContext private val context: Context) :
AuthStorage {
private val sharedPreferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(context)
override fun storeAuthData(token: String, baseUrl: String) {
Timber.v("storeAuthData() called with: token = $token, baseUrl = $baseUrl")
sharedPreferences.edit()
.putString(TOKEN_KEY, token)
.putString(BASE_URL_KEY, baseUrl)
.apply()
}
override suspend fun getBaseUrl(): String? {
val baseUrl = getString(BASE_URL_KEY)
Timber.d("getBaseUrl: base url is $baseUrl")
return baseUrl
}
override suspend fun getToken(): String? {
Timber.v("getToken() called")
val token = getString(TOKEN_KEY)
Timber.d("getToken: token is $token")
return token
}
private suspend fun getString(key: String): String? = withContext(Dispatchers.Default) {
sharedPreferences.getString(key, null)
}
override fun tokenObservable(): Flow<String?> {
Timber.v("tokenObservable() called")
return callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
Timber.v("tokenObservable: listener called with key $key")
val token = when (key) {
null -> null
TOKEN_KEY -> prefs.getString(key, null)
else -> return@OnSharedPreferenceChangeListener
}
Timber.d("tokenObservable: New token: $token")
trySendBlocking(token).onFailure { Timber.e(it, "Can't send new token") }
}
Timber.v("tokenObservable: registering listener")
send(getToken())
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
Timber.v("tokenObservable: flow has been closed")
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
}
override fun clearAuthData() {
Timber.v("clearAuthData() called")
sharedPreferences.edit()
.remove(TOKEN_KEY)
.remove(BASE_URL_KEY)
.apply()
}
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.data.auth.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String
)

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.data.impl
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.data.auth.impl.AuthOkHttpInterceptor
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import javax.inject.Inject
class OkHttpBuilder @Inject constructor() {
fun buildOkHttp(token: String?): OkHttpClient {
Timber.v("buildOkHttp() called with: token = $token")
val builder = OkHttpClient.Builder()
if (BuildConfig.DEBUG) builder.addNetworkInterceptor(buildLoggingInterceptor())
if (token != null) builder.addNetworkInterceptor(AuthOkHttpInterceptor(token))
return builder.build()
}
private fun buildLoggingInterceptor(): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) }
interceptor.level = HttpLoggingInterceptor.Level.BODY
return interceptor
}
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.data.impl
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor(private val okHttpBuilder: OkHttpBuilder) {
private val json by lazy {
Json {
coerceInputValues = true
ignoreUnknownKeys = true
}
}
fun buildRetrofit(baseUrl: String, token: String? = null): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl, token = $token")
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpBuilder.buildOkHttp(token))
.addConverterFactory(converterFactory)
.build()
}
}

View File

@@ -0,0 +1,22 @@
package gq.kirmanak.mealient.data.impl
import androidx.room.TypeConverter
import kotlinx.datetime.*
object RoomTypeConverters {
@TypeConverter
fun localDateTimeToTimestamp(localDateTime: LocalDateTime) =
localDateTime.toInstant(TimeZone.UTC).toEpochMilliseconds()
@TypeConverter
fun timestampToLocalDateTime(timestamp: Long) =
Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
@TypeConverter
fun localDateToTimeStamp(date: LocalDate) =
localDateTimeToTimestamp(date.atTime(0, 0))
@TypeConverter
fun timestampToLocalDate(timestamp: Long) =
timestampToLocalDateTime(timestamp).date
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.data.recipes
import android.widget.ImageView
interface RecipeImageLoader {
suspend fun loadRecipeImage(view: ImageView, slug: String?)
}

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.data.recipes
import androidx.paging.ExperimentalPagingApi
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import kotlinx.serialization.ExperimentalSerializationApi
@ExperimentalPagingApi
@ExperimentalSerializationApi
@Module
@InstallIn(SingletonComponent::class)
interface RecipeModule {
@Binds
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
@Binds
fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
@Binds
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
@Binds
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.data.recipes
import androidx.paging.Pager
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
interface RecipeRepo {
fun createPager(): Pager<Int, RecipeSummaryEntity>
suspend fun clearLocalData()
suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo
}

View File

@@ -0,0 +1,76 @@
package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
import androidx.room.*
import gq.kirmanak.mealient.data.recipes.db.entity.*
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
@Dao
interface RecipeDao {
@Query("SELECT * FROM tags")
suspend fun queryAllTags(): List<TagEntity>
@Query("SELECT * FROM categories")
suspend fun queryAllCategories(): List<CategoryEntity>
@Query("SELECT * FROM recipe_summaries ORDER BY date_added DESC")
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipeSummaryEntity: RecipeSummaryEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTag(tagEntity: TagEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTagRecipeEntity(tagRecipeEntity: TagRecipeEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategory(categoryEntity: CategoryEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryRecipeEntity(categoryRecipeEntity: CategoryRecipeEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTagRecipeEntities(tagRecipeEntities: Set<TagRecipeEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryRecipeEntities(categoryRecipeEntities: Set<CategoryRecipeEntity>)
@Query("DELETE FROM recipe_summaries")
suspend fun removeAllRecipes()
@Query("DELETE FROM tags")
suspend fun removeAllTags()
@Query("DELETE FROM categories")
suspend fun removeAllCategories()
@Query("SELECT * FROM recipe_summaries ORDER BY date_updated DESC")
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
@Query("SELECT * FROM category_recipe")
suspend fun queryAllCategoryRecipes(): List<CategoryRecipeEntity>
@Query("SELECT * FROM tag_recipe")
suspend fun queryAllTagRecipes(): List<TagRecipeEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipe: RecipeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeInstructions(instructions: List<RecipeInstructionEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
@Transaction
@Query("SELECT * FROM recipe JOIN recipe_summaries ON recipe.remote_id = recipe_summaries.remote_id JOIN recipe_ingredient ON recipe_ingredient.recipe_id = recipe.remote_id JOIN recipe_instruction ON recipe_instruction.recipe_id = recipe.remote_id WHERE recipe.remote_id = :recipeId")
suspend fun queryFullRecipeInfo(recipeId: Long): FullRecipeInfo?
@Query("DELETE FROM recipe_ingredient WHERE recipe_id = :recipeId")
suspend fun deleteRecipeIngredients(recipeId: Long)
@Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId")
suspend fun deleteRecipeInstructions(recipeId: Long)
}

View File

@@ -0,0 +1,21 @@
package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
interface RecipeStorage {
suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>)
fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity>
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
suspend fun clearAllLocalData()
suspend fun saveRecipeInfo(recipe: GetRecipeResponse)
suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo
}

View File

@@ -0,0 +1,171 @@
package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
import androidx.room.withTransaction
import gq.kirmanak.mealient.data.AppDb
import gq.kirmanak.mealient.data.recipes.db.entity.*
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeIngredientResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeInstructionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import timber.log.Timber
import javax.inject.Inject
class RecipeStorageImpl @Inject constructor(
private val db: AppDb
) : RecipeStorage {
private val recipeDao: RecipeDao by lazy { db.recipeDao() }
override suspend fun saveRecipes(
recipes: List<GetRecipeSummaryResponse>
) = db.withTransaction {
Timber.v("saveRecipes() called with $recipes")
val tagEntities = mutableSetOf<TagEntity>()
tagEntities.addAll(recipeDao.queryAllTags())
val categoryEntities = mutableSetOf<CategoryEntity>()
categoryEntities.addAll(recipeDao.queryAllCategories())
val tagRecipeEntities = mutableSetOf<TagRecipeEntity>()
val categoryRecipeEntities = mutableSetOf<CategoryRecipeEntity>()
for (recipe in recipes) {
val recipeSummaryEntity = recipe.recipeEntity()
recipeDao.insertRecipe(recipeSummaryEntity)
for (tag in recipe.tags) {
val tagId = getIdOrInsert(tagEntities, tag)
tagRecipeEntities += TagRecipeEntity(tagId, recipeSummaryEntity.remoteId)
}
for (category in recipe.recipeCategories) {
val categoryId = getOrInsert(categoryEntities, category)
categoryRecipeEntities += CategoryRecipeEntity(
categoryId,
recipeSummaryEntity.remoteId
)
}
}
recipeDao.insertTagRecipeEntities(tagRecipeEntities)
recipeDao.insertCategoryRecipeEntities(categoryRecipeEntities)
}
private suspend fun getOrInsert(
categoryEntities: MutableSet<CategoryEntity>,
category: String
): Long {
val existingCategory = categoryEntities.find { it.name == category }
val categoryId = if (existingCategory == null) {
val categoryEntity = CategoryEntity(name = category)
val newId = recipeDao.insertCategory(categoryEntity)
categoryEntities.add(categoryEntity.copy(localId = newId))
newId
} else {
existingCategory.localId
}
return categoryId
}
private suspend fun getIdOrInsert(
tagEntities: MutableSet<TagEntity>,
tag: String
): Long {
val existingTag = tagEntities.find { it.name == tag }
val tagId = if (existingTag == null) {
val tagEntity = TagEntity(name = tag)
val newId = recipeDao.insertTag(tagEntity)
tagEntities.add(tagEntity.copy(localId = newId))
newId
} else {
existingTag.localId
}
return tagId
}
private fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
remoteId = remoteId,
name = name,
slug = slug,
image = image,
description = description,
rating = rating,
dateAdded = dateAdded,
dateUpdated = dateUpdated,
)
override fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity> {
Timber.v("queryRecipes() called")
return recipeDao.queryRecipesByPages()
}
override suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>) {
Timber.v("refreshAll() called with: recipes = $recipes")
db.withTransaction {
recipeDao.removeAllRecipes()
saveRecipes(recipes)
}
}
override suspend fun clearAllLocalData() {
Timber.v("clearAllLocalData() called")
db.withTransaction {
recipeDao.removeAllRecipes()
recipeDao.removeAllCategories()
recipeDao.removeAllTags()
}
}
override suspend fun saveRecipeInfo(recipe: GetRecipeResponse) {
Timber.v("saveRecipeInfo() called with: recipe = $recipe")
db.withTransaction {
recipeDao.insertRecipe(recipe.toRecipeEntity())
recipeDao.deleteRecipeIngredients(recipe.remoteId)
val ingredients = recipe.recipeIngredients.map {
it.toRecipeIngredientEntity(recipe.remoteId)
}
recipeDao.insertRecipeIngredients(ingredients)
recipeDao.deleteRecipeInstructions(recipe.remoteId)
val instructions = recipe.recipeInstructions.map {
it.toRecipeInstructionEntity(recipe.remoteId)
}
recipeDao.insertRecipeInstructions(instructions)
}
}
private fun GetRecipeResponse.toRecipeEntity() = RecipeEntity(
remoteId = remoteId,
recipeYield = recipeYield
)
private fun GetRecipeIngredientResponse.toRecipeIngredientEntity(remoteId: Long) =
RecipeIngredientEntity(
recipeId = remoteId,
title = title,
note = note,
unit = unit,
food = food,
disableAmount = disableAmount,
quantity = quantity
)
private fun GetRecipeInstructionResponse.toRecipeInstructionEntity(remoteId: Long) =
RecipeInstructionEntity(
recipeId = remoteId,
title = title,
text = text
)
override suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo {
Timber.v("queryRecipeInfo() called with: recipeId = $recipeId")
val fullRecipeInfo = checkNotNull(recipeDao.queryFullRecipeInfo(recipeId)) {
"Can't find recipe by id $recipeId in DB"
}
Timber.v("queryRecipeInfo() returned: $fullRecipeInfo")
return fullRecipeInfo
}
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "categories", indices = [Index(value = ["name"], unique = true)])
data class CategoryEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "name") val name: String,
)

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "category_recipe",
primaryKeys = ["category_id", "recipe_id"],
indices = [Index(value = ["category_id", "recipe_id"], unique = true)],
foreignKeys = [ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["local_id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
), ForeignKey(
entity = RecipeSummaryEntity::class,
parentColumns = ["remote_id"],
childColumns = ["recipe_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)]
)
data class CategoryRecipeEntity(
@ColumnInfo(name = "category_id") val categoryId: Long,
@ColumnInfo(name = "recipe_id", index = true) val recipeId: Long
)

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recipe")
data class RecipeEntity(
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long,
@ColumnInfo(name = "recipe_yield") val recipeYield: String,
)

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recipe_ingredient")
data class RecipeIngredientEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "recipe_id") val recipeId: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "note") val note: String,
@ColumnInfo(name = "unit") val unit: String,
@ColumnInfo(name = "food") val food: String,
@ColumnInfo(name = "disable_amount") val disableAmount: Boolean,
@ColumnInfo(name = "quantity") val quantity: Int,
)

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recipe_instruction")
data class RecipeInstructionEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "recipe_id") val recipeId: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "text") val text: String,
)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
@Entity(tableName = "recipe_summaries")
data class RecipeSummaryEntity(
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "slug") val slug: String,
@ColumnInfo(name = "image") val image: String,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "rating") val rating: Int?,
@ColumnInfo(name = "date_added") val dateAdded: LocalDate,
@ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime
) {
override fun toString(): String {
return "RecipeEntity(remoteId=$remoteId, name='$name')"
}
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "tags", indices = [Index(value = ["name"], unique = true)])
data class TagEntity(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "name") val name: String
)

View File

@@ -0,0 +1,27 @@
package gq.kirmanak.mealient.data.recipes.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "tag_recipe",
primaryKeys = ["tag_id", "recipe_id"],
foreignKeys = [ForeignKey(
entity = TagEntity::class,
parentColumns = ["local_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
), ForeignKey(
entity = RecipeSummaryEntity::class,
parentColumns = ["remote_id"],
childColumns = ["recipe_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
)]
)
data class TagRecipeEntity(
@ColumnInfo(name = "tag_id") val tagId: Long,
@ColumnInfo(name = "recipe_id", index = true) val recipeId: Long
)

View File

@@ -0,0 +1,27 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.room.Embedded
import androidx.room.Relation
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
data class FullRecipeInfo(
@Embedded val recipeEntity: RecipeEntity,
@Relation(
parentColumn = "remote_id",
entityColumn = "remote_id"
)
val recipeSummaryEntity: RecipeSummaryEntity,
@Relation(
parentColumn = "remote_id",
entityColumn = "recipe_id"
)
val recipeIngredients: List<RecipeIngredientEntity>,
@Relation(
parentColumn = "remote_id",
entityColumn = "recipe_id"
)
val recipeInstructions: List<RecipeInstructionEntity>,
)

View File

@@ -0,0 +1,21 @@
package gq.kirmanak.mealient.data.recipes.impl
import android.widget.ImageView
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.ui.ImageLoader
import javax.inject.Inject
class RecipeImageLoaderImpl @Inject constructor(
private val imageLoader: ImageLoader,
private val authRepo: AuthRepo
): RecipeImageLoader {
override suspend fun loadRecipeImage(view: ImageView, slug: String?) {
val baseUrl = authRepo.getBaseUrl()
val recipeImageUrl =
if (baseUrl.isNullOrBlank() || slug.isNullOrBlank()) null
else "$baseUrl/api/media/recipes/$slug/images/original.webp"
imageLoader.loadImage(recipeImageUrl, R.drawable.placeholder_recipe, view)
}
}

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.PagingSource
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipePagingSourceFactory @Inject constructor(
private val recipeStorage: RecipeStorage
) : () -> PagingSource<Int, RecipeSummaryEntity> {
private val sources: MutableList<PagingSource<Int, RecipeSummaryEntity>> = mutableListOf()
override fun invoke(): PagingSource<Int, RecipeSummaryEntity> {
Timber.v("invoke() called")
val newSource = recipeStorage.queryRecipes()
sources.add(newSource)
return newSource
}
fun invalidate() {
Timber.v("invalidate() called")
for (source in sources) {
if (!source.invalid) {
source.invalidate()
}
}
sources.removeAll { it.invalid }
}
}

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
@ExperimentalPagingApi
class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val dataSource: RecipeDataSource,
) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> {
Timber.v("createPager() called")
val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true)
return Pager(
config = pagingConfig,
remoteMediator = mediator,
pagingSourceFactory = pagingSourceFactory
)
}
override suspend fun clearLocalData() {
Timber.v("clearLocalData() called")
storage.clearAllLocalData()
}
override suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
try {
storage.saveRecipeInfo(dataSource.requestRecipeInfo(recipeSlug))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Timber.e(e, "loadRecipeInfo: can't update full recipe info")
}
return storage.queryRecipeInfo(recipeId)
}
}

View File

@@ -0,0 +1,63 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.annotation.VisibleForTesting
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.LoadType.PREPEND
import androidx.paging.LoadType.REFRESH
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
@ExperimentalPagingApi
class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
) : RemoteMediator<Int, RecipeSummaryEntity>() {
@VisibleForTesting
var lastRequestEnd: Int = 0
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, RecipeSummaryEntity>
): MediatorResult {
Timber.v("load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state")
if (loadType == PREPEND) {
Timber.i("load: early exit, PREPEND isn't supported")
return MediatorResult.Success(endOfPaginationReached = true)
}
val start = if (loadType == REFRESH) 0 else lastRequestEnd
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
val count: Int = try {
val recipes = network.requestRecipes(start, limit)
if (loadType == REFRESH) storage.refreshAll(recipes)
else storage.saveRecipes(recipes)
recipes.size
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Timber.e(e, "Can't load recipes")
return MediatorResult.Error(e)
}
// After something is inserted into DB the paging sources have to be invalidated
// But for some reason Room/Paging library don't do it automatically
// Here we invalidate them manually.
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
pagingSourceFactory.invalidate()
Timber.d("load: expectedCount = $limit, received $count")
lastRequestEnd = start + count
return MediatorResult.Success(endOfPaginationReached = count < limit)
}
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
interface RecipeDataSource {
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(slug: String): GetRecipeResponse
}

View File

@@ -0,0 +1,50 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import kotlinx.serialization.ExperimentalSerializationApi
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class RecipeDataSourceImpl @Inject constructor(
private val authRepo: AuthRepo,
private val retrofitBuilder: RetrofitBuilder
) : RecipeDataSource {
private var _recipeService: RecipeService? = null
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
val service: RecipeService = getRecipeService()
val recipeSummary = service.getRecipeSummary(start, limit)
Timber.v("requestRecipes() returned: $recipeSummary")
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
Timber.v("requestRecipeInfo() called with: slug = $slug")
val service: RecipeService = getRecipeService()
val recipeInfo = service.getRecipe(slug)
Timber.v("requestRecipeInfo() returned: $recipeInfo")
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called")
val cachedService: RecipeService? = _recipeService
val service: RecipeService = if (cachedService == null) {
val baseUrl = checkNotNull(authRepo.getBaseUrl()) { "Base url is null" }
val token = checkNotNull(authRepo.getToken()) { "Token is null" }
Timber.d("requestRecipes: baseUrl = $baseUrl, token = $token")
val retrofit = retrofitBuilder.buildRetrofit(baseUrl, token)
val createdService = retrofit.create(RecipeService::class.java)
_recipeService = createdService
createdService
} else {
cachedService
}
return service
}
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface RecipeService {
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String
): GetRecipeResponse
}

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeIngredientResponse(
@SerialName("title") val title: String = "",
@SerialName("note") val note: String = "",
@SerialName("unit") val unit: String = "",
@SerialName("food") val food: String = "",
@SerialName("disableAmount") val disableAmount: Boolean,
@SerialName("quantity") val quantity: Int,
)

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeInstructionResponse(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String,
)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse>,
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse>,
)

View File

@@ -0,0 +1,24 @@
package gq.kirmanak.mealient.data.recipes.network.response
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime
) {
override fun toString(): String {
return "GetRecipeSummaryResponse(remoteId=$remoteId, name='$name')"
}
}