Initialize RecipesFragment
This commit is contained in:
17
.idea/deploymentTargetDropDown.xml
generated
Normal file
17
.idea/deploymentTargetDropDown.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetDropDown">
|
||||||
|
<targetSelectedWithDropDown>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="$USER_HOME$/.android/avd/Pixel_5_API_30.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</targetSelectedWithDropDown>
|
||||||
|
<timeTargetWasSelectedWithDropDown value="2021-11-07T15:06:45.510498Z" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -4,8 +4,11 @@
|
|||||||
<option name="filePathToZoomLevelMap">
|
<option name="filePathToZoomLevelMap">
|
||||||
<map>
|
<map>
|
||||||
<entry key="app/src/main/res/layout/fragment_authentication.xml" value="0.3640625" />
|
<entry key="app/src/main/res/layout/fragment_authentication.xml" value="0.3640625" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_home.xml" value="0.12760416666666666" />
|
||||||
|
<entry key="app/src/main/res/layout/fragment_recipes.xml" value="0.2744456568144824" />
|
||||||
<entry key="app/src/main/res/layout/main_activity.xml" value="0.3640625" />
|
<entry key="app/src/main/res/layout/main_activity.xml" value="0.3640625" />
|
||||||
<entry key="app/src/main/res/layout/main_fragment.xml" value="0.20364583333333333" />
|
<entry key="app/src/main/res/layout/main_fragment.xml" value="0.20364583333333333" />
|
||||||
|
<entry key="app/src/main/res/layout/view_holder_recipe.xml" value="0.2674883374294164" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ dependencies {
|
|||||||
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
|
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
|
||||||
testImplementation "androidx.paging:paging-common-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-runtime:$room_version"
|
||||||
implementation "androidx.room:room-ktx:$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"
|
kapt "androidx.room:room-compiler:$room_version"
|
||||||
testImplementation "androidx.room:room-testing:$room_version"
|
testImplementation "androidx.room:room-testing:$room_version"
|
||||||
|
|
||||||
|
|||||||
18
app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt
Normal file
18
app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
20
app/src/main/java/gq/kirmanak/mealie/data/MealieModule.kt
Normal file
20
app/src/main/java/gq/kirmanak/mealie/data/MealieModule.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package gq.kirmanak.mealie.data
|
package gq.kirmanak.mealie.data
|
||||||
|
|
||||||
|
import gq.kirmanak.mealie.data.auth.AuthOkHttpInterceptor
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -7,13 +8,15 @@ import timber.log.Timber
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class OkHttpBuilder @Inject constructor() {
|
class OkHttpBuilder @Inject constructor() {
|
||||||
fun buildOkHttp(): OkHttpClient {
|
fun buildOkHttp(token: String?): OkHttpClient {
|
||||||
return OkHttpClient.Builder()
|
Timber.v("buildOkHttp() called with: token = $token")
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
.addNetworkInterceptor(buildLoggingInterceptor())
|
.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) }
|
val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) }
|
||||||
interceptor.level = HttpLoggingInterceptor.Level.BODY
|
interceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||||
return interceptor
|
return interceptor
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@ExperimentalSerializationApi
|
@ExperimentalSerializationApi
|
||||||
class RetrofitBuilder @Inject constructor(private val okHttpBuilder: OkHttpBuilder) {
|
class RetrofitBuilder @Inject constructor(private val okHttpBuilder: OkHttpBuilder) {
|
||||||
fun buildRetrofit(baseUrl: String): Retrofit {
|
fun buildRetrofit(baseUrl: String, token: String? = null): Retrofit {
|
||||||
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
|
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl, token = $token")
|
||||||
val url = if (baseUrl.startsWith("http")) baseUrl else "https://$baseUrl"
|
val url = if (baseUrl.startsWith("http")) baseUrl else "https://$baseUrl"
|
||||||
val contentType = "application/json".toMediaType()
|
val contentType = "application/json".toMediaType()
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(url)
|
.baseUrl(url)
|
||||||
.client(okHttpBuilder.buildOkHttp())
|
.client(okHttpBuilder.buildOkHttp(token))
|
||||||
.addConverterFactory(Json.asConverterFactory(contentType))
|
.addConverterFactory(Json.asConverterFactory(contentType))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Int, RecipeEntity>
|
||||||
|
}
|
||||||
@@ -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<Int, RecipeEntity> {
|
||||||
|
val pagingConfig = PagingConfig(pageSize = 30)
|
||||||
|
return Pager(pagingConfig, 0, mediator) {
|
||||||
|
storage.queryRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int, RecipeEntity>() {
|
||||||
|
|
||||||
|
override suspend fun load(
|
||||||
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, RecipeEntity>
|
||||||
|
): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM categories")
|
||||||
|
suspend fun queryAllCategories(): List<CategoryEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recipes")
|
||||||
|
fun queryRecipesByPages(): PagingSource<Int, RecipeEntity>
|
||||||
|
|
||||||
|
@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<TagRecipeEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertCategoryRecipeEntities(categoryRecipeEntities: Set<CategoryRecipeEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM recipes")
|
||||||
|
suspend fun removeAllRecipes()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<GetRecipeSummaryResponse>)
|
||||||
|
|
||||||
|
fun queryRecipes(): PagingSource<Int, RecipeEntity>
|
||||||
|
|
||||||
|
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
|
||||||
|
}
|
||||||
@@ -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<GetRecipeSummaryResponse>
|
||||||
|
) = db.withTransaction {
|
||||||
|
Timber.v("saveRecipes() called with $recipes")
|
||||||
|
|
||||||
|
val tagEntities = mutableSetOf<TagEntity>()
|
||||||
|
tagEntities.addAll(recipeDao.queryAllTags())
|
||||||
|
|
||||||
|
val categoryEntities = mutableSetOf<CategoryEntity>()
|
||||||
|
categoryEntities.addAll(recipeDao.queryAllCategories())
|
||||||
|
|
||||||
|
val tagRecipeEntities = mutableSetOf<TagRecipeEntity>()
|
||||||
|
val categoryRecipeEntities = mutableSetOf<CategoryRecipeEntity>()
|
||||||
|
|
||||||
|
for (recipe in recipes) {
|
||||||
|
val 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<CategoryEntity>,
|
||||||
|
category: String
|
||||||
|
): Long {
|
||||||
|
val existingCategory = categoryEntities.find { it.name == category }
|
||||||
|
val categoryId = if (existingCategory == null) {
|
||||||
|
val categoryEntity = CategoryEntity(name = category)
|
||||||
|
val newId = recipeDao.insertCategory(categoryEntity)
|
||||||
|
categoryEntities.add(categoryEntity.copy(localId = newId))
|
||||||
|
newId
|
||||||
|
} else {
|
||||||
|
existingCategory.localId
|
||||||
|
}
|
||||||
|
return categoryId
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getIdOrInsert(
|
||||||
|
tagEntities: MutableSet<TagEntity>,
|
||||||
|
tag: String
|
||||||
|
): Long {
|
||||||
|
val existingTag = tagEntities.find { it.name == tag }
|
||||||
|
val tagId = if (existingTag == null) {
|
||||||
|
val tagEntity = TagEntity(name = tag)
|
||||||
|
val newId = recipeDao.insertTag(tagEntity)
|
||||||
|
tagEntities.add(tagEntity.copy(localId = newId))
|
||||||
|
newId
|
||||||
|
} else {
|
||||||
|
existingTag.localId
|
||||||
|
}
|
||||||
|
return tagId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GetRecipeSummaryResponse.recipeEntity() = RecipeEntity(
|
||||||
|
remoteId = remoteId,
|
||||||
|
name = name,
|
||||||
|
slug = slug,
|
||||||
|
image = image,
|
||||||
|
description = description,
|
||||||
|
rating = rating,
|
||||||
|
dateAdded = dateAdded,
|
||||||
|
dateUpdated = dateUpdated,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun queryRecipes(): PagingSource<Int, RecipeEntity> {
|
||||||
|
Timber.v("queryRecipes() called")
|
||||||
|
return recipeDao.queryRecipesByPages()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>) {
|
||||||
|
Timber.v("refreshAll() called with: recipes = $recipes")
|
||||||
|
db.withTransaction {
|
||||||
|
recipeDao.removeAllRecipes()
|
||||||
|
saveRecipes(recipes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<String>,
|
||||||
|
@SerialName("tags") val tags: List<String>,
|
||||||
|
@SerialName("rating") val rating: Int?,
|
||||||
|
@SerialName("dateAdded") val dateAdded: Instant,
|
||||||
|
@SerialName("dateUpdated") val dateUpdated: Instant
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package gq.kirmanak.mealie.data.recipes.network
|
||||||
|
|
||||||
|
interface RecipeDataSource {
|
||||||
|
suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List<GetRecipeSummaryResponse>
|
||||||
|
}
|
||||||
@@ -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<GetRecipeSummaryResponse> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetRecipeSummaryResponse>
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import android.widget.Toast
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
|
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
|
||||||
@@ -46,6 +47,7 @@ class AuthenticationFragment : Fragment() {
|
|||||||
private fun checkIfAuthenticatedAlready() {
|
private fun checkIfAuthenticatedAlready() {
|
||||||
Timber.v("checkIfAuthenticatedAlready() called")
|
Timber.v("checkIfAuthenticatedAlready() called")
|
||||||
lifecycleScope.launchWhenCreated {
|
lifecycleScope.launchWhenCreated {
|
||||||
|
if (viewModel.isAuthenticated()) navigateToRecipes()
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
if (viewModel.isAuthenticated()) "User is authenticated"
|
if (viewModel.isAuthenticated()) "User is authenticated"
|
||||||
@@ -55,6 +57,10 @@ class AuthenticationFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun navigateToRecipes() {
|
||||||
|
findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment())
|
||||||
|
}
|
||||||
|
|
||||||
private fun onLoginClicked() {
|
private fun onLoginClicked() {
|
||||||
Timber.v("onLoginClicked() called")
|
Timber.v("onLoginClicked() called")
|
||||||
val email: String
|
val email: String
|
||||||
@@ -73,6 +79,7 @@ class AuthenticationFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
lifecycleScope.launchWhenResumed {
|
lifecycleScope.launchWhenResumed {
|
||||||
val exception = viewModel.authenticate(email, pass, url)
|
val exception = viewModel.authenticate(email, pass, url)
|
||||||
|
if (exception == null) navigateToRecipes()
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
"Exception is ${exception?.message ?: "null"}",
|
"Exception is ${exception?.message ?: "null"}",
|
||||||
|
|||||||
@@ -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<Int, RecipeEntity> by lazy { recipeRepo.createPager() }
|
||||||
|
val recipeFlow: Flow<PagingData<RecipeEntity>> by lazy { pager.flow.cachedIn(viewModelScope) }
|
||||||
|
}
|
||||||
@@ -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<RecipeViewModel>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RecipeEntity, RecipeViewHolder>(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<RecipeEntity>() {
|
||||||
|
override fun areItemsTheSame(oldItem: RecipeEntity, newItem: RecipeEntity): Boolean {
|
||||||
|
return oldItem.localId == newItem.localId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: RecipeEntity, newItem: RecipeEntity): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/src/main/res/layout/fragment_recipes.xml
Normal file
17
app/src/main/res/layout/fragment_recipes.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".ui.recipes.RecipesFragment">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recipes"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
25
app/src/main/res/layout/view_holder_recipe.xml
Normal file
25
app/src/main/res/layout/view_holder_recipe.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/name"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/image" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/image"
|
||||||
|
android:layout_width="340dp"
|
||||||
|
android:layout_height="224dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:srcCompat="@tools:sample/backgrounds/scenic" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/nav_graph"
|
android:id="@+id/nav_graph"
|
||||||
app:startDestination="@id/authenticationFragment">
|
app:startDestination="@id/authenticationFragment">
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/authenticationFragment"
|
android:id="@+id/authenticationFragment"
|
||||||
android:name="gq.kirmanak.mealie.ui.auth.AuthenticationFragment"
|
android:name="gq.kirmanak.mealie.ui.auth.AuthenticationFragment"
|
||||||
android:label="AuthenticationFragment" />
|
android:label="AuthenticationFragment" >
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_authenticationFragment_to_recipesFragment"
|
||||||
|
app:destination="@id/recipesFragment" />
|
||||||
|
</fragment>
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/recipesFragment"
|
||||||
|
android:name="gq.kirmanak.mealie.ui.recipes.RecipesFragment"
|
||||||
|
android:label="fragment_recipes"
|
||||||
|
tools:layout="@layout/fragment_recipes" />
|
||||||
</navigation>
|
</navigation>
|
||||||
Reference in New Issue
Block a user