Merge pull request #76 from kirmanak/v1-support

Add V1 support
This commit is contained in:
Kirill Kamakin
2022-10-30 13:59:32 +01:00
committed by GitHub
106 changed files with 2682 additions and 1008 deletions

View File

@@ -1,7 +1,6 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
interface AddRecipeDataSource {
suspend fun addRecipe(recipe: AddRecipeRequest): String
suspend fun addRecipe(recipe: AddRecipeInfo): String
}

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.data.add
data class AddRecipeInfo(
val name: String,
val description: String,
val recipeYield: String,
val recipeIngredient: List<AddRecipeIngredientInfo>,
val recipeInstructions: List<AddRecipeInstructionInfo>,
val settings: AddRecipeSettingsInfo,
)
data class AddRecipeSettingsInfo(
val disableComments: Boolean,
val public: Boolean,
)
data class AddRecipeIngredientInfo(
val note: String,
)
data class AddRecipeInstructionInfo(
val text: String,
)

View File

@@ -1,13 +1,12 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow
interface AddRecipeRepo {
val addRecipeRequestFlow: Flow<AddRecipeRequest>
val addRecipeRequestFlow: Flow<AddRecipeInfo>
suspend fun preserve(recipe: AddRecipeRequest)
suspend fun preserve(recipe: AddRecipeInfo)
suspend fun clear()

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.extensions.toAddRecipeRequest
import gq.kirmanak.mealient.extensions.toAddRecipeInfo
import gq.kirmanak.mealient.extensions.toDraft
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
@@ -20,10 +20,10 @@ class AddRecipeRepoImpl @Inject constructor(
private val logger: Logger,
) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest>
get() = addRecipeStorage.updates.map { it.toAddRecipeRequest() }
override val addRecipeRequestFlow: Flow<AddRecipeInfo>
get() = addRecipeStorage.updates.map { it.toAddRecipeInfo() }
override suspend fun preserve(recipe: AddRecipeRequest) {
override suspend fun preserve(recipe: AddRecipeInfo) {
logger.v { "preserveRecipe() called with: recipe = $recipe" }
addRecipeStorage.save(recipe.toDraft())
}

View File

@@ -4,5 +4,5 @@ interface AuthDataSource {
/**
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(username: String, password: String, baseUrl: String): String
suspend fun authenticate(username: String, password: String): String
}

View File

@@ -1,15 +1,28 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthDataSourceImpl @Inject constructor(
private val mealieDataSource: MealieDataSource,
private val serverInfoRepo: ServerInfoRepo,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
) : AuthDataSource {
override suspend fun authenticate(username: String, password: String, baseUrl: String): String =
mealieDataSource.authenticate(baseUrl, username, password)
override suspend fun authenticate(
username: String,
password: String,
): String {
val baseUrl = serverInfoRepo.requireUrl()
return when (serverInfoRepo.getVersion()) {
ServerVersion.V0 -> v0Source.authenticate(baseUrl, username, password)
ServerVersion.V1 -> v1Source.authenticate(baseUrl, username, password)
}
}
}

View File

@@ -3,8 +3,7 @@ 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 gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -15,7 +14,6 @@ import javax.inject.Singleton
class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : AuthRepo {
@@ -24,9 +22,9 @@ class AuthRepoImpl @Inject constructor(
override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
authDataSource.authenticate(email, password, baseURLStorage.requireBaseURL())
.let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) }
val token = authDataSource.authenticate(email, password)
val header = AUTH_HEADER_FORMAT.format(token)
authStorage.setAuthHeader(header)
authStorage.setEmail(email)
authStorage.setPassword(password)
}

View File

@@ -1,10 +0,0 @@
package gq.kirmanak.mealient.data.baseurl
interface BaseURLStorage {
suspend fun getBaseURL(): String?
suspend fun requireBaseURL(): String
suspend fun storeBaseURL(baseURL: String)
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.data.baseurl
interface ServerInfoRepo {
suspend fun getUrl(): String?
suspend fun requireUrl(): String
suspend fun getVersion(): ServerVersion
suspend fun storeBaseURL(baseURL: String, version: String)
}

View File

@@ -0,0 +1,52 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerInfoRepoImpl @Inject constructor(
private val serverInfoStorage: ServerInfoStorage,
private val versionDataSource: VersionDataSource,
private val logger: Logger,
) : ServerInfoRepo {
override suspend fun getUrl(): String? {
val result = serverInfoStorage.getBaseURL()
logger.v { "getUrl() returned: $result" }
return result
}
override suspend fun requireUrl(): String {
val result = checkNotNull(getUrl()) { "Server URL was null when it was required" }
logger.v { "requireUrl() returned: $result" }
return result
}
override suspend fun getVersion(): ServerVersion {
var version = serverInfoStorage.getServerVersion()
val serverVersion = if (version == null) {
logger.d { "getVersion: version is null, requesting" }
version = versionDataSource.getVersionInfo(requireUrl()).version
val result = determineServerVersion(version)
serverInfoStorage.storeServerVersion(version)
result
} else {
determineServerVersion(version)
}
logger.v { "getVersion() returned: $serverVersion from $version" }
return serverVersion
}
private fun determineServerVersion(version: String): ServerVersion = when {
version.startsWith("v0") -> ServerVersion.V0
version.startsWith("v1") -> ServerVersion.V1
else -> throw NetworkError.NotMealie(IllegalStateException("Server version is unknown: $version"))
}
override suspend fun storeBaseURL(baseURL: String, version: String) {
logger.v { "storeBaseURL() called with: baseURL = $baseURL, version = $version" }
serverInfoStorage.storeBaseURL(baseURL, version)
}
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.data.baseurl
interface ServerInfoStorage {
suspend fun getBaseURL(): String?
suspend fun storeBaseURL(baseURL: String, version: String)
suspend fun storeServerVersion(version: String)
suspend fun getServerVersion(): String?
}

View File

@@ -0,0 +1,3 @@
package gq.kirmanak.mealient.data.baseurl
enum class ServerVersion { V0, V1 }

View File

@@ -0,0 +1,37 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.extensions.toVersionInfo
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
val responses = coroutineScope {
val v0Deferred = async {
runCatchingExceptCancel { v0Source.getVersionInfo(baseUrl).toVersionInfo() }
}
val v1Deferred = async {
runCatchingExceptCancel { v1Source.getVersionInfo(baseUrl).toVersionInfo() }
}
listOf(v0Deferred, v1Deferred).awaitAll()
}
val firstSuccess = responses.firstNotNullOfOrNull { it.getOrNull() }
if (firstSuccess == null) {
throw responses.firstNotNullOf { it.exceptionOrNull() }
} else {
return firstSuccess
}
}
}

View File

@@ -1,7 +1,5 @@
package gq.kirmanak.mealient.data.baseurl
data class VersionInfo(
val production: Boolean,
val version: String,
val demoStatus: Boolean,
)

View File

@@ -1,26 +0,0 @@
package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BaseURLStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
) : BaseURLStorage {
private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey)
override suspend fun requireBaseURL(): String = checkNotNull(getBaseURL()) {
"Base URL was null when it was required"
}
override suspend fun storeBaseURL(baseURL: String) {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
}

View File

@@ -0,0 +1,36 @@
package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerInfoStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
) : ServerInfoStorage {
private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
private val serverVersionKey: Preferences.Key<String>
get() = preferencesStorage.serverVersionKey
override suspend fun getBaseURL(): String? = getValue(baseUrlKey)
override suspend fun storeBaseURL(baseURL: String, version: String) {
preferencesStorage.storeValues(
Pair(baseUrlKey, baseURL),
Pair(serverVersionKey, version),
)
}
override suspend fun getServerVersion(): String? = getValue(serverVersionKey)
override suspend fun storeServerVersion(version: String) {
preferencesStorage.storeValues(Pair(serverVersionKey, version))
}
private suspend fun <T> getValue(key: Preferences.Key<T>): T? = preferencesStorage.getValue(key)
}

View File

@@ -1,49 +1,80 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.toVersionInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.extensions.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceWrapper @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo,
private val mealieDataSource: MealieDataSource,
) : AddRecipeDataSource, RecipeDataSource, VersionDataSource {
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
) : AddRecipeDataSource, RecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String =
withAuthHeader { token -> addRecipe(getUrl(), token, recipe) }
override suspend fun addRecipe(
recipe: AddRecipeInfo,
): String = makeCall { token, url, version ->
when (version) {
ServerVersion.V0 -> v0Source.addRecipe(url, token, recipe.toV0Request())
ServerVersion.V1 -> {
val slug = v1Source.createRecipe(url, token, recipe.toV1CreateRequest())
v1Source.updateRecipe(url, token, slug, recipe.toV1UpdateRequest())
slug
}
}
}
override suspend fun getVersionInfo(baseUrl: String): VersionInfo =
mealieDataSource.getVersionInfo(baseUrl).toVersionInfo()
override suspend fun requestRecipes(
start: Int,
limit: Int,
): List<RecipeSummaryInfo> = makeCall { token, url, version ->
when (version) {
ServerVersion.V0 -> {
v0Source.requestRecipes(url, token, start, limit).map { it.toRecipeSummaryInfo() }
}
ServerVersion.V1 -> {
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
val page = start / limit + 1
v1Source.requestRecipes(url, token, page, limit).map { it.toRecipeSummaryInfo() }
}
}
}
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> =
withAuthHeader { token -> requestRecipes(getUrl(), token, start, limit) }
override suspend fun requestRecipeInfo(
slug: String,
): FullRecipeInfo = makeCall { token, url, version ->
when (version) {
ServerVersion.V0 -> v0Source.requestRecipeInfo(url, token, slug).toFullRecipeInfo()
ServerVersion.V1 -> v1Source.requestRecipeInfo(url, token, slug).toFullRecipeInfo()
}
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse =
withAuthHeader { token -> requestRecipeInfo(getUrl(), token, slug) }
private suspend fun getUrl() = baseURLStorage.requireBaseURL()
private suspend inline fun <T> withAuthHeader(block: MealieDataSource.(String?) -> T): T =
mealieDataSource.runCatching { block(authRepo.getAuthHeader()) }.getOrElse {
private suspend inline fun <T> makeCall(block: (String?, String, ServerVersion) -> T): T {
val authHeader = authRepo.getAuthHeader()
val url = serverInfoRepo.requireUrl()
val version = serverInfoRepo.getVersion()
return runCatchingExceptCancel { block(authHeader, url, version) }.getOrElse {
if (it is NetworkError.Unauthorized) {
authRepo.invalidateAuthHeader()
// Trying again with new authentication header
mealieDataSource.block(authRepo.getAuthHeader())
val newHeader = authRepo.getAuthHeader()
if (newHeader == authHeader) throw it else block(newHeader, url, version)
} else {
throw it
}
}
}
}

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.recipes
import androidx.paging.Pager
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipeRepo {
@@ -9,5 +9,5 @@ interface RecipeRepo {
suspend fun clearLocalData()
suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo
suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity
}

View File

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

View File

@@ -2,11 +2,12 @@ package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
import androidx.room.withTransaction
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
import gq.kirmanak.mealient.database.AppDb
import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.*
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.recipeEntity
import gq.kirmanak.mealient.extensions.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
@@ -23,71 +24,14 @@ class RecipeStorageImpl @Inject constructor(
private val recipeDao: RecipeDao by lazy { db.recipeDao() }
override suspend fun saveRecipes(
recipes: List<GetRecipeSummaryResponse>
recipes: List<RecipeSummaryInfo>
) = db.withTransaction {
logger.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
}
@@ -96,7 +40,7 @@ class RecipeStorageImpl @Inject constructor(
return recipeDao.queryRecipesByPages()
}
override suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>) {
override suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) {
logger.v { "refreshAll() called with: recipes = $recipes" }
db.withTransaction {
recipeDao.removeAllRecipes()
@@ -108,12 +52,10 @@ class RecipeStorageImpl @Inject constructor(
logger.v { "clearAllLocalData() called" }
db.withTransaction {
recipeDao.removeAllRecipes()
recipeDao.removeAllCategories()
recipeDao.removeAllTags()
}
}
override suspend fun saveRecipeInfo(recipe: GetRecipeResponse) {
override suspend fun saveRecipeInfo(recipe: FullRecipeInfo) {
logger.v { "saveRecipeInfo() called with: recipe = $recipe" }
db.withTransaction {
recipeDao.insertRecipe(recipe.toRecipeEntity())
@@ -132,7 +74,7 @@ class RecipeStorageImpl @Inject constructor(
}
}
override suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo {
override suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity {
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
val fullRecipeInfo = checkNotNull(recipeDao.queryFullRecipeInfo(recipeId)) {
"Can't find recipe by id $recipeId in DB"

View File

@@ -1,6 +1,6 @@
package gq.kirmanak.mealient.data.recipes.impl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.logging.Logger
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
@@ -8,7 +8,7 @@ import javax.inject.Singleton
@Singleton
class RecipeImageUrlProviderImpl @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val serverInfoRepo: ServerInfoRepo,
private val logger: Logger,
) : RecipeImageUrlProvider {
@@ -16,7 +16,7 @@ class RecipeImageUrlProviderImpl @Inject constructor(
logger.v { "generateImageUrl() called with: slug = $slug" }
slug?.takeUnless { it.isBlank() } ?: return null
val imagePath = IMAGE_PATH_FORMAT.format(slug)
val baseUrl = baseURLStorage.getBaseURL()?.takeUnless { it.isEmpty() }
val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() }
val result = baseUrl?.toHttpUrlOrNull()
?.newBuilder()
?.addPathSegments(imagePath)

View File

@@ -7,9 +7,9 @@ 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.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,7 +38,7 @@ class RecipeRepoImpl @Inject constructor(
storage.clearAllLocalData()
}
override suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo {
override suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity {
logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" }
runCatchingExceptCancel {

View File

@@ -7,7 +7,7 @@ import androidx.paging.LoadType.REFRESH
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.data.recipes.network
data class FullRecipeInfo(
val remoteId: String,
val name: String,
val recipeYield: String,
val recipeIngredients: List<RecipeIngredientInfo>,
val recipeInstructions: List<RecipeInstructionInfo>,
)
data class RecipeIngredientInfo(
val note: String,
)
data class RecipeInstructionInfo(
val text: String,
)

View File

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

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.data.recipes.network
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
data class RecipeSummaryInfo(
val remoteId: String,
val name: String,
val slug: String,
val description: String = "",
val imageId: String,
val dateAdded: LocalDate,
val dateUpdated: LocalDateTime
)

View File

@@ -7,6 +7,8 @@ interface PreferencesStorage {
val baseUrlKey: Preferences.Key<String>
val serverVersionKey: Preferences.Key<String>
val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
suspend fun <T> getValue(key: Preferences.Key<T>): T?

View File

@@ -18,6 +18,8 @@ class PreferencesStorageImpl @Inject constructor(
override val baseUrlKey = stringPreferencesKey("baseUrl")
override val serverVersionKey = stringPreferencesKey("serverVersion")
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {

View File

@@ -4,10 +4,8 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.baseurl.*
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import javax.inject.Singleton
@Module
@@ -16,9 +14,13 @@ interface BaseURLModule {
@Binds
@Singleton
fun bindVersionDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): VersionDataSource
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource
@Binds
@Singleton
fun bindBaseUrlStorage(baseURLStorageImpl: BaseURLStorageImpl): BaseURLStorage
fun bindBaseUrlStorage(baseURLStorageImpl: ServerInfoStorageImpl): ServerInfoStorage
@Binds
@Singleton
fun bindServerInfoRepo(serverInfoRepoImpl: ServerInfoRepoImpl): ServerInfoRepo
}

View File

@@ -1,15 +0,0 @@
package gq.kirmanak.mealient.extensions
import kotlinx.coroutines.CancellationException
/**
* Like [runCatching] but rethrows [CancellationException] to support
* cancellation of coroutines.
*/
inline fun <T> runCatchingExceptCancel(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}

View File

@@ -0,0 +1,173 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeIngredientInfo
import gq.kirmanak.mealient.data.add.AddRecipeInstructionInfo
import gq.kirmanak.mealient.data.add.AddRecipeSettingsInfo
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeIngredientInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeInstructionInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.v0.models.*
import gq.kirmanak.mealient.datasource.v1.models.*
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import java.util.*
fun FullRecipeInfo.toRecipeEntity() = RecipeEntity(
remoteId = remoteId,
recipeYield = recipeYield
)
fun RecipeIngredientInfo.toRecipeIngredientEntity(remoteId: String) = RecipeIngredientEntity(
recipeId = remoteId,
note = note,
)
fun RecipeInstructionInfo.toRecipeInstructionEntity(remoteId: String) = RecipeInstructionEntity(
recipeId = remoteId,
text = text
)
fun GetRecipeSummaryResponseV0.toRecipeSummaryInfo() = RecipeSummaryInfo(
remoteId = remoteId.toString(),
name = name,
slug = slug,
description = description,
dateAdded = dateAdded,
dateUpdated = dateUpdated,
imageId = slug,
)
fun GetRecipeSummaryResponseV1.toRecipeSummaryInfo() = RecipeSummaryInfo(
remoteId = remoteId,
name = name,
slug = slug,
description = description,
dateAdded = dateAdded,
dateUpdated = dateUpdated,
imageId = remoteId,
)
fun RecipeSummaryInfo.recipeEntity() = RecipeSummaryEntity(
remoteId = remoteId,
name = name,
slug = slug,
description = description,
dateAdded = dateAdded,
dateUpdated = dateUpdated,
imageId = imageId,
)
fun VersionResponseV0.toVersionInfo() = VersionInfo(version)
fun VersionResponseV1.toVersionInfo() = VersionInfo(version)
fun AddRecipeDraft.toAddRecipeInfo() = AddRecipeInfo(
name = recipeName,
description = recipeDescription,
recipeYield = recipeYield,
recipeIngredient = recipeIngredients.map { AddRecipeIngredientInfo(note = it) },
recipeInstructions = recipeInstructions.map { AddRecipeInstructionInfo(text = it) },
settings = AddRecipeSettingsInfo(
public = isRecipePublic,
disableComments = areCommentsDisabled,
)
)
fun AddRecipeInfo.toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)
fun GetRecipeResponseV0.toFullRecipeInfo() = FullRecipeInfo(
remoteId = remoteId.toString(),
name = name,
recipeYield = recipeYield,
recipeIngredients = recipeIngredients.map { it.toRecipeIngredientInfo() },
recipeInstructions = recipeInstructions.map { it.toRecipeInstructionInfo() }
)
fun GetRecipeIngredientResponseV0.toRecipeIngredientInfo() = RecipeIngredientInfo(
note = note,
)
fun GetRecipeInstructionResponseV0.toRecipeInstructionInfo() = RecipeInstructionInfo(
text = text
)
fun GetRecipeResponseV1.toFullRecipeInfo() = FullRecipeInfo(
remoteId = remoteId,
name = name,
recipeYield = recipeYield,
recipeIngredients = recipeIngredients.map { it.toRecipeIngredientInfo() },
recipeInstructions = recipeInstructions.map { it.toRecipeInstructionInfo() }
)
fun GetRecipeIngredientResponseV1.toRecipeIngredientInfo() = RecipeIngredientInfo(
note = note,
)
fun GetRecipeInstructionResponseV1.toRecipeInstructionInfo() = RecipeInstructionInfo(
text = text
)
fun AddRecipeInfo.toV0Request() = AddRecipeRequestV0(
name = name,
description = description,
recipeYield = recipeYield,
recipeIngredient = recipeIngredient.map { it.toV0Ingredient() },
recipeInstructions = recipeInstructions.map { it.toV0Instruction() },
settings = settings.toV0Settings(),
)
private fun AddRecipeSettingsInfo.toV0Settings() = AddRecipeSettingsV0(
disableComments = disableComments,
public = public,
)
private fun AddRecipeIngredientInfo.toV0Ingredient() = AddRecipeIngredientV0(
note = note,
)
private fun AddRecipeInstructionInfo.toV0Instruction() = AddRecipeInstructionV0(
text = text,
)
fun AddRecipeInfo.toV1CreateRequest() = CreateRecipeRequestV1(
name = name,
)
fun AddRecipeInfo.toV1UpdateRequest() = UpdateRecipeRequestV1(
description = description,
recipeYield = recipeYield,
recipeIngredient = recipeIngredient.map { it.toV1Ingredient() },
recipeInstructions = recipeInstructions.map { it.toV1Instruction() },
settings = settings.toV1Settings(),
)
private fun AddRecipeSettingsInfo.toV1Settings() = AddRecipeSettingsV1(
disableComments = disableComments,
public = public,
)
private fun AddRecipeIngredientInfo.toV1Ingredient() = AddRecipeIngredientV1(
id = UUID.randomUUID().toString(),
note = note,
)
private fun AddRecipeInstructionInfo.toV1Instruction() = AddRecipeInstructionV1(
id = UUID.randomUUID().toString(),
text = text,
ingredientReferences = emptyList(),
)

View File

@@ -1,67 +0,0 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
fun GetRecipeResponse.toRecipeEntity() = RecipeEntity(
remoteId = remoteId,
recipeYield = recipeYield
)
fun GetRecipeIngredientResponse.toRecipeIngredientEntity(remoteId: Long) =
RecipeIngredientEntity(
recipeId = remoteId,
title = title,
note = note,
unit = unit,
food = food,
disableAmount = disableAmount,
quantity = quantity
)
fun GetRecipeInstructionResponse.toRecipeInstructionEntity(remoteId: Long) =
RecipeInstructionEntity(
recipeId = remoteId,
title = title,
text = text
)
fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
remoteId = remoteId,
name = name,
slug = slug,
image = image,
description = description,
rating = rating,
dateAdded = dateAdded,
dateUpdated = dateUpdated,
)
fun VersionResponse.toVersionInfo() = VersionInfo(production, version, demoStatus)
fun AddRecipeDraft.toAddRecipeRequest() = AddRecipeRequest(
name = recipeName,
description = recipeDescription,
recipeYield = recipeYield,
recipeIngredient = recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = isRecipePublic,
disableComments = areCommentsDisabled,
)
)
fun AddRecipeRequest.toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)

