package xyz.pixelatedw.recipe import android.content.Intent 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.clickable 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.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.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.ui.theme.RecipeTheme import java.io.BufferedReader import java.io.InputStreamReader import androidx.compose.material3.Typography import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController class MainActivity : ComponentActivity() { private val recipeView: RecipesView by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) findSourceDir() enableEdgeToEdge() setContent { RecipeTheme { Surface { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> RecipeList(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 getContent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { result.data?.data?.let { uri -> getRecipes(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 ) } val annotatedString = parseMarkdown(active.value!!.content, MaterialTheme.typography) Text(text = annotatedString) } } } } @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) ) } 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()) { for (tag in recipe.tags) { Text( text = tag, modifier = Modifier.padding(start = 8.dp) ) } } } } //@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 -> { 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 } }