Caching loaded recipes in database, copying files to app storage for easier handling and some fixes

master
Wynd 2025-08-09 18:09:09 +03:00
parent 0af796082c
commit 0b2e301f8a
14 changed files with 262 additions and 103 deletions

View File

@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.devtools.ksp)
}
android {
@ -49,18 +50,22 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.navigation.ui)
implementation(libs.androidx.navigation.navigation.compose)
implementation(libs.androidx.room)
implementation(libs.jtoml)
implementation(libs.commonmark)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.navigation.ui)
implementation(libs.androidx.navigation.navigation.compose)
implementation(libs.jtoml)
implementation(libs.commonmark)
}

View File

@ -1,6 +1,5 @@
package xyz.pixelatedw.recipe
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -11,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.room.Room
import xyz.pixelatedw.recipe.data.AppDatabase
import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.ui.components.MainScreen
import xyz.pixelatedw.recipe.ui.theme.RecipeTheme
@ -18,11 +19,18 @@ import xyz.pixelatedw.recipe.utils.getRecipes
class MainActivity : ComponentActivity() {
private val recipeView: RecipesView by viewModels()
private lateinit var db: AppDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
findSourceDir()
db = Room.databaseBuilder(
this,
AppDatabase::class.java, "recipes"
).allowMainThreadQueries().build()
val recipes = db.recipeWithTagsDao().getAll()
recipeView.setRecipes(recipes)
enableEdgeToEdge()
@ -30,25 +38,22 @@ class MainActivity : ComponentActivity() {
RecipeTheme {
Surface {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
MainScreen(innerPadding, recipeView)
MainScreen(this, innerPadding, recipeView)
}
}
}
}
}
private fun findSourceDir() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
val getContent =
val sourceChooser =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
getRecipes(this, recipeView, uri)
}
}
}
getRecipes(this, db, uri)
getContent.launch(intent)
val recipes = db.recipeWithTagsDao().getAll()
recipeView.setRecipes(recipes)
}
}
}
}

View File

@ -0,0 +1,16 @@
package xyz.pixelatedw.recipe.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
version = 1,
entities = [Recipe::class, Tag::class, RecipeTag::class],
)
abstract class AppDatabase : RoomDatabase() {
abstract fun recipeWithTagsDao(): RecipeWithTagsDao
abstract fun recipeDao(): RecipeDao
abstract fun tagDao(): TagDao
}

View File

@ -1,15 +1,41 @@
package xyz.pixelatedw.recipe.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.net.toUri
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import java.io.File
@Entity
data class Recipe(
@PrimaryKey
val title: String,
val tags: List<String>,
val previews: List<Bitmap>,
val preview: String?,
val lastModified: Long,
val content: String
) {
fun mainImage(): Bitmap? {
return this.previews.getOrNull(0)
fun mainImage(ctx: Context): Bitmap? {
if (this.preview != null) {
val file = File(ctx.filesDir, this.preview)
if (file.exists()) {
ctx.contentResolver.openInputStream(file.toUri()).use {
val bitmap: Bitmap? = BitmapFactory.decodeStream(it)
return bitmap
}
}
}
return null
}
}
@Dao
interface RecipeDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(recipe: Recipe)
}

View File

@ -0,0 +1,44 @@
package xyz.pixelatedw.recipe.data
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.Junction
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
@Entity(primaryKeys = ["title", "name"])
data class RecipeTag(
val title: String,
val name: String
)
data class RecipeWithTags(
@Embedded
val recipe: Recipe,
@Relation(
parentColumn = "title",
entity = Tag::class,
entityColumn = "name",
associateBy = Junction(
value = RecipeTag::class,
parentColumn = "title",
entityColumn = "name"
)
)
val tags: List<Tag>
)
@Dao
interface RecipeWithTagsDao {
@Transaction
@Query("SELECT * FROM recipe")
fun getAll(): List<RecipeWithTags>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(recipe: RecipeTag)
}

View File