View File

@@ -12,12 +12,12 @@ import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeIngredientInfo
import gq.kirmanak.mealient.data.add.AddRecipeInstructionInfo
import gq.kirmanak.mealient.data.add.AddRecipeSettingsInfo
import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredient
import gq.kirmanak.mealient.datasource.models.AddRecipeInstruction
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger
@@ -122,14 +122,15 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
private fun saveValues() = with(binding) {
logger.v { "saveValues() called" }
val instructions = parseInputRows(instructionsFlow).map { AddRecipeInstruction(text = it) }
val ingredients = parseInputRows(ingredientsFlow).map { AddRecipeIngredient(note = it) }
val settings = AddRecipeSettings(
val instructions =
parseInputRows(instructionsFlow).map { AddRecipeInstructionInfo(text = it) }
val ingredients = parseInputRows(ingredientsFlow).map { AddRecipeIngredientInfo(note = it) }
val settings = AddRecipeSettingsInfo(
public = publicRecipe.isChecked,
disableComments = disableComments.isChecked,
)
viewModel.preserve(
AddRecipeRequest(
AddRecipeInfo(
name = recipeNameInput.text.toString(),
description = recipeDescriptionInput.text.toString(),
recipeYield = recipeYieldInput.text.toString(),
@@ -148,7 +149,7 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
.filterNot { it.isBlank() }
.toList()
private fun onSavedInputLoaded(request: AddRecipeRequest) = with(binding) {
private fun onSavedInputLoaded(request: AddRecipeInfo) = with(binding) {
logger.v { "onSavedInputLoaded() called with: request = $request" }
recipeNameInput.setText(request.name)
recipeDescriptionInput.setText(request.description)

View File

@@ -3,9 +3,9 @@ package gq.kirmanak.mealient.ui.add
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@@ -23,8 +23,8 @@ class AddRecipeViewModel @Inject constructor(
private val _addRecipeResultChannel = Channel<Boolean>(Channel.UNLIMITED)
val addRecipeResult: Flow<Boolean> get() = _addRecipeResultChannel.receiveAsFlow()
private val _preservedAddRecipeRequestChannel = Channel<AddRecipeRequest>(Channel.UNLIMITED)
val preservedAddRecipeRequest: Flow<AddRecipeRequest>
private val _preservedAddRecipeRequestChannel = Channel<AddRecipeInfo>(Channel.UNLIMITED)
val preservedAddRecipeRequest: Flow<AddRecipeInfo>
get() = _preservedAddRecipeRequestChannel.receiveAsFlow()
fun loadPreservedRequest() {
@@ -47,7 +47,7 @@ class AddRecipeViewModel @Inject constructor(
}
}
fun preserve(request: AddRecipeRequest) {
fun preserve(request: AddRecipeInfo) {
logger.v { "preserve() called with: request = $request" }
viewModelScope.launch { addRecipeRepo.preserve(request) }
}

View File

@@ -10,7 +10,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState

View File

@@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.launch

View File

@@ -10,7 +10,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState

View File

@@ -5,9 +5,9 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.launch
@@ -15,7 +15,7 @@ import javax.inject.Inject
@HiltViewModel
class BaseURLViewModel @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val serverInfoRepo: ServerInfoRepo,
private val versionDataSource: VersionDataSource,
private val logger: Logger,
) : ViewModel() {
@@ -35,8 +35,8 @@ class BaseURLViewModel @Inject constructor(
logger.v { "checkBaseURL() called with: baseURL = $baseURL" }
val result = runCatchingExceptCancel {
// If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL)
baseURLStorage.storeBaseURL(baseURL)
val version = versionDataSource.getVersionInfo(baseURL).version
serverInfoRepo.storeBaseURL(baseURL, version)
}
logger.i { "checkBaseURL: result is $result" }
_uiState.value = OperationUiState.fromResult(result)

View File

@@ -44,7 +44,7 @@ class RecipeModelLoader private constructor(
options: Options?
): String? {
logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.imageId) }
}
override fun getHeaders(

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.ui.recipes.info
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
data class RecipeInfoUiState(
val areIngredientsVisible: Boolean = false,
val areInstructionsVisible: Boolean = false,
val recipeInfo: FullRecipeInfo? = null,
val recipeInfo: FullRecipeEntity? = null,
)

View File

@@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -20,7 +20,7 @@ class RecipeInfoViewModel @Inject constructor(
private val _uiState = MutableLiveData(RecipeInfoUiState())
val uiState: LiveData<RecipeInfoUiState> get() = _uiState
fun loadRecipeInfo(recipeId: Long, recipeSlug: String) {
fun loadRecipeInfo(recipeId: String, recipeSlug: String) {
logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" }
_uiState.value = RecipeInfoUiState()
viewModelScope.launch {

View File

@@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavDirections
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -15,7 +15,7 @@ import javax.inject.Inject
@HiltViewModel
class SplashViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage,
private val baseURLStorage: BaseURLStorage,
private val serverInfoRepo: ServerInfoRepo,
) : ViewModel() {
private val _nextDestination = MutableLiveData<NavDirections>()
val nextDestination: LiveData<NavDirections> = _nextDestination
@@ -25,7 +25,7 @@ class SplashViewModel @Inject constructor(
delay(1000)
_nextDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> SplashFragmentDirections.actionSplashFragmentToDisclaimerFragment()
baseURLStorage.getBaseURL() == null -> SplashFragmentDirections.actionSplashFragmentToBaseURLFragment()
serverInfoRepo.getUrl() == null -> SplashFragmentDirections.actionSplashFragmentToBaseURLFragment()
else -> SplashFragmentDirections.actionSplashFragmentToRecipesFragment()
}
}

View File

@@ -36,7 +36,7 @@
app:argType="string" />
<argument
android:name="recipe_id"
app:argType="long" />
app:argType="string" />
</dialog>
<fragment
android:id="@+id/disclaimerFragment"

View File

@@ -4,11 +4,13 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import io.mockk.*
@@ -27,7 +29,7 @@ class AuthRepoImplTest {
lateinit var dataSource: AuthDataSource
@MockK
lateinit var baseURLStorage: BaseURLStorage
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
@@ -40,7 +42,7 @@ class AuthRepoImplTest {
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthRepoImpl(storage, dataSource, baseURLStorage, logger)
subject = AuthRepoImpl(storage, dataSource, logger)
}
@Test
@@ -51,14 +53,9 @@ class AuthRepoImplTest {
@Test
fun `when authenticate successfully then saves to storage`() = runTest {
coEvery {
dataSource.authenticate(
eq(TEST_USERNAME),
eq(TEST_PASSWORD),
eq(TEST_BASE_URL)
)
} returns TEST_TOKEN
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerifyAll {
storage.setAuthHeader(TEST_AUTH_HEADER)
@@ -70,9 +67,9 @@ class AuthRepoImplTest {
@Test
fun `when authenticate fails then does not change storage`() = runTest {
coEvery { dataSource.authenticate(any(), any(), any()) } throws RuntimeException()
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
runCatching { subject.authenticate("invalid", "") }
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
runCatchingExceptCancel { subject.authenticate("invalid", "") }
confirmVerified(storage)
}
@@ -107,22 +104,19 @@ class AuthRepoImplTest {
fun `when invalidate with credentials then calls authenticate`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns TEST_PASSWORD
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
} returns TEST_TOKEN
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.invalidateAuthHeader()
coVerifyAll {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
}
coVerifyAll { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) }
}
@Test
fun `when invalidate with credentials and auth fails then clears email`() = runTest {
coEvery { storage.getEmail() } returns "invalid"
coEvery { storage.getPassword() } returns ""
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(any(), any(), any()) } throws RuntimeException()
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
subject.invalidateAuthHeader()
coVerify { storage.setEmail(null) }
}

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.baseurl
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import io.mockk.MockKAnnotations
import io.mockk.coEvery
@@ -15,20 +15,22 @@ import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLStorageImplTest {
class ServerInfoStorageImplTest {
@MockK(relaxUnitFun = true)
lateinit var preferencesStorage: PreferencesStorage
lateinit var subject: BaseURLStorage
lateinit var subject: ServerInfoStorage
private val baseUrlKey = stringPreferencesKey("baseUrlKey")
private val serverVersionKey = stringPreferencesKey("serverVersionKey")
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = BaseURLStorageImpl(preferencesStorage)
subject = ServerInfoStorageImpl(preferencesStorage)
every { preferencesStorage.baseUrlKey } returns baseUrlKey
every { preferencesStorage.serverVersionKey } returns serverVersionKey
}
@Test
@@ -37,30 +39,21 @@ class BaseURLStorageImplTest {
assertThat(subject.getBaseURL()).isNull()
}
@Test(expected = IllegalStateException::class)
fun `when requireBaseURL and preferences storage empty then IllegalStateException`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns null
subject.requireBaseURL()
}
@Test
fun `when getBaseUrl and preferences storage has value then value`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns "baseUrl"
assertThat(subject.getBaseURL()).isEqualTo("baseUrl")
}
@Test
fun `when requireBaseURL and preferences storage has value then value`() = runTest {
coEvery { preferencesStorage.getValue(eq(baseUrlKey)) } returns "baseUrl"
assertThat(subject.requireBaseURL()).isEqualTo("baseUrl")
}
@Test
fun `when storeBaseURL then calls preferences storage`() = runTest {
subject.storeBaseURL("baseUrl")
subject.storeBaseURL("baseUrl", "v0.5.6")
coVerify {
preferencesStorage.baseUrlKey
preferencesStorage.storeValues(eq(Pair(baseUrlKey, "baseUrl")))
preferencesStorage.storeValues(
eq(Pair(baseUrlKey, "baseUrl")),
eq(Pair(serverVersionKey, "v0.5.6")),
)
}
}
}

View File

@@ -1,16 +1,19 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.RecipeImplTestData.GET_CAKE_RESPONSE
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerifyAll
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
@@ -21,32 +24,37 @@ import java.io.IOException
class MealieDataSourceWrapperTest {
@MockK
lateinit var baseURLStorage: BaseURLStorage
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK
lateinit var mealieDataSource: MealieDataSource
lateinit var v0Source: MealieDataSourceV0
@MockK
lateinit var v1Source: MealieDataSourceV1
lateinit var subject: MealieDataSourceWrapper
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = MealieDataSourceWrapper(baseURLStorage, authRepo, mealieDataSource)
subject = MealieDataSourceWrapper(serverInfoRepo, authRepo, v0Source, v1Source)
}
@Test
fun `when withAuthHeader fails with Unauthorized then invalidates auth`() = runTest {
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { authRepo.getAuthHeader() } returns null andThen TEST_AUTH_HEADER
coEvery {
mealieDataSource.requestRecipeInfo(eq(TEST_BASE_URL), isNull(), eq("cake"))
v0Source.requestRecipeInfo(eq(TEST_BASE_URL), isNull(), eq("cake"))
} throws NetworkError.Unauthorized(IOException())
val successResponse = mockk<GetRecipeResponseV0>(relaxed = true)
coEvery {
mealieDataSource.requestRecipeInfo(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq("cake"))
} returns GET_CAKE_RESPONSE
v0Source.requestRecipeInfo(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq("cake"))
} returns successResponse
subject.requestRecipeInfo("cake")
coVerifyAll {
authRepo.getAuthHeader()

View File

@@ -3,10 +3,6 @@ package gq.kirmanak.mealient.data.recipes.db
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.database.AppDb
import gq.kirmanak.mealient.database.recipe.entity.CategoryEntity
import gq.kirmanak.mealient.database.recipe.entity.CategoryRecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.TagEntity
import gq.kirmanak.mealient.database.recipe.entity.TagRecipeEntity
import gq.kirmanak.mealient.test.HiltRobolectricTest
import gq.kirmanak.mealient.test.RecipeImplTestData.BREAD_INGREDIENT
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_BREAD_RECIPE_INGREDIENT_ENTITY
@@ -36,28 +32,6 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Inject
lateinit var appDb: AppDb
@Test
fun `when saveRecipes then saves tags`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actualTags = appDb.recipeDao().queryAllTags()
assertThat(actualTags).containsExactly(
TagEntity(localId = 1, name = "gluten"),
TagEntity(localId = 2, name = "allergic"),
TagEntity(localId = 3, name = "milk")
)
}
@Test
fun `when saveRecipes then saves categories`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllCategories()
assertThat(actual).containsExactly(
CategoryEntity(localId = 1, name = "dessert"),
CategoryEntity(localId = 2, name = "tasty"),
CategoryEntity(localId = 3, name = "porridge")
)
}
@Test
fun `when saveRecipes then saves recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
@@ -68,30 +42,6 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
)
}
@Test
fun `when saveRecipes then saves category recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllCategoryRecipes()
assertThat(actual).containsExactly(
CategoryRecipeEntity(categoryId = 1, recipeId = 1),
CategoryRecipeEntity(categoryId = 2, recipeId = 1),
CategoryRecipeEntity(categoryId = 3, recipeId = 2),
CategoryRecipeEntity(categoryId = 2, recipeId = 2)
)
}
@Test
fun `when saveRecipes then saves tag recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllTagRecipes()
assertThat(actual).containsExactly(
TagRecipeEntity(tagId = 1, recipeId = 1),
TagRecipeEntity(tagId = 2, recipeId = 1),
TagRecipeEntity(tagId = 3, recipeId = 2),
TagRecipeEntity(tagId = 1, recipeId = 2),
)
}
@Test
fun `when refreshAll then old recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
@@ -100,28 +50,6 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
assertThat(actual).containsExactly(CAKE_RECIPE_SUMMARY_ENTITY)
}
@Test
fun `when refreshAll then old category recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
val actual = appDb.recipeDao().queryAllCategoryRecipes()
assertThat(actual).containsExactly(
CategoryRecipeEntity(categoryId = 1, recipeId = 1),
CategoryRecipeEntity(categoryId = 2, recipeId = 1),
)
}
@Test
fun `when refreshAll then old tag recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
val actual = appDb.recipeDao().queryAllTagRecipes()
assertThat(actual).containsExactly(
TagRecipeEntity(tagId = 1, recipeId = 1),
TagRecipeEntity(tagId = 2, recipeId = 1),
)
}
@Test
fun `when clearAllLocalData then recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
@@ -130,27 +58,11 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
assertThat(actual).isEmpty()
}
@Test
fun `when clearAllLocalData then categories aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllCategories()
assertThat(actual).isEmpty()
}
@Test
fun `when clearAllLocalData then tags aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllTags()
assertThat(actual).isEmpty()
}
@Test
fun `when saveRecipeInfo then saves recipe info`() = runTest {
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val actual = appDb.recipeDao().queryFullRecipeInfo(1)
val actual = appDb.recipeDao().queryFullRecipeInfo("1")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
}
@@ -159,7 +71,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE)
subject.saveRecipeInfo(GET_PORRIDGE_RESPONSE)
val actual = appDb.recipeDao().queryFullRecipeInfo(2)
val actual = appDb.recipeDao().queryFullRecipeInfo("2")
assertThat(actual).isEqualTo(FULL_PORRIDGE_INFO_ENTITY)
}
@@ -169,7 +81,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val newRecipe = GET_CAKE_RESPONSE.copy(recipeIngredients = listOf(BREAD_INGREDIENT))
subject.saveRecipeInfo(newRecipe)
val actual = appDb.recipeDao().queryFullRecipeInfo(1)?.recipeIngredients
val actual = appDb.recipeDao().queryFullRecipeInfo("1")?.recipeIngredients
val expected = listOf(CAKE_BREAD_RECIPE_INGREDIENT_ENTITY.copy(localId = 3))
assertThat(actual).isEqualTo(expected)
}
@@ -180,7 +92,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val newRecipe = GET_CAKE_RESPONSE.copy(recipeInstructions = listOf(MIX_INSTRUCTION))
subject.saveRecipeInfo(newRecipe)
val actual = appDb.recipeDao().queryFullRecipeInfo(1)?.recipeInstructions
val actual = appDb.recipeDao().queryFullRecipeInfo("1")?.recipeInstructions
val expected = listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY.copy(localId = 3))
assertThat(actual).isEqualTo(expected)
}

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.recipes.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.logging.Logger
import io.mockk.MockKAnnotations
import io.mockk.coEvery
@@ -17,7 +17,7 @@ class RecipeImageUrlProviderImplTest {
lateinit var subject: RecipeImageUrlProvider
@MockK
lateinit var baseURLStorage: BaseURLStorage
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
@@ -25,7 +25,7 @@ class RecipeImageUrlProviderImplTest {
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = RecipeImageUrlProviderImpl(baseURLStorage, logger)
subject = RecipeImageUrlProviderImpl(serverInfoRepo, logger)
prepareBaseURL("https://google.com/")
}
@@ -81,6 +81,6 @@ class RecipeImageUrlProviderImplTest {
}
private fun prepareBaseURL(baseURL: String?) {
coEvery { baseURLStorage.getBaseURL() } returns baseURL
coEvery { serverInfoRepo.getUrl() } returns baseURL
}
}

View File

@@ -47,24 +47,24 @@ class RecipeRepoImplTest {
@Test
fun `when loadRecipeInfo then loads recipe`() = runTest {
coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE
coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
val actual = subject.loadRecipeInfo(1, "cake")
coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY
val actual = subject.loadRecipeInfo("1", "cake")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
}
@Test
fun `when loadRecipeInfo then saves to DB`() = runTest {
coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE
coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
subject.loadRecipeInfo(1, "cake")
coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY
subject.loadRecipeInfo("1", "cake")
coVerify { storage.saveRecipeInfo(eq(GET_CAKE_RESPONSE)) }
}
@Test
fun `when loadRecipeInfo with error then loads from DB`() = runTest {
coEvery { dataSource.requestRecipeInfo(eq("cake")) } throws RuntimeException()
coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
val actual = subject.loadRecipeInfo(1, "cake")
coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY
val actual = subject.loadRecipeInfo("1", "cake")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
}
}

View File

@@ -6,7 +6,7 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.NetworkError.Unauthorized
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
import io.mockk.MockKAnnotations

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.extensions
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredient
import gq.kirmanak.mealient.datasource.models.AddRecipeInstruction
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeIngredientInfo
import gq.kirmanak.mealient.data.add.AddRecipeInstructionInfo
import gq.kirmanak.mealient.data.add.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import org.junit.Test
@@ -22,42 +22,42 @@ class RemoteToLocalMappingsTest {
areCommentsDisabled = true,
)
val expected = AddRecipeRequest(
val expected = AddRecipeInfo(
name = "Recipe name",
description = "Recipe description",
recipeYield = "Recipe yield",
recipeIngredient = listOf(
AddRecipeIngredient(note = "Recipe ingredient 1"),
AddRecipeIngredient(note = "Recipe ingredient 2")
AddRecipeIngredientInfo(note = "Recipe ingredient 1"),
AddRecipeIngredientInfo(note = "Recipe ingredient 2")
),
recipeInstructions = listOf(
AddRecipeInstruction(text = "Recipe instruction 1"),
AddRecipeInstruction(text = "Recipe instruction 2")
AddRecipeInstructionInfo(text = "Recipe instruction 1"),
AddRecipeInstructionInfo(text = "Recipe instruction 2")
),
settings = AddRecipeSettings(
settings = AddRecipeSettingsInfo(
public = false,
disableComments = true,
)
)
assertThat(input.toAddRecipeRequest()).isEqualTo(expected)
assertThat(input.toAddRecipeInfo()).isEqualTo(expected)
}
@Test
fun `when toDraft then fills fields correctly`() {
val request = AddRecipeRequest(
val request = AddRecipeInfo(
name = "Recipe name",
description = "Recipe description",
recipeYield = "Recipe yield",
recipeIngredient = listOf(
AddRecipeIngredient(note = "Recipe ingredient 1"),
AddRecipeIngredient(note = "Recipe ingredient 2")
AddRecipeIngredientInfo(note = "Recipe ingredient 1"),
AddRecipeIngredientInfo(note = "Recipe ingredient 2")
),
recipeInstructions = listOf(
AddRecipeInstruction(text = "Recipe instruction 1"),
AddRecipeInstruction(text = "Recipe instruction 2")
AddRecipeInstructionInfo(text = "Recipe instruction 1"),
AddRecipeInstructionInfo(text = "Recipe instruction 2")
),
settings = AddRecipeSettings(
settings = AddRecipeSettingsInfo(
public = false,
disableComments = true,
)

View File

@@ -1,10 +1,13 @@
package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.baseurl.ServerVersion
object AuthImplTestData {
const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
const val TEST_URL = "TEST_URL"
const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION = ServerVersion.V0
}

View File

@@ -1,133 +1,95 @@
package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.add.AddRecipeIngredientInfo
import gq.kirmanak.mealient.data.add.AddRecipeInstructionInfo
import gq.kirmanak.mealient.data.add.AddRecipeSettingsInfo
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeIngredientInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeInstructionInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
import gq.kirmanak.mealient.database.recipe.entity.*
import gq.kirmanak.mealient.datasource.models.GetRecipeIngredientResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeInstructionResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
object RecipeImplTestData {
val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse(
remoteId = 1,
val RECIPE_SUMMARY_CAKE = RecipeSummaryInfo(
remoteId = "1",
name = "Cake",
slug = "cake",
image = "86",
description = "A tasty cake",
recipeCategories = listOf("dessert", "tasty"),
tags = listOf("gluten", "allergic"),
rating = 4,
dateAdded = LocalDate.parse("2021-11-13"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
imageId = "cake",
)
val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse(
remoteId = 2,
val RECIPE_SUMMARY_PORRIDGE = RecipeSummaryInfo(
remoteId = "2",
name = "Porridge",
slug = "porridge",
image = "89",
description = "A tasty porridge",
recipeCategories = listOf("porridge", "tasty"),
tags = listOf("gluten", "milk"),
rating = 5,
dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
imageId = "porridge",
)
val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE)
val CAKE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
remoteId = 1,
remoteId = "1",
name = "Cake",
slug = "cake",
image = "86",
description = "A tasty cake",
rating = 4,
dateAdded = LocalDate.parse("2021-11-13"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13")
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
imageId = "cake",
)
val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
remoteId = 2,
remoteId = "2",
name = "Porridge",
slug = "porridge",
image = "89",
description = "A tasty porridge",
rating = 5,
dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
imageId = "porridge",
)
private val SUGAR_INGREDIENT = GetRecipeIngredientResponse(
title = "Sugar",
private val SUGAR_INGREDIENT = RecipeIngredientInfo(
note = "2 oz of white sugar",
unit = "",
food = "",
disableAmount = true,
quantity = 1
)
val BREAD_INGREDIENT = GetRecipeIngredientResponse(
title = "Bread",
val BREAD_INGREDIENT = RecipeIngredientInfo(
note = "2 oz of white bread",
unit = "",
food = "",
disableAmount = false,
quantity = 2
)
private val MILK_INGREDIENT = GetRecipeIngredientResponse(
title = "Milk",
private val MILK_INGREDIENT = RecipeIngredientInfo(
note = "2 oz of white milk",
unit = "",
food = "",
disableAmount = true,
quantity = 3
)
val MIX_INSTRUCTION = GetRecipeInstructionResponse(
title = "Mix",
val MIX_INSTRUCTION = RecipeInstructionInfo(
text = "Mix the ingredients"
)
private val BAKE_INSTRUCTION = GetRecipeInstructionResponse(
title = "Bake",
private val BAKE_INSTRUCTION = RecipeInstructionInfo(
text = "Bake the ingredients"
)
private val BOIL_INSTRUCTION = GetRecipeInstructionResponse(
title = "Boil",
private val BOIL_INSTRUCTION = RecipeInstructionInfo(
text = "Boil the ingredients"
)
val GET_CAKE_RESPONSE = GetRecipeResponse(
remoteId = 1,
val GET_CAKE_RESPONSE = FullRecipeInfo(
remoteId = "1",
name = "Cake",
slug = "cake",
image = "86",
description = "A tasty cake",
recipeCategories = listOf("dessert", "tasty"),
tags = listOf("gluten", "allergic"),
rating = 4,
dateAdded = LocalDate.parse("2021-11-13"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
recipeYield = "4 servings",
recipeIngredients = listOf(SUGAR_INGREDIENT, BREAD_INGREDIENT),
recipeInstructions = listOf(MIX_INSTRUCTION, BAKE_INSTRUCTION)
)
val GET_PORRIDGE_RESPONSE = GetRecipeResponse(
remoteId = 2,
val GET_PORRIDGE_RESPONSE = FullRecipeInfo(
remoteId = "2",
name = "Porridge",
slug = "porridge",
image = "89",
description = "A tasty porridge",
recipeCategories = listOf("porridge", "tasty"),
tags = listOf("gluten", "milk"),
rating = 5,
dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
recipeYield = "3 servings",
recipeIngredients = listOf(SUGAR_INGREDIENT, MILK_INGREDIENT),
recipeInstructions = listOf(MIX_INSTRUCTION, BOIL_INSTRUCTION)
@@ -135,46 +97,34 @@ object RecipeImplTestData {
val MIX_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 1,
recipeId = 1,
title = "Mix",
recipeId = "1",
text = "Mix the ingredients",
)
private val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 2,
recipeId = 1,
title = "Bake",
recipeId = "1",
text = "Bake the ingredients",
)
private val CAKE_RECIPE_ENTITY = RecipeEntity(
remoteId = 1,
remoteId = "1",
recipeYield = "4 servings"
)
private val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 1,
recipeId = 1,
title = "Sugar",
recipeId = "1",
note = "2 oz of white sugar",
unit = "",
food = "",
disableAmount = true,
quantity = 1
)
val CAKE_BREAD_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 2,
recipeId = 1,
title = "Bread",
recipeId = "1",
note = "2 oz of white bread",
unit = "",
food = "",
disableAmount = false,
quantity = 2
)
val FULL_CAKE_INFO_ENTITY = FullRecipeInfo(
val FULL_CAKE_INFO_ENTITY = FullRecipeEntity(
recipeEntity = CAKE_RECIPE_ENTITY,
recipeSummaryEntity = CAKE_RECIPE_SUMMARY_ENTITY,
recipeIngredients = listOf(
@@ -188,47 +138,35 @@ object RecipeImplTestData {
)
private val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity(
remoteId = 2,
remoteId = "2",
recipeYield = "3 servings"
)
private val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 4,
recipeId = 2,
title = "Milk",
recipeId = "2",
note = "2 oz of white milk",
unit = "",
food = "",
disableAmount = true,
quantity = 3
)
private val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 3,
recipeId = 2,
title = "Sugar",
recipeId = "2",
note = "2 oz of white sugar",
unit = "",
food = "",
disableAmount = true,
quantity = 1
)
private val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 3,
recipeId = 2,
title = "Mix",
recipeId = "2",
text = "Mix the ingredients"
)
private val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 4,
recipeId = 2,
title = "Boil",
recipeId = "2",
text = "Boil the ingredients"
)
val FULL_PORRIDGE_INFO_ENTITY = FullRecipeInfo(
val FULL_PORRIDGE_INFO_ENTITY = FullRecipeEntity(
recipeEntity = PORRIDGE_RECIPE_ENTITY_FULL,
recipeSummaryEntity = PORRIDGE_RECIPE_SUMMARY_ENTITY,
recipeIngredients = listOf(
@@ -240,4 +178,21 @@ object RecipeImplTestData {
PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY,
)
)
val PORRIDGE_ADD_RECIPE_INFO = AddRecipeInfo(
name = "Porridge",
description = "Tasty breakfast",
recipeYield = "5 servings",
recipeIngredient = listOf(
AddRecipeIngredientInfo("Milk"),
AddRecipeIngredientInfo("Sugar"),
AddRecipeIngredientInfo("Salt"),
AddRecipeIngredientInfo("Porridge"),
),
recipeInstructions = listOf(
AddRecipeInstructionInfo("Mix"),
AddRecipeInstructionInfo("Cook"),
),
settings = AddRecipeSettingsInfo(disableComments = false, public = true),
)
}

View File

@@ -2,8 +2,8 @@ package gq.kirmanak.mealient.ui.add
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
@@ -61,21 +61,21 @@ class AddRecipeViewModelTest {
@Test
fun `when preserve then doesn't update UI`() {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(AddRecipeRequest())
subject.preserve(AddRecipeRequest())
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
subject.preserve(PORRIDGE_ADD_RECIPE_INFO)
coVerify(inverse = true) { addRecipeRepo.addRecipeRequestFlow }
}
@Test
fun `when preservedAddRecipeRequest without loadPreservedRequest then empty`() = runTest {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(AddRecipeRequest())
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(PORRIDGE_ADD_RECIPE_INFO)
val actual = withTimeoutOrNull(10) { subject.preservedAddRecipeRequest.firstOrNull() }
assertThat(actual).isNull()
}
@Test
fun `when loadPreservedRequest then updates preservedAddRecipeRequest`() = runTest {
val expected = AddRecipeRequest()
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.loadPreservedRequest()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
@@ -83,7 +83,7 @@ class AddRecipeViewModelTest {
@Test
fun `when clear then updates preservedAddRecipeRequest`() = runTest {
val expected = AddRecipeRequest()
val expected = PORRIDGE_ADD_RECIPE_INFO
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.clear()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)

View File

@@ -1,10 +1,11 @@
package gq.kirmanak.mealient.ui.baseurl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
@@ -20,7 +21,7 @@ import org.junit.Test
class BaseURLViewModelTest : RobolectricTest() {
@MockK(relaxUnitFun = true)
lateinit var baseURLStorage: BaseURLStorage
lateinit var serverInfoRepo: ServerInfoRepo
@MockK
lateinit var versionDataSource: VersionDataSource
@@ -33,16 +34,16 @@ class BaseURLViewModelTest : RobolectricTest() {
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = BaseURLViewModel(baseURLStorage, versionDataSource, logger)
subject = BaseURLViewModel(serverInfoRepo, versionDataSource, logger)
}
@Test
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
coEvery {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} returns VersionInfo(true, "0.5.6", true)
} returns VersionInfo(TEST_VERSION)
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
coVerify { baseURLStorage.storeBaseURL(eq(TEST_BASE_URL)) }
coVerify { serverInfoRepo.storeBaseURL(eq(TEST_BASE_URL), eq(TEST_VERSION)) }
}
}

View File

@@ -0,0 +1,404 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "28c896eb34e95c0cff33148178252f72",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_categories_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_categories_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "category_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `recipe_id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"category_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_category_recipe_category_id_recipe_id",
"unique": true,
"columnNames": [
"category_id",
"recipe_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_category_recipe_category_id_recipe_id` ON `${TABLE_NAME}` (`category_id`, `recipe_id`)"
},
{
"name": "index_category_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_category_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"category_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "tag_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`tag_id`, `recipe_id`), FOREIGN KEY(`tag_id`) REFERENCES `tags`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"tag_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_tag_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tag_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"tag_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "recipe_summaries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `image` TEXT, `description` TEXT NOT NULL, `rating` INTEGER, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "dateAdded",
"columnName": "date_added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateUpdated",
"columnName": "date_updated",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recipeYield",
"columnName": "recipe_yield",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_ingredient",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `title` TEXT NOT NULL, `note` TEXT NOT NULL, `unit` TEXT NOT NULL, `food` TEXT NOT NULL, `disable_amount` INTEGER NOT NULL, `quantity` REAL NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "food",
"columnName": "food",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "disableAmount",
"columnName": "disable_amount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_instruction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `title` TEXT NOT NULL, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '28c896eb34e95c0cff33148178252f72')"
]
}
}

View File

@@ -0,0 +1,410 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "13be83018f147e1f6e864790656da4a7",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_categories_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_categories_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "category_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `recipe_id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"category_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_category_recipe_category_id_recipe_id",
"unique": true,
"columnNames": [
"category_id",
"recipe_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_category_recipe_category_id_recipe_id` ON `${TABLE_NAME}` (`category_id`, `recipe_id`)"
},
{
"name": "index_category_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_category_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"category_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "tag_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`tag_id`, `recipe_id`), FOREIGN KEY(`tag_id`) REFERENCES `tags`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"tag_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_tag_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tag_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"tag_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "recipe_summaries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `image` TEXT, `description` TEXT NOT NULL, `rating` INTEGER, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "dateAdded",
"columnName": "date_added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateUpdated",
"columnName": "date_updated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageId",
"columnName": "image_id",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recipeYield",
"columnName": "recipe_yield",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_ingredient",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `title` TEXT NOT NULL, `note` TEXT NOT NULL, `unit` TEXT NOT NULL, `food` TEXT NOT NULL, `disable_amount` INTEGER NOT NULL, `quantity` REAL NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "food",
"columnName": "food",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "disableAmount",
"columnName": "disable_amount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_instruction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `title` TEXT NOT NULL, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '13be83018f147e1f6e864790656da4a7')"
]
}
}

View File

@@ -0,0 +1,374 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "e75a1e16503fdf60c62b7f9d17ec0bc6",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_categories_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_categories_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "category_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `recipe_id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"category_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_category_recipe_category_id_recipe_id",
"unique": true,
"columnNames": [
"category_id",
"recipe_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_category_recipe_category_id_recipe_id` ON `${TABLE_NAME}` (`category_id`, `recipe_id`)"
},
{
"name": "index_category_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_category_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"category_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "tag_recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `recipe_id` TEXT NOT NULL, PRIMARY KEY(`tag_id`, `recipe_id`), FOREIGN KEY(`tag_id`) REFERENCES `tags`(`local_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`recipe_id`) REFERENCES `recipe_summaries`(`remote_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"tag_id",
"recipe_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_tag_recipe_recipe_id",
"unique": false,
"columnNames": [
"recipe_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tag_recipe_recipe_id` ON `${TABLE_NAME}` (`recipe_id`)"
}
],
"foreignKeys": [
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"tag_id"
],
"referencedColumns": [
"local_id"
]
},
{
"table": "recipe_summaries",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"recipe_id"
],
"referencedColumns": [
"remote_id"
]
}
]
},
{
"tableName": "recipe_summaries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `image` TEXT, `description` TEXT NOT NULL, `rating` INTEGER, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "dateAdded",
"columnName": "date_added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateUpdated",
"columnName": "date_updated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageId",
"columnName": "image_id",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recipeYield",
"columnName": "recipe_yield",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_ingredient",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `note` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_instruction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e75a1e16503fdf60c62b7f9d17ec0bc6')"
]
}
}

