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