@ -6,26 +6,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class RecipesView : ViewModel() {
private val _activeRecipe = MutableStateFlow<Recipe?>( null )
private val _activeRecipe = MutableStateFlow<RecipeWithTags?>( null )
val activeRecipe = _activeRecipe.asStateFlow()
private val _recipes = MutableStateFlow<List<Recipe>>( arrayListOf() )
private val _recipes = MutableStateFlow<List<RecipeWithTags>>( arrayListOf() )
val recipes = _recipes.asStateFlow()
private val _search = MutableStateFlow<String?>(null)
val search = _search.asStateFlow()
fun setRecipes(recipes: List<RecipeWithTags>) {
_recipes.update { recipes }
}
fun setSearch(search: String) {
_search.update { search }
}
fun addRecipe(recipe: Recipe) {
_recipes.update {
it + recipe
}
}
fun setActive(recipe: Recipe) {
fun setActive(recipe: RecipeWithTags) {
_activeRecipe.update { recipe }
}
}

View File

@ -0,0 +1,18 @@
package xyz.pixelatedw.recipe.data
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
@Entity
data class Tag(
@PrimaryKey val name: String
)
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(tag: Tag)
}

View File

@ -1,34 +1,44 @@
package xyz.pixelatedw.recipe.ui.components
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import xyz.pixelatedw.recipe.data.Recipe
import xyz.pixelatedw.recipe.MainActivity
import xyz.pixelatedw.recipe.data.RecipeWithTags
import xyz.pixelatedw.recipe.data.RecipesView
@Composable
fun MainScreen(padding: PaddingValues, view: RecipesView) {
fun MainScreen(ctx: MainActivity, padding: PaddingValues, view: RecipesView) {
val recipes = view.recipes.collectAsState()
val active = view.activeRecipe.collectAsState()
val search = view.search.collectAsState()
val navController = rememberNavController()
val isInSearch = isInSearch@{ recipe: Recipe ->
val hasTitle = recipe.title.contains(search.value.orEmpty(), ignoreCase = true)
val hasTags = recipe.tags.isNotEmpty() && recipe.tags.stream()
.filter { tag -> tag.contains(search.value.orEmpty(), ignoreCase = true) }.count() > 0
val isInSearch = isInSearch@{ entry: RecipeWithTags ->
val hasTitle = entry.recipe.title.contains(search.value.orEmpty(), ignoreCase = true)
val hasTags = entry.tags.isNotEmpty() && entry.tags.stream()
.filter { tag -> tag.name.contains(search.value.orEmpty(), ignoreCase = true) }
.count() > 0
hasTitle || hasTags
}
@ -39,18 +49,33 @@ fun MainScreen(padding: PaddingValues, view: RecipesView) {
) {
composable("list") {
Column(modifier = Modifier.padding(padding)) {
OutlinedTextField(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedTextField(
value = search.value.orEmpty(),
onValueChange = { search -> view.setSearch(search) })
onValueChange = { search -> view.setSearch(search) },
)
Button(
onClick = { ctx.sourceChooser.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) },
) {
Text(
text = "Load",
maxLines = 1,
textAlign = TextAlign.Center,
)
}
}
LazyColumn {
items(recipes.value) { recipe ->
if (isInSearch(recipe)) {
val previewUri = recipe.mainImage()
RecipePreview(recipe, previewUri, onClick = {
view.setActive(recipe)
items(recipes.value) { entry ->
if (isInSearch(entry)) {
val previewUri = entry.recipe.mainImage(LocalContext.current)
RecipePreview(entry, previewUri, onClick = {
view.setActive(entry)
navController.navigate("info")
})
}

View File

@ -16,10 +16,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import xyz.pixelatedw.recipe.data.Recipe
import xyz.pixelatedw.recipe.data.RecipeWithTags
import xyz.pixelatedw.recipe.utils.parseMarkdown
@Composable
fun RecipeInfo(padding: PaddingValues, active: Recipe) {
fun RecipeInfo(padding: PaddingValues, active: RecipeWithTags) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
@ -30,7 +31,7 @@ fun RecipeInfo(padding: PaddingValues, active: Recipe) {
modifier = Modifier.fillMaxWidth()
) {
Text(
text = active.title,
text = active.recipe.title,
style = MaterialTheme.typography.displayLarge,
textAlign = TextAlign.Center
)
@ -40,7 +41,7 @@ fun RecipeInfo(padding: PaddingValues, active: Recipe) {
modifier = Modifier.fillMaxWidth()
) {
val annotatedString =
parseMarkdown(active.content, MaterialTheme.typography)
parseMarkdown(active.recipe.content, MaterialTheme.typography)
Text(
text = annotatedString,
modifier = Modifier.padding(16.dp),

View File

@ -20,9 +20,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import xyz.pixelatedw.recipe.data.Recipe
import xyz.pixelatedw.recipe.data.RecipeWithTags
@Composable
fun RecipePreview(recipe: Recipe, previewUri: Bitmap?, onClick: () -> Unit) {
fun RecipePreview(entry: RecipeWithTags, previewUri: Bitmap?, onClick: () -> Unit) {
Column(modifier = Modifier
.padding(8.dp)
.clickable(onClick = onClick)) {
@ -38,7 +39,7 @@ fun RecipePreview(recipe: Recipe, previewUri: Bitmap?, onClick: () -> Unit) {
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
text = recipe.title,
text = entry.recipe.title,
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
textAlign = TextAlign.Center,
@ -47,7 +48,7 @@ fun RecipePreview(recipe: Recipe, previewUri: Bitmap?, onClick: () -> Unit) {
)
}
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (tag in recipe.tags) {
for (tag in entry.tags) {
Tag(tag)
}
}

View File

@ -10,9 +10,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import xyz.pixelatedw.recipe.data.Tag
@Composable
fun Tag(tag: String) {
fun Tag(tag: Tag) {
Box(
modifier = Modifier
.padding(start = 8.dp)
@ -21,7 +22,7 @@ fun Tag(tag: String) {
) {
Text(
modifier = Modifier.padding(start = 8.dp, end = 8.dp),
text = tag
text = tag.name
)
}
}

View File

@ -1,61 +1,56 @@
package xyz.pixelatedw.recipe.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.text.trimmedLength
import androidx.documentfile.provider.DocumentFile
import io.github.wasabithumb.jtoml.JToml
import xyz.pixelatedw.recipe.data.AppDatabase
import xyz.pixelatedw.recipe.data.Recipe
import xyz.pixelatedw.recipe.data.RecipesView
import xyz.pixelatedw.recipe.data.RecipeTag
import xyz.pixelatedw.recipe.data.Tag
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.InputStreamReader
private val previews = HashMap<String, Bitmap>()
private val recipeFiles = mutableListOf<DocumentFile>()
fun getRecipes(ctx: Context, view: RecipesView, uri: Uri?) {
fun getRecipes(ctx: Context, db: AppDatabase, uri: Uri?) {
if (uri == null) {
return
}
val path = ""
val dir = DocumentFile.fromTreeUri(ctx, uri)
if (dir != null) {
parseDir(ctx, dir)
parseDir(ctx, dir, path)
for (file in recipeFiles) {
val recipe = parseRecipe(ctx, file)
if (recipe != null) {
view.addRecipe(recipe)
}
parseRecipe(ctx, db, file)
}
}
}
fun parseDir(ctx: Context, dir: DocumentFile) {
fun parseDir(ctx: Context, dir: DocumentFile, path: String) {
val fileList: Array<DocumentFile> = dir.listFiles()
for (file in fileList) {
if(file.isDirectory) {
parseDir(ctx, file)
if (file.isDirectory) {
parseDir(ctx, file, path + File.separator + file.name)
continue;
}
if (file.isFile && file.name?.endsWith(".jpg") == true) {
val inStream = ctx.contentResolver.openInputStream(file.uri)
val picsDir = File(ctx.filesDir, path)
picsDir.mkdirs()
var bitmap: Bitmap? = null
if (file.exists()) {
try {
bitmap = BitmapFactory.decodeStream(inStream)
}
finally {
inStream?.close()
}
}
val newFile = File(picsDir, file.name!!)
if (bitmap != null) {
previews[file.name!!] = bitmap
ctx.contentResolver.openInputStream(file.uri).use { inStream ->
FileOutputStream(newFile, false).use { outStream ->
inStream?.copyTo(outStream)
}
}
}
if (file.isFile && file.name?.endsWith(".md") == true) {
@ -64,7 +59,7 @@ fun parseDir(ctx: Context, dir: DocumentFile) {
}
}
private fun parseRecipe(ctx: Context, file: DocumentFile): Recipe? {
private fun parseRecipe(ctx: Context, db: AppDatabase, file: DocumentFile) {
val lastModified = file.lastModified()
val inputStream = ctx.contentResolver.openInputStream(file.uri)
@ -75,9 +70,8 @@ private fun parseRecipe(ctx: Context, file: DocumentFile): Recipe? {
val sb = StringBuilder()
var hasToml = false
var frontMatterSize = 0
// TODO This could use some improvements as it always assumes frontmatter is the very first thing in the file
for (i in 0..lines.size) {
val line = lines[i]
for (line in lines) {
frontMatterSize += line.trimmedLength() + 1
if (line == "+++") {
@ -91,38 +85,57 @@ private fun parseRecipe(ctx: Context, file: DocumentFile): Recipe? {
sb.appendLine(line)
}
if (!hasToml) {
reader.close()
return
}
val toml = JToml.jToml()
val doc = toml.readFromString(sb.toString())
val tags = arrayListOf<String>()
for (tomlElem in doc["tags"]!!.asArray()) {
tags.add(tomlElem!!.asPrimitive().asString())
if (!doc.contains("title")) {
reader.close()
return
}
val recipeTitle = doc["title"]!!.asPrimitive().asString()
val pics = arrayListOf<String>()
if (doc.contains("pics")) {
for (tomlElem in doc["pics"]!!.asArray()) {
pics.add(tomlElem!!.asPrimitive().asString())
}
val recipePreviews = mutableListOf<Bitmap>()
for (pic in pics) {
val bitmap = previews[pic]
if (bitmap != null) {
recipePreviews.add(bitmap)
}
val tags = arrayListOf<Tag>()
if (doc.contains("tags")) {
for (tomlElem in doc["tags"]!!.asArray()) {
val tag = Tag(tomlElem!!.asPrimitive().asString())
tags.add(tag)
val recipeWithTags = RecipeTag(recipeTitle, tag.name)
db.tagDao().insert(tag)
db.recipeWithTagsDao().insert(recipeWithTags)
}
}
var recipePreview: String? = null
for (pic in pics) {
recipePreview = pic
}
val content = text.substring(frontMatterSize..<text.length)
val recipe = Recipe(
title = doc["title"]!!.asPrimitive().asString(),
tags = tags,
previews = recipePreviews,
title = recipeTitle,
preview = recipePreview,
lastModified = lastModified,
content = content
)
reader.close()
db.recipeDao().insert(recipe)
return recipe
println(recipe)
reader.close()
}

View File

@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.devtools.ksp) apply false
}

View File

@ -12,9 +12,13 @@ composeBom = "2025.07.00"
documentfile = "1.1.0"
commonmark = "0.25.1"
navVersion = "2.9.3"
room = "2.7.2"
roomCompiler = "2.7.2"
ksp = "2.0.21-1.0.27"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
jtoml = { group = "io.github.wasabithumb", name = "jtoml", version.ref = "jtoml" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@ -34,9 +38,10 @@ commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "com
androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navVersion"}
androidx-navigation-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navVersion"}
androidx-navigation-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navVersion"}
androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room"}
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"}