View File

@@ -0,0 +1,160 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "f6e28dd617e4d4a6843a7865c9da736d",
"entities": [
{
"tableName": "recipe_summaries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `description` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "slug",
"columnName": "slug",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dateAdded",
"columnName": "date_added",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateUpdated",
"columnName": "date_updated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageId",
"columnName": "image_id",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, PRIMARY KEY(`remote_id`))",
"fields": [
{
"fieldPath": "remoteId",
"columnName": "remote_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recipeYield",
"columnName": "recipe_yield",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"remote_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_ingredient",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `note` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recipe_instruction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "localId",
"columnName": "local_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "recipeId",
"columnName": "recipe_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"local_id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f6e28dd617e4d4a6843a7865c9da736d')"
]
}
}

View File

@@ -1,19 +1,13 @@
package gq.kirmanak.mealient.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.*
import androidx.room.migration.AutoMigrationSpec
import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.*
@Database(
version = 2,
version = 6,
entities = [
CategoryEntity::class,
CategoryRecipeEntity::class,
TagEntity::class,
TagRecipeEntity::class,
RecipeSummaryEntity::class,
RecipeEntity::class,
RecipeIngredientEntity::class,
@@ -21,10 +15,29 @@ import gq.kirmanak.mealient.database.recipe.entity.*
],
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2)
AutoMigration(from = 1, to = 2),
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5, spec = AppDb.From4To5Migration::class),
AutoMigration(from = 5, to = 6, spec = AppDb.From5To6Migration::class),
]
)
@TypeConverters(RoomTypeConverters::class)
abstract class AppDb : RoomDatabase() {
abstract fun recipeDao(): RecipeDao
@DeleteColumn(tableName = "recipe_instruction", columnName = "title")
@DeleteColumn(tableName = "recipe_ingredient", columnName = "title")
@DeleteColumn(tableName = "recipe_ingredient", columnName = "unit")
@DeleteColumn(tableName = "recipe_ingredient", columnName = "food")
@DeleteColumn(tableName = "recipe_ingredient", columnName = "disable_amount")
@DeleteColumn(tableName = "recipe_ingredient", columnName = "quantity")
class From4To5Migration : AutoMigrationSpec
@DeleteColumn(tableName = "recipe_summaries", columnName = "image")
@DeleteColumn(tableName = "recipe_summaries", columnName = "rating")
@DeleteTable(tableName = "tag_recipe")
@DeleteTable(tableName = "tags")
@DeleteTable(tableName = "categories")
@DeleteTable(tableName = "category_recipe")
class From5To6Migration : AutoMigrationSpec
}

