From 8854c39f13b43f35fea640d3d400869537b35047 Mon Sep 17 00:00:00 2001 From: Wynd Date: Fri, 8 Aug 2025 23:21:33 +0300 Subject: [PATCH] Split the codebase more nicely --- app/src/main/AndroidManifest.xml | 1 - .../xyz/pixelatedw/recipe/MainActivity.kt | 431 +----------------- .../java/xyz/pixelatedw/recipe/data/Recipe.kt | 8 + .../xyz/pixelatedw/recipe/data/RecipesView.kt | 24 + .../recipe/ui/components/MainScreen.kt | 40 ++ .../recipe/ui/components/RecipeInfo.kt | 50 ++ .../recipe/ui/components/RecipePreview.kt | 50 ++ .../pixelatedw/recipe/ui/components/Tag.kt | 27 ++ .../pixelatedw/recipe/utils/MarkdownParser.kt | 191 ++++++++ .../pixelatedw/recipe/utils/RecipeParser.kt | 77 ++++ 10 files changed, 482 insertions(+), 417 deletions(-) create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/data/Recipe.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/data/RecipesView.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/ui/components/MainScreen.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipeInfo.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipePreview.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/ui/components/Tag.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/utils/MarkdownParser.kt create mode 100644 app/src/main/java/xyz/pixelatedw/recipe/utils/RecipeParser.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8eada8f..0a05bbb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,6 @@ android:theme="@style/Theme.Recipe"> - diff --git a/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt b/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt index ec56190..afc8483 100644 --- a/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt +++ b/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt @@ -1,86 +1,20 @@ package xyz.pixelatedw.recipe import android.content.Intent -import android.graphics.Color -import android.net.Uri import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.ParagraphStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextIndent -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import androidx.core.text.trimmedLength -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.ViewModel -import io.github.wasabithumb.jtoml.JToml -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import org.commonmark.node.BulletList -import org.commonmark.node.Document -import org.commonmark.node.Emphasis -import org.commonmark.node.HardLineBreak -import org.commonmark.node.Heading -import org.commonmark.node.ListItem -import org.commonmark.node.Node -import org.commonmark.node.OrderedList -import org.commonmark.node.Paragraph -import org.commonmark.node.SoftLineBreak -import org.commonmark.node.StrongEmphasis -import org.commonmark.node.ThematicBreak -import org.commonmark.parser.Parser +import xyz.pixelatedw.recipe.data.RecipesView +import xyz.pixelatedw.recipe.ui.components.MainScreen import xyz.pixelatedw.recipe.ui.theme.RecipeTheme -import java.io.BufferedReader -import java.io.InputStreamReader -import androidx.compose.material3.Typography -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.text.style.LineBreak -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController +import xyz.pixelatedw.recipe.utils.getRecipes class MainActivity : ComponentActivity() { private val recipeView: RecipesView by viewModels() @@ -90,365 +24,30 @@ class MainActivity : ComponentActivity() { findSourceDir() - enableEdgeToEdge() - setContent { + enableEdgeToEdge() + setContent { RecipeTheme { Surface { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - RecipeList(innerPadding, recipeView) + MainScreen(innerPadding, recipeView) } } } - } - } + } + } private fun findSourceDir() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addCategory(Intent.CATEGORY_DEFAULT) - } + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - val getContent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.data?.let { uri -> - getRecipes(uri) + val getContent = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { uri -> + getRecipes(this, recipeView, uri) + } } } - } getContent.launch(intent) } - - private fun getRecipes(uri: Uri?) { - if (uri == null) { - return - } - - val dir = DocumentFile.fromTreeUri(this, uri) - if (dir != null) { - val fileList: Array = dir.listFiles() - for (file in fileList) { - if (file.isFile && file.name?.endsWith(".md") == true) { - val recipe = parseRecipe(file) - if (recipe != null) { - this.recipeView.addRecipe(recipe) - } - } - } - } - } - - private fun parseRecipe(file: DocumentFile): Recipe? { - val lastModified = file.lastModified() - - val inputStream = this.contentResolver.openInputStream(file.uri) - val reader = BufferedReader(InputStreamReader(inputStream)) - val text = reader.readText() - val lines = text.lines() - - 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] - frontMatterSize += line.trimmedLength() + 1 - - if (line == "+++") { - if (hasToml) { - break - } - hasToml = true - continue - } - - sb.appendLine(line) - } - - val toml = JToml.jToml() - val doc = toml.readFromString(sb.toString()) - - val tags = arrayListOf() - for (tomlElem in doc["tags"]!!.asArray()) { - tags.add(tomlElem!!.asPrimitive().asString()) - } - - val content = text.substring(frontMatterSize..( null ) - val activeRecipe = _activeRecipe.asStateFlow() - - private val _recipes = MutableStateFlow>( arrayListOf() ) - val recipes = _recipes.asStateFlow() - - fun addRecipe(recipe: Recipe) { - _recipes.update { - it + recipe - } - } - - fun setActive(recipe: Recipe) { - _activeRecipe.update { recipe } - } -} - -data class Recipe( - val title: String, - val tags: List, - val lastModified: Long, - val content: String -) - -@Composable -fun RecipeList(padding: PaddingValues, view: RecipesView) { - val recipes = view.recipes.collectAsState() - val active = view.activeRecipe.collectAsState() - - val navController = rememberNavController() - - NavHost( - navController = navController, - startDestination = "list" - ) { - composable("list") { - LazyColumn(modifier = Modifier.padding(padding)) { - items(recipes.value) { recipe -> - RecipePreview(recipe, onClick = { - view.setActive(recipe) - navController.navigate("info") - }) - } - } - } - composable("info") { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(padding) - ) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = active.value!!.title, - style = MaterialTheme.typography.displayLarge, - textAlign = TextAlign.Center - ) - } - - Box( - modifier = Modifier.fillMaxWidth() - ) { - val annotatedString = - parseMarkdown(active.value!!.content, MaterialTheme.typography) - Text( - text = annotatedString, - modifier = Modifier.padding(16.dp), - ) - } - } - } - } -} - -@Composable -fun RecipePreview(recipe: Recipe, onClick: () -> Unit) { - Column(modifier = Modifier - .padding(8.dp) - .clickable(onClick = onClick)) { - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(R.drawable.ic_launcher_background), - contentDescription = "Recipe image", - modifier = Modifier.size(256.dp).padding(top = 16.dp, bottom = 16.dp) - ) - } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - text = recipe.title, - modifier = Modifier.fillMaxWidth(), - style = TextStyle( - textAlign = TextAlign.Center, - fontSize = 7.em, - ) - ) - } - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { - for (tag in recipe.tags) { - Box( - modifier = Modifier - .padding(start = 8.dp) - .clip(RoundedCornerShape(percent = 50)) - .background(androidx.compose.ui.graphics.Color(0, 153, 170)) - ) { - Text( - modifier = Modifier.padding(start = 8.dp, end = 8.dp), - text = tag - ) - } - } - } - } -} - -//@Preview( -// showBackground = true, -// name = "Light Mode" -//) -//@Preview( -// uiMode = Configuration.UI_MODE_NIGHT_YES, -// showBackground = true, -// name = "Dark Mode" -//) -//@Composable -//fun RecipePreviewPreview() { -// RecipeTheme { -// Surface { -// RecipePreview(Recipe("Test", listOf("test", "test2", "test3")), active) -// } -// } -//} - -private fun parseMarkdown( - markdown: String, - typography: Typography, -): AnnotatedString { - val parser = Parser.builder().build() - val document = parser.parse(markdown) - val annotatedString = buildAnnotatedString { - visitMarkdownNode(document, typography) - } - return annotatedString.trim() as AnnotatedString -} - -private fun AnnotatedString.Builder.visitMarkdownNode( - node: Node, - typography: Typography, -) { - val headingColor = androidx.compose.ui.graphics.Color.White - when (node) { - is Heading -> { - val style = when (node.level) { - in 1..3 -> typography.titleLarge - 4 -> typography.titleMedium - 5 -> typography.bodySmall - else -> typography.bodySmall - } - withStyle(style.toParagraphStyle().merge(ParagraphStyle(textAlign = TextAlign.Center))) { - withStyle(style.toSpanStyle().copy(color = headingColor)) { - visitChildren(node, typography) - appendLine() - } - } - } - is Paragraph -> { - if (node.parents.any { it is BulletList || it is OrderedList }) { - visitChildren(node, typography) - } else { - withStyle(typography.bodyLarge.toParagraphStyle()) { - visitChildren(node, typography) - appendLine() - } - } - } - is Emphasis -> { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - visitChildren(node, typography) - } - } - is StrongEmphasis -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - visitChildren(node, typography) - } - } - is org.commonmark.node.Text -> { - append(node.literal) - visitChildren(node, typography) - } - is SoftLineBreak -> { - append(" ") - } - is HardLineBreak -> { - appendLine() - } - is ThematicBreak -> { - withStyle(ParagraphStyle(textAlign = TextAlign.Center)) { - withStyle(SpanStyle(letterSpacing = 0.sp)) { - appendLine("─".repeat(10)) - } - } - } - is OrderedList -> { - withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = 10.sp, restLine = 20.sp))) { - visitChildren(node, typography) - } - } - is BulletList -> { - withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = 10.sp, restLine = 20.sp))) { - visitChildren(node, typography) - } - } - is ListItem -> { - withStyle(ParagraphStyle(lineHeight = 18.sp)) { - if (node.parents.any { it is BulletList }) { - append("• ") - } else if (node.parents.any { it is OrderedList }) { - val startNumber = (node.parents.first { it is OrderedList } as OrderedList).markerStartNumber - val index = startNumber + node.previousSiblings.filterIsInstance().size - append("$index. ") - } - visitChildren(node, typography) - appendLine() - } - } - is Document -> { - visitChildren(node, typography) - } - else -> { - Log.e("MarkdownText", "Traversing unhandled node: $node") - visitChildren(node, typography) - } - } -} - -private fun AnnotatedString.Builder.visitChildren( - node: Node, - typography: Typography, -) { - var child = node.firstChild - while (child != null) { - visitMarkdownNode(child, typography) - child = child.next - } -} - -private val Node.parents: List get() { - val list = mutableListOf() - var current = this - while (true) { - current = current.parent ?: return list - list += current - } -} - -private val Node.previousSiblings: List get() { - val list = mutableListOf() - var current = this - while (true) { - current = current.previous ?: return list - list += current - } } diff --git a/app/src/main/java/xyz/pixelatedw/recipe/data/Recipe.kt b/app/src/main/java/xyz/pixelatedw/recipe/data/Recipe.kt new file mode 100644 index 0000000..609464b --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/data/Recipe.kt @@ -0,0 +1,8 @@ +package xyz.pixelatedw.recipe.data + +data class Recipe( + val title: String, + val tags: List, + val lastModified: Long, + val content: String +) diff --git a/app/src/main/java/xyz/pixelatedw/recipe/data/RecipesView.kt b/app/src/main/java/xyz/pixelatedw/recipe/data/RecipesView.kt new file mode 100644 index 0000000..eb3dd57 --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/data/RecipesView.kt @@ -0,0 +1,24 @@ +package xyz.pixelatedw.recipe.data + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class RecipesView : ViewModel() { + private val _activeRecipe = MutableStateFlow( null ) + val activeRecipe = _activeRecipe.asStateFlow() + + private val _recipes = MutableStateFlow>( arrayListOf() ) + val recipes = _recipes.asStateFlow() + + fun addRecipe(recipe: Recipe) { + _recipes.update { + it + recipe + } + } + + fun setActive(recipe: Recipe) { + _activeRecipe.update { recipe } + } +} diff --git a/app/src/main/java/xyz/pixelatedw/recipe/ui/components/MainScreen.kt b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/MainScreen.kt new file mode 100644 index 0000000..62611e3 --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/MainScreen.kt @@ -0,0 +1,40 @@ +package xyz.pixelatedw.recipe.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import xyz.pixelatedw.recipe.data.RecipesView + +@Composable +fun MainScreen(padding: PaddingValues, view: RecipesView) { + val recipes = view.recipes.collectAsState() + val active = view.activeRecipe.collectAsState() + + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = "list" + ) { + composable("list") { + LazyColumn(modifier = Modifier.padding(padding)) { + items(recipes.value) { recipe -> + RecipePreview(recipe, onClick = { + view.setActive(recipe) + navController.navigate("info") + }) + } + } + } + composable("info") { + RecipeInfo(padding, active.value!!) + } + } +} diff --git a/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipeInfo.kt b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipeInfo.kt new file mode 100644 index 0000000..1d2373b --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipeInfo.kt @@ -0,0 +1,50 @@ +package xyz.pixelatedw.recipe.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.utils.parseMarkdown + +@Composable +fun RecipeInfo(padding: PaddingValues, active: Recipe) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(padding) + ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = active.title, + style = MaterialTheme.typography.displayLarge, + textAlign = TextAlign.Center + ) + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + val annotatedString = + parseMarkdown(active.content, MaterialTheme.typography) + Text( + text = annotatedString, + modifier = Modifier.padding(16.dp), + ) + } + } +} diff --git a/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipePreview.kt b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipePreview.kt new file mode 100644 index 0000000..77ae76b --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/RecipePreview.kt @@ -0,0 +1,50 @@ +package xyz.pixelatedw.recipe.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import xyz.pixelatedw.recipe.R +import xyz.pixelatedw.recipe.data.Recipe + +@Composable +fun RecipePreview(recipe: Recipe, onClick: () -> Unit) { + Column(modifier = Modifier + .padding(8.dp) + .clickable(onClick = onClick)) { + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(R.drawable.ic_launcher_background), + contentDescription = "Recipe image", + modifier = Modifier.size(256.dp).padding(top = 16.dp, bottom = 16.dp) + ) + } + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text( + text = recipe.title, + modifier = Modifier.fillMaxWidth(), + style = TextStyle( + textAlign = TextAlign.Center, + fontSize = 7.em, + ) + ) + } + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + for (tag in recipe.tags) { + Tag(tag) + } + } + } +} diff --git a/app/src/main/java/xyz/pixelatedw/recipe/ui/components/Tag.kt b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/Tag.kt new file mode 100644 index 0000000..a74adb7 --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/ui/components/Tag.kt @@ -0,0 +1,27 @@ +package xyz.pixelatedw.recipe.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun Tag(tag: String) { + Box( + modifier = Modifier + .padding(start = 8.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(Color(0, 153, 170)) + ) { + Text( + modifier = Modifier.padding(start = 8.dp, end = 8.dp), + text = tag + ) + } +} diff --git a/app/src/main/java/xyz/pixelatedw/recipe/utils/MarkdownParser.kt b/app/src/main/java/xyz/pixelatedw/recipe/utils/MarkdownParser.kt new file mode 100644 index 0000000..628c92d --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/utils/MarkdownParser.kt @@ -0,0 +1,191 @@ +package xyz.pixelatedw.recipe.utils + +import android.util.Log +import androidx.compose.material3.Typography +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import org.commonmark.node.BulletList +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.HardLineBreak +import org.commonmark.node.Heading +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +fun parseMarkdown( + markdown: String, + typography: Typography, +): AnnotatedString { + val parser = Parser.builder().build() + val document = parser.parse(markdown) + val annotatedString = buildAnnotatedString { + visitMarkdownNode(document, typography) + } + return annotatedString.trim() as AnnotatedString +} + +private fun AnnotatedString.Builder.visitMarkdownNode( + node: Node, + typography: Typography, +) { + val headingColor = androidx.compose.ui.graphics.Color.White + when (node) { + is Heading -> { + val style = when (node.level) { + in 1..3 -> typography.titleLarge + 4 -> typography.titleMedium + 5 -> typography.bodySmall + else -> typography.bodySmall + } + withStyle( + style.toParagraphStyle().merge(ParagraphStyle(textAlign = TextAlign.Center)) + ) { + withStyle(style.toSpanStyle().copy(color = headingColor)) { + visitChildren(node, typography) + appendLine() + } + } + } + + is Paragraph -> { + if (node.parents.any { it is BulletList || it is OrderedList }) { + visitChildren(node, typography) + } else { + withStyle(typography.bodyLarge.toParagraphStyle()) { + visitChildren(node, typography) + appendLine() + } + } + } + + is Emphasis -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + visitChildren(node, typography) + } + } + + is StrongEmphasis -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + visitChildren(node, typography) + } + } + + is Text -> { + append(node.literal) + visitChildren(node, typography) + } + + is SoftLineBreak -> { + append(" ") + } + + is HardLineBreak -> { + appendLine() + } + + is ThematicBreak -> { + withStyle(ParagraphStyle(textAlign = TextAlign.Center)) { + withStyle(SpanStyle(letterSpacing = 0.sp)) { + appendLine("─".repeat(10)) + } + } + } + + is OrderedList -> { + withStyle( + ParagraphStyle( + textIndent = TextIndent( + firstLine = 10.sp, + restLine = 20.sp + ) + ) + ) { + visitChildren(node, typography) + } + } + + is BulletList -> { + withStyle( + ParagraphStyle( + textIndent = TextIndent( + firstLine = 10.sp, + restLine = 20.sp + ) + ) + ) { + visitChildren(node, typography) + } + } + + is ListItem -> { + withStyle(ParagraphStyle(lineHeight = 18.sp)) { + if (node.parents.any { it is BulletList }) { + append("• ") + } else if (node.parents.any { it is OrderedList }) { + val startNumber = + (node.parents.first { it is OrderedList } as OrderedList).markerStartNumber + val index = + startNumber + node.previousSiblings.filterIsInstance().size + append("$index. ") + } + visitChildren(node, typography) + appendLine() + } + } + + is Document -> { + visitChildren(node, typography) + } + + else -> { + Log.e("MarkdownText", "Traversing unhandled node: $node") + visitChildren(node, typography) + } + } +} + +private fun AnnotatedString.Builder.visitChildren( + node: Node, + typography: Typography, +) { + var child = node.firstChild + while (child != null) { + visitMarkdownNode(child, typography) + child = child.next + } +} + +private val Node.parents: List + get() { + val list = mutableListOf() + var current = this + while (true) { + current = current.parent ?: return list + list += current + } + } + +private val Node.previousSiblings: List + get() { + val list = mutableListOf() + var current = this + while (true) { + current = current.previous ?: return list + list += current + } + } diff --git a/app/src/main/java/xyz/pixelatedw/recipe/utils/RecipeParser.kt b/app/src/main/java/xyz/pixelatedw/recipe/utils/RecipeParser.kt new file mode 100644 index 0000000..8cc1dbe --- /dev/null +++ b/app/src/main/java/xyz/pixelatedw/recipe/utils/RecipeParser.kt @@ -0,0 +1,77 @@ +package xyz.pixelatedw.recipe.utils + +import android.content.Context +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.Recipe +import xyz.pixelatedw.recipe.data.RecipesView +import java.io.BufferedReader +import java.io.InputStreamReader + +fun getRecipes(ctx: Context, view: RecipesView, uri: Uri?) { + if (uri == null) { + return + } + + val dir = DocumentFile.fromTreeUri(ctx, uri) + if (dir != null) { + val fileList: Array = dir.listFiles() + for (file in fileList) { + if (file.isFile && file.name?.endsWith(".md") == true) { + val recipe = parseRecipe(ctx, file) + if (recipe != null) { + view.addRecipe(recipe) + } + } + } + } +} + +private fun parseRecipe(ctx: Context, file: DocumentFile): Recipe? { + val lastModified = file.lastModified() + + val inputStream = ctx.contentResolver.openInputStream(file.uri) + val reader = BufferedReader(InputStreamReader(inputStream)) + val text = reader.readText() + val lines = text.lines() + + 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] + frontMatterSize += line.trimmedLength() + 1 + + if (line == "+++") { + if (hasToml) { + break + } + hasToml = true + continue + } + + sb.appendLine(line) + } + + val toml = JToml.jToml() + val doc = toml.readFromString(sb.toString()) + + val tags = arrayListOf() + for (tomlElem in doc["tags"]!!.asArray()) { + tags.add(tomlElem!!.asPrimitive().asString()) + } + + val content = text.substring(frontMatterSize..