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..