View File

@@ -17,6 +17,8 @@ interface DatabaseModule {
@Provides
@Singleton
fun createDb(@ApplicationContext context: Context): AppDb =
Room.databaseBuilder(context, AppDb::class.java, "app.db").build()
Room.databaseBuilder(context, AppDb::class.java, "app.db")
.fallbackToDestructiveMigrationFrom(2)
.build()
}
}

View File

@@ -6,54 +6,18 @@ import gq.kirmanak.mealient.database.recipe.entity.*
@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)
@@ -66,11 +30,11 @@ interface RecipeDao {
@Transaction
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used
@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?
suspend fun queryFullRecipeInfo(recipeId: String): FullRecipeEntity?
@Query("DELETE FROM recipe_ingredient WHERE recipe_id = :recipeId")
suspend fun deleteRecipeIngredients(recipeId: Long)
suspend fun deleteRecipeIngredients(recipeId: String)
@Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId")
suspend fun deleteRecipeInstructions(recipeId: Long)
suspend fun deleteRecipeInstructions(recipeId: String)
}

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.database.recipe.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

@@ -1,29 +0,0 @@
package gq.kirmanak.mealient.database.recipe.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

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.database.recipe.entity
import androidx.room.Embedded
import androidx.room.Relation
data class FullRecipeInfo(
data class FullRecipeEntity(
@Embedded val recipeEntity: RecipeEntity,
@Relation(
parentColumn = "remote_id",

View File

@@ -6,6 +6,6 @@ import androidx.room.PrimaryKey
@Entity(tableName = "recipe")
data class RecipeEntity(
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long,
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: String,
@ColumnInfo(name = "recipe_yield") val recipeYield: String,
)

View File

@@ -7,11 +7,6 @@ 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 = "recipe_id") val recipeId: 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

@@ -7,7 +7,6 @@ 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 = "recipe_id") val recipeId: String,
@ColumnInfo(name = "text") val text: String,
)

