diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..d2c8772
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index aa70f28..4f5fd78 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -4,8 +4,11 @@
diff --git a/app/build.gradle b/app/build.gradle
index 9013ddf..a30b36c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -76,10 +76,10 @@ dependencies {
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
testImplementation "androidx.paging:paging-common-ktx:$paging_version"
- def room_version = "2.3.0"
+ def room_version = "2.4.0-beta01"
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
- implementation "androidx.room:room-paging:2.4.0-beta01"
+ implementation "androidx.room:room-paging:$room_version"
kapt "androidx.room:room-compiler:$room_version"
testImplementation "androidx.room:room-testing:$room_version"
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt b/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt
new file mode 100644
index 0000000..0092b86
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt
@@ -0,0 +1,18 @@
+package gq.kirmanak.mealie.data
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import gq.kirmanak.mealie.data.recipes.db.*
+import javax.inject.Singleton
+
+@Database(
+ version = 1,
+ entities = [CategoryEntity::class, CategoryRecipeEntity::class, TagEntity::class, TagRecipeEntity::class, RecipeEntity::class],
+ exportSchema = false
+)
+@TypeConverters(RoomTypeConverters::class)
+@Singleton
+abstract class MealieDb : RoomDatabase() {
+ abstract fun recipeDao(): RecipeDao
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/MealieModule.kt b/app/src/main/java/gq/kirmanak/mealie/data/MealieModule.kt
new file mode 100644
index 0000000..44829fe
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/MealieModule.kt
@@ -0,0 +1,20 @@
+package gq.kirmanak.mealie.data
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class MealieModule {
+ companion object {
+ @Provides
+ fun createDb(@ApplicationContext context: Context): MealieDb {
+ return Room.databaseBuilder(context, MealieDb::class.java, "mealie.db").build()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/OkHttpBuilder.kt b/app/src/main/java/gq/kirmanak/mealie/data/OkHttpBuilder.kt
index 92dbf99..2c66ceb 100644
--- a/app/src/main/java/gq/kirmanak/mealie/data/OkHttpBuilder.kt
+++ b/app/src/main/java/gq/kirmanak/mealie/data/OkHttpBuilder.kt
@@ -1,5 +1,6 @@
package gq.kirmanak.mealie.data
+import gq.kirmanak.mealie.data.auth.AuthOkHttpInterceptor
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -7,13 +8,15 @@ import timber.log.Timber
import javax.inject.Inject
class OkHttpBuilder @Inject constructor() {
- fun buildOkHttp(): OkHttpClient {
- return OkHttpClient.Builder()
+ fun buildOkHttp(token: String?): OkHttpClient {
+ Timber.v("buildOkHttp() called with: token = $token")
+ val builder = OkHttpClient.Builder()
.addNetworkInterceptor(buildLoggingInterceptor())
- .build()
+ if (token != null) builder.addNetworkInterceptor(AuthOkHttpInterceptor(token))
+ return builder.build()
}
- private fun buildLoggingInterceptor() : Interceptor {
+ private fun buildLoggingInterceptor(): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) }
interceptor.level = HttpLoggingInterceptor.Level.BODY
return interceptor
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt b/app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt
index fbcf519..b0157e5 100644
--- a/app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt
+++ b/app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt
@@ -10,13 +10,13 @@ import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor(private val okHttpBuilder: OkHttpBuilder) {
- fun buildRetrofit(baseUrl: String): Retrofit {
- Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
+ fun buildRetrofit(baseUrl: String, token: String? = null): Retrofit {
+ Timber.v("buildRetrofit() called with: baseUrl = $baseUrl, token = $token")
val url = if (baseUrl.startsWith("http")) baseUrl else "https://$baseUrl"
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(url)
- .client(okHttpBuilder.buildOkHttp())
+ .client(okHttpBuilder.buildOkHttp(token))
.addConverterFactory(Json.asConverterFactory(contentType))
.build()
}
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/RoomTypeConverters.kt b/app/src/main/java/gq/kirmanak/mealie/data/RoomTypeConverters.kt
new file mode 100644
index 0000000..b375194
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/RoomTypeConverters.kt
@@ -0,0 +1,12 @@
+package gq.kirmanak.mealie.data
+
+import androidx.room.TypeConverter
+import kotlinx.datetime.Instant
+
+object RoomTypeConverters {
+ @TypeConverter
+ fun instantToTimestamp(instant: Instant) = instant.toEpochMilliseconds()
+
+ @TypeConverter
+ fun timestampToInstant(timestamp: Long) = Instant.fromEpochMilliseconds(timestamp)
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthOkHttpInterceptor.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthOkHttpInterceptor.kt
new file mode 100644
index 0000000..0900d42
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthOkHttpInterceptor.kt
@@ -0,0 +1,16 @@
+package gq.kirmanak.mealie.data.auth
+
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class AuthOkHttpInterceptor(token: String) : Interceptor {
+ private val headerValue = "Bearer $token"
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val newRequest = chain.request()
+ .newBuilder()
+ .addHeader("Authorization", headerValue)
+ .build()
+ return chain.proceed(newRequest)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeModule.kt
new file mode 100644
index 0000000..6c2d574
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeModule.kt
@@ -0,0 +1,27 @@
+package gq.kirmanak.mealie.data.recipes
+
+import androidx.paging.ExperimentalPagingApi
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
+import gq.kirmanak.mealie.data.recipes.db.RecipeStorageImpl
+import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
+import gq.kirmanak.mealie.data.recipes.network.RecipeDataSourceImpl
+import kotlinx.serialization.ExperimentalSerializationApi
+
+@ExperimentalPagingApi
+@ExperimentalSerializationApi
+@Module
+@InstallIn(ViewModelComponent::class)
+interface RecipeModule {
+ @Binds
+ fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
+
+ @Binds
+ fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
+
+ @Binds
+ fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt
new file mode 100644
index 0000000..c53fb2e
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt
@@ -0,0 +1,8 @@
+package gq.kirmanak.mealie.data.recipes
+
+import androidx.paging.Pager
+import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
+
+interface RecipeRepo {
+ fun createPager(): Pager
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepoImpl.kt
new file mode 100644
index 0000000..3106d22
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepoImpl.kt
@@ -0,0 +1,21 @@
+package gq.kirmanak.mealie.data.recipes
+
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
+import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
+import javax.inject.Inject
+
+@ExperimentalPagingApi
+class RecipeRepoImpl @Inject constructor(
+ private val mediator: RecipesRemoteMediator,
+ private val storage: RecipeStorage
+) : RecipeRepo {
+ override fun createPager(): Pager {
+ val pagingConfig = PagingConfig(pageSize = 30)
+ return Pager(pagingConfig, 0, mediator) {
+ storage.queryRecipes()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipesRemoteMediator.kt
new file mode 100644
index 0000000..24be35d
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipesRemoteMediator.kt
@@ -0,0 +1,53 @@
+package gq.kirmanak.mealie.data.recipes
+
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.LoadType
+import androidx.paging.LoadType.*
+import androidx.paging.PagingState
+import androidx.paging.RemoteMediator
+import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
+import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
+import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
+import javax.inject.Inject
+
+@ExperimentalPagingApi
+class RecipesRemoteMediator @Inject constructor(
+ private val storage: RecipeStorage,
+ private val network: RecipeDataSource
+) : RemoteMediator() {
+
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState
+ ): MediatorResult {
+ val pageSize = state.config.pageSize
+ val closestPage = state.anchorPosition?.let { state.closestPageToPosition(it) }
+ val start = when (loadType) {
+ REFRESH -> 0
+ PREPEND -> closestPage?.prevKey ?: 0
+ APPEND -> closestPage?.nextKey ?: 0
+ }
+ val end = when (loadType) {
+ REFRESH -> pageSize
+ PREPEND, APPEND -> start + pageSize
+ }
+
+ val recipes = try {
+ network.requestRecipes(start = start, end = end)
+ } catch (e: Exception) {
+ return MediatorResult.Error(e)
+ }
+
+ try {
+ when (loadType) {
+ REFRESH -> storage.refreshAll(recipes)
+ PREPEND, APPEND -> storage.saveRecipes(recipes)
+ }
+ } catch (e: Exception) {
+ return MediatorResult.Error(e)
+ }
+ val expectedCount = end - start
+ val isEndReached = recipes.size < expectedCount
+ return MediatorResult.Success(isEndReached)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryEntity.kt
new file mode 100644
index 0000000..204dc64
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryEntity.kt
@@ -0,0 +1,12 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryRecipeEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryRecipeEntity.kt
new file mode 100644
index 0000000..a9be55b
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/CategoryRecipeEntity.kt
@@ -0,0 +1,29 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+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 = RecipeEntity::class,
+ parentColumns = ["local_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
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt
new file mode 100644
index 0000000..e9a9758
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt
@@ -0,0 +1,43 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+import androidx.paging.PagingSource
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+
+@Dao
+interface RecipeDao {
+ @Query("SELECT * FROM tags")
+ suspend fun queryAllTags(): List
+
+ @Query("SELECT * FROM categories")
+ suspend fun queryAllCategories(): List
+
+ @Query("SELECT * FROM recipes")
+ fun queryRecipesByPages(): PagingSource
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertRecipe(recipeEntity: RecipeEntity): Long
+
+ @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)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertCategoryRecipeEntities(categoryRecipeEntities: Set)
+
+ @Query("DELETE FROM recipes")
+ suspend fun removeAllRecipes()
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeEntity.kt
new file mode 100644
index 0000000..1046982
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeEntity.kt
@@ -0,0 +1,20 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import kotlinx.datetime.Instant
+
+@Entity(tableName = "recipes", indices = [Index(value = ["remote_id"], unique = true)])
+data class RecipeEntity(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
+ @ColumnInfo(name = "remote_id") val remoteId: Long,
+ @ColumnInfo(name = "name") val name: String,
+ @ColumnInfo(name = "slug") val slug: String,
+ @ColumnInfo(name = "image") val image: String,
+ @ColumnInfo(name = "description") val description: String,
+ @ColumnInfo(name = "rating") val rating: Int?,
+ @ColumnInfo(name = "date_added") val dateAdded: Instant,
+ @ColumnInfo(name = "date_updated") val dateUpdated: Instant
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt
new file mode 100644
index 0000000..98e8db8
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt
@@ -0,0 +1,12 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+import androidx.paging.PagingSource
+import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse
+
+interface RecipeStorage {
+ suspend fun saveRecipes(recipes: List)
+
+ fun queryRecipes(): PagingSource
+
+ suspend fun refreshAll(recipes: List)
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt
new file mode 100644
index 0000000..887f490
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt
@@ -0,0 +1,102 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+import androidx.paging.PagingSource
+import androidx.room.withTransaction
+import gq.kirmanak.mealie.data.MealieDb
+import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse
+import timber.log.Timber
+import javax.inject.Inject
+
+class RecipeStorageImpl @Inject constructor(
+ private val db: MealieDb
+) : RecipeStorage {
+ private val recipeDao: RecipeDao by lazy { db.recipeDao() }
+
+ override suspend fun saveRecipes(
+ recipes: List
+ ) = db.withTransaction {
+ Timber.v("saveRecipes() called with $recipes")
+
+ val tagEntities = mutableSetOf()
+ tagEntities.addAll(recipeDao.queryAllTags())
+
+ val categoryEntities = mutableSetOf()
+ categoryEntities.addAll(recipeDao.queryAllCategories())
+
+ val tagRecipeEntities = mutableSetOf()
+ val categoryRecipeEntities = mutableSetOf()
+
+ for (recipe in recipes) {
+ val recipeId = recipeDao.insertRecipe(recipe.recipeEntity())
+
+ for (tag in recipe.tags) {
+ val tagId = getIdOrInsert(tagEntities, tag)
+ tagRecipeEntities += TagRecipeEntity(tagId, recipeId)
+ }
+
+ for (category in recipe.recipeCategories) {
+ val categoryId = getOrInsert(categoryEntities, category)
+ categoryRecipeEntities += CategoryRecipeEntity(categoryId, recipeId)
+ }
+ }
+
+ recipeDao.insertTagRecipeEntities(tagRecipeEntities)
+ recipeDao.insertCategoryRecipeEntities(categoryRecipeEntities)
+ }
+
+ private suspend fun getOrInsert(
+ categoryEntities: MutableSet,
+ 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,
+ tag: String
+ ): Long {
+ val existingTag = tagEntities.find { it.name == tag }
+ val tagId = if (existingTag == null) {
+ val tagEntity = TagEntity(name = tag)
+ val newId = recipeDao.insertTag(tagEntity)
+ tagEntities.add(tagEntity.copy(localId = newId))
+ newId
+ } else {
+ existingTag.localId
+ }
+ return tagId
+ }
+
+ private fun GetRecipeSummaryResponse.recipeEntity() = RecipeEntity(
+ remoteId = remoteId,
+ name = name,
+ slug = slug,
+ image = image,
+ description = description,
+ rating = rating,
+ dateAdded = dateAdded,
+ dateUpdated = dateUpdated,
+ )
+
+ override fun queryRecipes(): PagingSource {
+ Timber.v("queryRecipes() called")
+ return recipeDao.queryRecipesByPages()
+ }
+
+ override suspend fun refreshAll(recipes: List) {
+ Timber.v("refreshAll() called with: recipes = $recipes")
+ db.withTransaction {
+ recipeDao.removeAllRecipes()
+ saveRecipes(recipes)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagEntity.kt
new file mode 100644
index 0000000..43617d6
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagEntity.kt
@@ -0,0 +1,12 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+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
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagRecipeEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagRecipeEntity.kt
new file mode 100644
index 0000000..7888092
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/TagRecipeEntity.kt
@@ -0,0 +1,27 @@
+package gq.kirmanak.mealie.data.recipes.db
+
+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 = RecipeEntity::class,
+ parentColumns = ["local_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
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/GetRecipeSummaryResponse.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/GetRecipeSummaryResponse.kt
new file mode 100644
index 0000000..f59c48f
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/GetRecipeSummaryResponse.kt
@@ -0,0 +1,19 @@
+package gq.kirmanak.mealie.data.recipes.network
+
+import kotlinx.datetime.Instant
+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,
+ @SerialName("tags") val tags: List,
+ @SerialName("rating") val rating: Int?,
+ @SerialName("dateAdded") val dateAdded: Instant,
+ @SerialName("dateUpdated") val dateUpdated: Instant
+)
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt
new file mode 100644
index 0000000..188b8f8
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt
@@ -0,0 +1,5 @@
+package gq.kirmanak.mealie.data.recipes.network
+
+interface RecipeDataSource {
+ suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt
new file mode 100644
index 0000000..6fb95a4
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt
@@ -0,0 +1,37 @@
+package gq.kirmanak.mealie.data.recipes.network
+
+import gq.kirmanak.mealie.data.RetrofitBuilder
+import gq.kirmanak.mealie.data.auth.AuthRepo
+import kotlinx.serialization.ExperimentalSerializationApi
+import timber.log.Timber
+import javax.inject.Inject
+
+@ExperimentalSerializationApi
+class RecipeDataSourceImpl @Inject constructor(
+ private val authRepo: AuthRepo,
+ private val retrofitBuilder: RetrofitBuilder
+) : RecipeDataSource {
+ private var _recipeService: RecipeService? = null
+
+ override suspend fun requestRecipes(start: Int, end: Int): List {
+ Timber.v("requestRecipes() called")
+ val service: RecipeService = getRecipeService()
+ return service.getRecipeSummary(start, end)
+ }
+
+ private suspend fun getRecipeService(): RecipeService {
+ val cachedService: RecipeService? = _recipeService
+ val service: RecipeService = if (cachedService == null) {
+ val baseUrl = checkNotNull(authRepo.getBaseUrl()) { "Base url is null" }
+ val token = checkNotNull(authRepo.getToken()) { "Token is null" }
+ Timber.d("requestRecipes: baseUrl = $baseUrl, token = $token")
+ val retrofit = retrofitBuilder.buildRetrofit(baseUrl, token)
+ val createdService = retrofit.create(RecipeService::class.java)
+ _recipeService = createdService
+ createdService
+ } else {
+ cachedService
+ }
+ return service
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt
new file mode 100644
index 0000000..6cfa483
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt
@@ -0,0 +1,12 @@
+package gq.kirmanak.mealie.data.recipes.network
+
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface RecipeService {
+ @GET("/api/recipes/summary")
+ suspend fun getRecipeSummary(
+ @Query("start") start: Int,
+ @Query("end") end: Int
+ ): List
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt
index 404297e..fbd1c50 100644
--- a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt
+++ b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt
@@ -9,6 +9,7 @@ import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
@@ -46,6 +47,7 @@ class AuthenticationFragment : Fragment() {
private fun checkIfAuthenticatedAlready() {
Timber.v("checkIfAuthenticatedAlready() called")
lifecycleScope.launchWhenCreated {
+ if (viewModel.isAuthenticated()) navigateToRecipes()
Toast.makeText(
requireContext(),
if (viewModel.isAuthenticated()) "User is authenticated"
@@ -55,6 +57,10 @@ class AuthenticationFragment : Fragment() {
}
}
+ private fun navigateToRecipes() {
+ findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment())
+ }
+
private fun onLoginClicked() {
Timber.v("onLoginClicked() called")
val email: String
@@ -73,6 +79,7 @@ class AuthenticationFragment : Fragment() {
}
lifecycleScope.launchWhenResumed {
val exception = viewModel.authenticate(email, pass, url)
+ if (exception == null) navigateToRecipes()
Toast.makeText(
requireContext(),
"Exception is ${exception?.message ?: "null"}",
diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt
new file mode 100644
index 0000000..3cd9269
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt
@@ -0,0 +1,18 @@
+package gq.kirmanak.mealie.ui.recipes
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.Pager
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import dagger.hilt.android.lifecycle.HiltViewModel
+import gq.kirmanak.mealie.data.recipes.RecipeRepo
+import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+@HiltViewModel
+class RecipeViewModel @Inject constructor(private val recipeRepo: RecipeRepo) : ViewModel() {
+ private val pager: Pager by lazy { recipeRepo.createPager() }
+ val recipeFlow: Flow> by lazy { pager.flow.cachedIn(viewModelScope) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt
new file mode 100644
index 0000000..944320f
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt
@@ -0,0 +1,47 @@
+package gq.kirmanak.mealie.ui.recipes
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import dagger.hilt.android.AndroidEntryPoint
+import gq.kirmanak.mealie.databinding.FragmentRecipesBinding
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class RecipesFragment : Fragment() {
+ private var _binding: FragmentRecipesBinding? = null
+ private val binding: FragmentRecipesBinding
+ get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
+ private val viewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentRecipesBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.recipes.layoutManager = LinearLayoutManager(requireContext())
+ val recipesPagingAdapter = RecipesPagingAdapter()
+ binding.recipes.adapter = recipesPagingAdapter
+ lifecycleScope.launchWhenResumed {
+ viewModel.recipeFlow.collectLatest {
+ recipesPagingAdapter.submitData(it)
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt
new file mode 100644
index 0000000..8567e95
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt
@@ -0,0 +1,41 @@
+package gq.kirmanak.mealie.ui.recipes
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
+import gq.kirmanak.mealie.databinding.ViewHolderRecipeBinding
+import timber.log.Timber
+
+class RecipesPagingAdapter : PagingDataAdapter(RecipeDiffCallback) {
+ override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeViewHolder {
+ Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
+ return RecipeViewHolder(binding)
+ }
+}
+
+class RecipeViewHolder(private val binding: ViewHolderRecipeBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: RecipeEntity?) {
+ binding.name.text = item?.name
+ }
+}
+
+private object RecipeDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: RecipeEntity, newItem: RecipeEntity): Boolean {
+ return oldItem.localId == newItem.localId
+ }
+
+ override fun areContentsTheSame(oldItem: RecipeEntity, newItem: RecipeEntity): Boolean {
+ return oldItem == newItem
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_recipes.xml b/app/src/main/res/layout/fragment_recipes.xml
new file mode 100644
index 0000000..bce4175
--- /dev/null
+++ b/app/src/main/res/layout/fragment_recipes.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml
new file mode 100644
index 0000000..bba6e2f
--- /dev/null
+++ b/app/src/main/res/layout/view_holder_recipe.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index fcb33ca..2aaa25f 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -1,11 +1,21 @@
+ android:label="AuthenticationFragment" >
+
+
+
\ No newline at end of file