Replace "Mealie" with "Mealient" everywhere
This commit is contained in:
20
app/src/main/java/gq/kirmanak/mealient/data/AppDb.kt
Normal file
20
app/src/main/java/gq/kirmanak/mealient/data/AppDb.kt
Normal 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
|
||||
}
|
||||
20
app/src/main/java/gq/kirmanak/mealient/data/AppModule.kt
Normal file
20
app/src/main/java/gq/kirmanak/mealient/data/AppModule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
15
app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt
Normal file
15
app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package gq.kirmanak.mealient.data.recipes
|
||||
|
||||
import android.widget.ImageView
|
||||
|
||||
interface RecipeImageLoader {
|
||||
suspend fun loadRecipeImage(view: ImageView, slug: String?)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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')"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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')"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user