View File

@@ -8,16 +8,11 @@ import kotlinx.datetime.LocalDateTime
@Entity(tableName = "recipe_summaries")
data class RecipeSummaryEntity(
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long,
@PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: String,
@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 "RecipeSummaryEntity(remoteId=$remoteId, name='$name')"
}
}
@ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime,
@ColumnInfo(name = "image_id") val imageId: String?,
)

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.database.recipe.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

@@ -1,27 +0,0 @@
package gq.kirmanak.mealient.database.recipe.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,22 @@
package gq.kirmanak.mealient.datasource
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.ResponseBody
/**
* Like [runCatching] but rethrows [CancellationException] to support
* cancellation of coroutines.
*/
inline fun <T> runCatchingExceptCancel(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified R> ResponseBody.decode(json: Json): R = json.decodeFromStream(byteStream())

View File

@@ -6,6 +6,12 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl
import gq.kirmanak.mealient.datasource.v0.MealieServiceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1Impl
import gq.kirmanak.mealient.datasource.v1.MealieServiceV1
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
@@ -49,9 +55,13 @@ interface DataSourceModule {
@Provides
@Singleton
fun provideMealieService(retrofit: Retrofit): MealieService =
fun provideMealieService(retrofit: Retrofit): MealieServiceV0 =
retrofit.create()
@Provides
@Singleton
fun provideMealieServiceV1(retrofit: Retrofit): MealieServiceV1 =
retrofit.create()
}
@Binds
@@ -64,5 +74,13 @@ interface DataSourceModule {
@Binds
@Singleton
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceImpl): MealieDataSource
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceV0Impl): MealieDataSourceV0
@Binds
@Singleton
fun bindMealieDataSourceV1(mealientDataSourceImpl: MealieDataSourceV1Impl): MealieDataSourceV1
@Binds
@Singleton
fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper
}

View File

@@ -1,92 +0,0 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceImpl @Inject constructor(
private val logger: Logger,
private val mealieService: MealieService,
private val json: Json,
) : MealieDataSource {
override suspend fun addRecipe(
baseUrl: String, token: String?, recipe: AddRecipeRequest
): String = makeCall(
block = { addRecipe("$baseUrl/api/recipes/create", token, recipe) },
logMethod = { "addRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" }
).getOrThrowUnauthorized()
override suspend fun authenticate(
baseUrl: String, username: String, password: String
): String = makeCall(
block = { getToken("$baseUrl/api/auth/token", username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetail = errorBody.decode<ErrorDetail>()
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(baseUrl: String): VersionResponse = makeCall(
block = { getVersion("$baseUrl/api/debug/version") },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
baseUrl: String, token: String?, start: Int, limit: Int
): List<GetRecipeSummaryResponse> = makeCall(
block = { getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" }
).getOrThrowUnauthorized()
override suspend fun requestRecipeInfo(
baseUrl: String, token: String?, slug: String
): GetRecipeResponse = makeCall(
block = { getRecipe("$baseUrl/api/recipes/$slug", token) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" }
).getOrThrowUnauthorized()
private suspend inline fun <T> makeCall(
crossinline block: suspend MealieService.() -> T,
crossinline logMethod: () -> String,
crossinline logParameters: () -> String,
): Result<T> {
logger.v { "${logMethod()} called with: ${logParameters()}" }
return mealieService.runCatching { block() }
.onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } }
.onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}" } }
}
@OptIn(ExperimentalSerializationApi::class)
private inline fun <reified R> ResponseBody.decode(): R = json.decodeFromStream(byteStream())
}
private fun <T> Result<T>.getOrThrowUnauthorized(): T = getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
NetworkError.Unauthorized(it)
} else {
it
}
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.datasource.models
package gq.kirmanak.mealient.datasource
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause)

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealient.datasource
interface NetworkRequestWrapper {
suspend fun <T> makeCall(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
): Result<T>
suspend fun <T> makeCallAndHandleUnauthorized(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
): T
}

View File

@@ -0,0 +1,36 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.logging.Logger
import retrofit2.HttpException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkRequestWrapperImpl @Inject constructor(
private val logger: Logger,
) : NetworkRequestWrapper {
override suspend fun <T> makeCall(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
): Result<T> {
logger.v { "${logMethod()} called with: ${logParameters()}" }
return runCatchingExceptCancel { block() }
.onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } }
.onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}, result = $it" } }
}
override suspend fun <T> makeCallAndHandleUnauthorized(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String
): T = makeCall(block, logMethod, logParameters).getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
NetworkError.Unauthorized(it)
} else {
it
}
}
}

View File

@@ -1,54 +0,0 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddRecipeRequest(
@SerialName("name") val name: String = "",
@SerialName("description") val description: String = "",
@SerialName("image") val image: String = "",
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredient: List<AddRecipeIngredient> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<AddRecipeInstruction> = emptyList(),
@SerialName("slug") val slug: String = "",
@SerialName("filePath") val filePath: String = "",
@SerialName("tags") val tags: List<String> = emptyList(),
@SerialName("categories") val categories: List<String> = emptyList(),
@SerialName("notes") val notes: List<AddRecipeNote> = emptyList(),
@SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(),
@SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(),
)
@Serializable
data class AddRecipeSettings(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("disableComments") val disableComments: Boolean = false,
@SerialName("landscapeView") val landscapeView: Boolean = true,
@SerialName("public") val public: Boolean = true,
@SerialName("showAssets") val showAssets: Boolean = true,
@SerialName("showNutrition") val showNutrition: Boolean = true,
)
@Serializable
data class AddRecipeNote(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeInstruction(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeIngredient(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("food") val food: String? = null,
@SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Int = 1,
@SerialName("title") val title: String? = null,
@SerialName("unit") val unit: String? = null,
)

View File

@@ -1,7 +0,0 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetail(@SerialName("detail") val detail: String? = null)

View File

@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.datasource.models
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

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

View File

@@ -1,23 +0,0 @@
package gq.kirmanak.mealient.datasource.models
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

@@ -1,24 +0,0 @@
package gq.kirmanak.mealient.datasource.models
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')"
}
}

View File

@@ -1,7 +0,0 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)

View File

@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VersionResponse(
@SerialName("production")
val production: Boolean,
@SerialName("version")
val version: String,
@SerialName("demoStatus")
val demoStatus: Boolean,
)

View File

@@ -1,16 +1,16 @@
package gq.kirmanak.mealient.datasource
package gq.kirmanak.mealient.datasource.v0
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.VersionResponse
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
interface MealieDataSource {
interface MealieDataSourceV0 {
suspend fun addRecipe(
baseUrl: String,
token: String?,
recipe: AddRecipeRequest,
recipe: AddRecipeRequestV0,
): String
/**
@@ -24,18 +24,18 @@ interface MealieDataSource {
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponse
): VersionResponseV0
suspend fun requestRecipes(
baseUrl: String,
token: String?,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponse>
): List<GetRecipeSummaryResponseV0>
suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String,
): GetRecipeResponse
): GetRecipeResponseV0
}

View File

@@ -0,0 +1,80 @@
package gq.kirmanak.mealient.datasource.v0
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.v0.models.*
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceV0Impl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieServiceV0,
private val json: Json,
) : MealieDataSourceV0 {
override suspend fun addRecipe(
baseUrl: String,
token: String?,
recipe: AddRecipeRequestV0,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addRecipe("$baseUrl/api/recipes/create", token, recipe) },
logMethod = { "addRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" }
)
override suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken("$baseUrl/api/auth/token", username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV0>(json)
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(
baseUrl: String
): VersionResponseV0 = networkRequestWrapper.makeCall(
block = { service.getVersion("$baseUrl/api/debug/version") },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
baseUrl: String,
token: String?,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponseV0> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" }
)
override suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String,
): GetRecipeResponseV0 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug", token) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" }
)
}

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.datasource
package gq.kirmanak.mealient.datasource.v0
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datasource.v0.models.*
import retrofit2.http.*
interface MealieService {
interface MealieServiceV0 {
@FormUrlEncoded
@POST
@@ -12,19 +12,19 @@ interface MealieService {
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): GetTokenResponse
): GetTokenResponseV0
@POST
suspend fun addRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: AddRecipeRequest,
@Body addRecipeRequestV0: AddRecipeRequestV0,
): String
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponse
): VersionResponseV0
@GET
suspend fun getRecipeSummary(
@@ -32,11 +32,11 @@ interface MealieService {
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
): List<GetRecipeSummaryResponseV0>
@GET
suspend fun getRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponse
): GetRecipeResponseV0
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddRecipeRequestV0(
@SerialName("name") val name: String,
@SerialName("description") val description: String,
@SerialName("recipeYield") val recipeYield: String,
@SerialName("recipeIngredient") val recipeIngredient: List<AddRecipeIngredientV0>,
@SerialName("recipeInstructions") val recipeInstructions: List<AddRecipeInstructionV0>,
@SerialName("settings") val settings: AddRecipeSettingsV0,
)
@Serializable
data class AddRecipeIngredientV0(
@SerialName("note") val note: String,
)
@Serializable
data class AddRecipeInstructionV0(
@SerialName("text") val text: String,
)
@Serializable
data class AddRecipeSettingsV0(
@SerialName("disableComments") val disableComments: Boolean,
@SerialName("public") val public: Boolean,
)

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetailV0(@SerialName("detail") val detail: String? = null)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeResponseV0(
@SerialName("id") val remoteId: Int,
@SerialName("name") val name: String,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponseV0>,
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponseV0>,
)
@Serializable
data class GetRecipeIngredientResponseV0(
@SerialName("note") val note: String = "",
)
@Serializable
data class GetRecipeInstructionResponseV0(
@SerialName("text") val text: String,
)

View File

@@ -0,0 +1,16 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeSummaryResponseV0(
@SerialName("id") val remoteId: Int,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("description") val description: String = "",
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime
)

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponseV0(
@SerialName("access_token") val accessToken: String,
)

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VersionResponseV0(
@SerialName("version") val version: String,
)

View File

@@ -0,0 +1,45 @@
package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.v1.models.*
interface MealieDataSourceV1 {
suspend fun createRecipe(
baseUrl: String,
token: String?,
recipe: CreateRecipeRequestV1,
): String
suspend fun updateRecipe(
baseUrl: String,
token: String?,
slug: String,
recipe: UpdateRecipeRequestV1,
): GetRecipeResponseV1
/**
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponseV1
suspend fun requestRecipes(
baseUrl: String,
token: String?,
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponseV1>
suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String,
): GetRecipeResponseV1
}

View File

@@ -0,0 +1,93 @@
package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.v1.models.*
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceV1Impl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieServiceV1,
private val json: Json,
) : MealieDataSourceV1 {
override suspend fun createRecipe(
baseUrl: String,
token: String?,
recipe: CreateRecipeRequestV1
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe("$baseUrl/api/recipes", token, recipe) },
logMethod = { "createRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" }
)
override suspend fun updateRecipe(
baseUrl: String,
token: String?,
slug: String,
recipe: UpdateRecipeRequestV1
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe("$baseUrl/api/recipes/$slug", token, recipe) },
logMethod = { "updateRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug, recipe = $recipe" }
)
override suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken("$baseUrl/api/auth/token", username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV1>(json)
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(
baseUrl: String,
): VersionResponseV1 = networkRequestWrapper.makeCall(
block = { service.getVersion("$baseUrl/api/app/about") },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
baseUrl: String,
token: String?,
page: Int,
perPage: Int
): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes", token, page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, page = $page, perPage = $perPage" }
).items
override suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug", token) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" }
)
}

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.v1.models.*
import retrofit2.http.*
interface MealieServiceV1 {
@FormUrlEncoded
@POST
suspend fun getToken(
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): GetTokenResponseV1
@POST
suspend fun createRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: CreateRecipeRequestV1,
): String
@PATCH
suspend fun updateRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: UpdateRecipeRequestV1,
): GetRecipeResponseV1
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponseV1
@GET
suspend fun getRecipeSummary(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("page") page: Int,
@Query("perPage") perPage: Int,
): GetRecipesResponseV1
@GET
suspend fun getRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponseV1
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateRecipeRequestV1(
@SerialName("name") val name: String,
)

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetailV1(
@SerialName("detail") val detail: String? = null,
)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeResponseV1(
@SerialName("id") val remoteId: String,
@SerialName("name") val name: String,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponseV1>,
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponseV1>,
)
@Serializable
data class GetRecipeIngredientResponseV1(
@SerialName("note") val note: String = "",
)
@Serializable
data class GetRecipeInstructionResponseV1(
@SerialName("text") val text: String,
)

Some files were not shown because too many files have changed in this diff Show More