diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/AttachmentGrid.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/AttachmentGrid.kt new file mode 100644 index 0000000..e9b2b14 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/AttachmentGrid.kt @@ -0,0 +1,46 @@ +package com.avinal.memos.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.avinal.memos.domain.Attachment + +@Composable +fun AttachmentGrid( + attachments: List, + serverUrl: String, + modifier: Modifier = Modifier, +) { + val images = attachments.filter { it.isImage } + if (images.isEmpty()) return + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + images.forEach { attachment -> + val imageUrl = buildImageUrl(attachment, serverUrl) + AsyncImage( + model = imageUrl, + contentDescription = attachment.filename, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.FillWidth, + ) + } + } +} + +private fun buildImageUrl(attachment: Attachment, serverUrl: String): String { + if (attachment.externalLink.isNotEmpty()) return attachment.externalLink + val base = serverUrl.trimEnd('/') + return "$base/file/${attachment.name}/${attachment.filename}" +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt new file mode 100644 index 0000000..050f56b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt @@ -0,0 +1,414 @@ +package com.avinal.memos.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.avinal.memos.ui.theme.LocalAccentColor + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MarkdownText( + markdown: String, + modifier: Modifier = Modifier, + onTaskToggle: ((lineIndex: Int, checked: Boolean) -> Unit)? = null, +) { + val textColor = MaterialTheme.colorScheme.onBackground + val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + val accent = LocalAccentColor.current + + Column(modifier = modifier) { + val lines = markdown.lines() + var lineIndex = 0 + var inCodeBlock = false + val codeBlockLines = mutableListOf() + + while (lineIndex < lines.size) { + val line = lines[lineIndex] + + if (line.trimStart().startsWith("```")) { + if (inCodeBlock) { + CodeBlock(codeBlockLines.joinToString("\n"), textColor) + codeBlockLines.clear() + inCodeBlock = false + } else { + inCodeBlock = true + } + lineIndex++ + continue + } + + if (inCodeBlock) { + codeBlockLines.add(line) + lineIndex++ + continue + } + + when { + line.startsWith("### ") -> HeadingBlock(line.removePrefix("### "), level = 3, textColor) + line.startsWith("## ") -> HeadingBlock(line.removePrefix("## "), level = 2, textColor) + line.startsWith("# ") && !line.startsWith("# ", 1) -> { + val text = line.removePrefix("# ") + if (text.all { it == '#' || it.isLetterOrDigit() || it == '_' || it == '-' } && !text.contains(' ')) { + TagChip(text, accent) + } else { + HeadingBlock(text, level = 1, textColor) + } + } + line.trimStart().startsWith("- [") && line.contains("]") -> { + val checked = line.contains("[x]", ignoreCase = true) + val text = line.substringAfter("] ").trim() + TaskItem(text, checked, lineIndex, onTaskToggle, textColor, subtleColor, accent) + } + line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ") -> { + val text = line.trimStart().removePrefix("- ").removePrefix("* ") + ListItem(text, textColor, accent) + } + line.startsWith("> ") -> BlockquoteBlock(line.removePrefix("> "), subtleColor, accent) + line.trim() == "---" || line.trim() == "***" || line.trim() == "___" -> { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = subtleColor.copy(alpha = 0.3f)) + } + line.isBlank() -> Spacer(Modifier.height(4.dp)) + else -> { + val tagRegex = Regex("""^#(\w+)(\s+.*)?$""") + val tagMatch = tagRegex.find(line.trim()) + if (tagMatch != null && !line.trim().startsWith("##")) { + FlowRow( + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp), + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp), + ) { + Regex("""#(\w+)""").findAll(line).forEach { match -> + TagChip(match.groupValues[1], accent) + } + val nonTagText = line.replace(Regex("""#\w+"""), "").trim() + if (nonTagText.isNotEmpty()) { + Text(parseInlineFormatting(nonTagText, textColor, accent), style = MaterialTheme.typography.bodyMedium) + } + } + } else { + ParagraphBlock(line, textColor, accent) + } + } + } + lineIndex++ + } + + if (inCodeBlock && codeBlockLines.isNotEmpty()) { + CodeBlock(codeBlockLines.joinToString("\n"), textColor) + } + } +} + +@Composable +private fun TagChip(tag: String, accent: Color) { + Text( + text = "#$tag", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp), + color = accent, + modifier = Modifier + .border(1.dp, accent.copy(alpha = 0.4f), RoundedCornerShape(50)) + .background(accent.copy(alpha = 0.1f), RoundedCornerShape(50)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) +} + +@Composable +private fun HeadingBlock(text: String, level: Int, textColor: Color) { + val accent = LocalAccentColor.current + val style = when (level) { + 1 -> MaterialTheme.typography.headlineMedium + 2 -> MaterialTheme.typography.titleLarge + else -> MaterialTheme.typography.titleMedium + } + Text( + text = parseInlineFormatting(text, textColor, accent), + style = style, + color = textColor, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) +} + +@Composable +private fun ParagraphBlock(text: String, textColor: Color, accent: Color) { + Text( + text = parseInlineFormatting(text, textColor, accent), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = textColor, + ) +} + +@Composable +private fun ListItem(text: String, textColor: Color, accent: Color) { + Row(modifier = Modifier.padding(start = 4.dp, top = 1.dp, bottom = 1.dp)) { + Text("•", style = MaterialTheme.typography.bodyMedium, color = textColor) + Spacer(Modifier.width(8.dp)) + Text( + text = parseInlineFormatting(text, textColor, accent), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = textColor, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun TaskItem( + text: String, + checked: Boolean, + actualLineIndex: Int, + onToggle: ((Int, Boolean) -> Unit)?, + textColor: Color, + subtleColor: Color, + accent: Color, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, top = 3.dp, bottom = 3.dp), + ) { + Checkbox( + checked = checked, + onCheckedChange = { newValue -> onToggle?.invoke(actualLineIndex, newValue) }, + modifier = Modifier.size(20.dp), + colors = CheckboxDefaults.colors( + checkedColor = accent, + uncheckedColor = subtleColor, + checkmarkColor = Color.White, + ), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = parseInlineFormattingWithTags(text, textColor, accent), + style = MaterialTheme.typography.bodyMedium.copy( + lineHeight = 22.sp, + textDecoration = if (checked) TextDecoration.LineThrough else TextDecoration.None, + ), + color = if (checked) subtleColor else textColor, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun BlockquoteBlock(text: String, subtleColor: Color, accent: Color) { + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Spacer( + modifier = Modifier + .width(3.dp) + .height(20.dp) + .background(accent.copy(alpha = 0.4f)), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = parseInlineFormatting(text, subtleColor, accent), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = subtleColor, + ) + } +} + +@Composable +private fun CodeBlock(code: String, textColor: Color) { + val accent = LocalAccentColor.current + val highlighted = remember(code) { highlightCode(code, textColor, accent) } + + Text( + text = highlighted, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 20.sp, + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp), + ) +} + +private val codeKeywords = setOf( + "fun", "val", "var", "class", "object", "interface", "if", "else", "when", "for", "while", + "return", "import", "package", "suspend", "override", "private", "public", "internal", + "data", "sealed", "enum", "companion", "abstract", "open", "const", "lateinit", + "def", "self", "lambda", "yield", "async", "await", "try", "catch", "finally", "throw", + "function", "let", "const", "export", "default", "from", "this", "new", "delete", "typeof", + "int", "float", "double", "string", "bool", "boolean", "void", "null", "true", "false", + "struct", "impl", "trait", "pub", "fn", "mod", "use", "mut", "ref", "match", +) +private val codeStringRegex = Regex("""(".*?"|'.*?')""") +private val codeCommentRegex = Regex("""(//.*$|#.*$)""", RegexOption.MULTILINE) +private val codeNumberRegex = Regex("""\b(\d+\.?\d*)\b""") + +private fun highlightCode(code: String, textColor: Color, accent: Color): AnnotatedString = buildAnnotatedString { + val stringColor = Color(0xFF6A9955) + val commentColor = Color(0xFF6A6A6A) + val keywordColor = accent + val numberColor = Color(0xFFB5CEA8) + + data class Span(val range: IntRange, val style: SpanStyle) + val spans = mutableListOf() + + codeCommentRegex.findAll(code).forEach { spans.add(Span(it.range, SpanStyle(color = commentColor))) } + codeStringRegex.findAll(code).forEach { + if (spans.none { s -> s.range.first <= it.range.first && s.range.last >= it.range.last }) { + spans.add(Span(it.range, SpanStyle(color = stringColor))) + } + } + Regex("""\b(\w+)\b""").findAll(code).forEach { match -> + if (match.groupValues[1] in codeKeywords) { + if (spans.none { s -> s.range.first <= match.range.first && s.range.last >= match.range.last }) { + spans.add(Span(match.range, SpanStyle(color = keywordColor, fontWeight = FontWeight.SemiBold))) + } + } + } + codeNumberRegex.findAll(code).forEach { match -> + if (spans.none { s -> s.range.first <= match.range.first && s.range.last >= match.range.last }) { + spans.add(Span(match.range, SpanStyle(color = numberColor))) + } + } + + spans.sortBy { it.range.first } + + var pos = 0 + for (span in spans) { + if (span.range.first > pos) { + withStyle(SpanStyle(color = textColor)) { append(code.substring(pos, span.range.first)) } + } + if (span.range.first >= pos) { + withStyle(span.style) { append(code.substring(span.range)) } + pos = span.range.last + 1 + } + } + if (pos < code.length) { + withStyle(SpanStyle(color = textColor)) { append(code.substring(pos)) } + } +} + +private val boldItalicRegex = Regex("""\*\*\*(.+?)\*\*\*""") +private val boldItalicUnderRegex = Regex("""___(.+?)___""") +private val boldRegex = Regex("""\*\*(.+?)\*\*""") +private val boldUnderRegex = Regex("""__(.+?)__""") +private val italicRegex = Regex("""(? 1) firstMatch.groupValues[1] else firstMatch.value + + when { + matchedRegex.value.startsWith("http") -> + withStyle(SpanStyle(color = accent)) { append(firstMatch.value) } + matchedRegex.value.startsWith("***") || matchedRegex.value.startsWith("___") -> + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic, color = textColor)) { append(inner) } + matchedRegex.value.startsWith("**") || matchedRegex.value.startsWith("__") -> + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = textColor)) { append(inner) } + matchedRegex.value.startsWith("~~") -> + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough, color = textColor)) { append(inner) } + matchedRegex.value.startsWith("*") || matchedRegex.value.startsWith("_") -> + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = textColor)) { append(inner) } + matchedRegex.value.startsWith("`") -> + withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, background = accent.copy(alpha = 0.1f), color = textColor)) { append(inner) } + matchedRegex.value.startsWith("[") -> + withStyle(SpanStyle(color = accent)) { append(inner) } + } + + remaining = remaining.substring(firstMatch.range.last + 1) + } +} + +private fun parseInlineFormattingWithTags(text: String, textColor: Color, accent: Color): AnnotatedString = buildAnnotatedString { + var remaining = text + while (remaining.isNotEmpty()) { + val tagMatch = inlineTagRegex.find(remaining) + val boldMatch = boldRegex.find(remaining) + val italicMatch = italicRegex.find(remaining) + val codeMatch = codeRegex.find(remaining) + + val firstMatch = listOfNotNull(tagMatch, boldMatch, italicMatch, codeMatch) + .minByOrNull { it.range.first } + + if (firstMatch == null) { + withStyle(SpanStyle(color = textColor)) { append(remaining) } + break + } + + withStyle(SpanStyle(color = textColor)) { append(remaining.substring(0, firstMatch.range.first)) } + + when (firstMatch) { + tagMatch -> withStyle(SpanStyle(color = accent, fontSize = 13.sp)) { + append("#${firstMatch.groupValues[1]}") + } + boldMatch -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = textColor)) { + append(firstMatch.groupValues[1]) + } + italicMatch -> withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = textColor)) { + append(firstMatch.groupValues[1]) + } + codeMatch -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = textColor)) { + append(firstMatch.groupValues[1]) + } + } + + remaining = remaining.substring(firstMatch.range.last + 1) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt new file mode 100644 index 0000000..eeb1c4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt @@ -0,0 +1,237 @@ +package com.avinal.memos.ui.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.ui.theme.LocalAccentColor +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private const val COMPACT_MAX_LINES = 12 + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MemoCard( + memo: Memo, + onClick: () -> Unit, + modifier: Modifier = Modifier, + serverUrl: String = "", + onPin: (() -> Unit)? = null, + onArchive: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, + onSave: ((String, MemoVisibility) -> Unit)? = null, + onReact: ((String) -> Unit)? = null, +) { + val accent = LocalAccentColor.current + val textColor = MaterialTheme.colorScheme.onBackground + val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + val contentLines = remember(memo.content) { memo.content.lines() } + val isLong = contentLines.size > COMPACT_MAX_LINES + var expanded by remember { mutableStateOf(false) } + var isEditing by remember { mutableStateOf(false) } + var editContent by remember { mutableStateOf("") } + var editVisibility by remember { mutableStateOf(memo.visibility) } + var showMenu by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("delete memo?", color = textColor) }, + text = { Text("this cannot be undone.", color = subtleColor) }, + containerColor = MaterialTheme.colorScheme.surface, + confirmButton = { + TextButton(onClick = { showDeleteDialog = false; onDelete?.invoke() }) { + Text("delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("cancel", color = subtleColor) } + }, + ) + } + + if (showMenu) { + AlertDialog( + onDismissRequest = { showMenu = false }, + containerColor = MaterialTheme.colorScheme.surface, + title = null, + text = { + Column { + MetroMenuItem(if (memo.pinned) "unpin" else "pin", textColor) { showMenu = false; onPin?.invoke() } + MetroMenuItem("edit", textColor) { + showMenu = false; editContent = memo.content; editVisibility = memo.visibility; isEditing = true + } + MetroMenuItem("copy content", textColor) { showMenu = false; clipboardManager.setText(AnnotatedString(memo.content)) } + MetroMenuItem("archive", textColor) { showMenu = false; onArchive?.invoke() } + Spacer(Modifier.height(8.dp)) + MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true } + } + }, + confirmButton = {}, + ) + } + + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { if (!isEditing) onClick() }, + onLongClick = { if (!isEditing) showMenu = true }, + ) + .padding(start = 24.dp, end = 12.dp, top = 14.dp, bottom = 14.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (memo.pinned) { + Text("pinned", fontSize = 12.sp, color = accent, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(8.dp)) + } + Text(formatAbsoluteDate(memo.displayTime), fontSize = 12.sp, color = subtleColor) + } + + Spacer(Modifier.height(8.dp)) + + if (isEditing) { + InlineEditor( + content = editContent, visibility = editVisibility, accent = accent, + textColor = textColor, subtleColor = subtleColor, + onContentChange = { editContent = it }, onVisibilityChange = { editVisibility = it }, + onSave = { onSave?.invoke(editContent, editVisibility); isEditing = false }, + onCancel = { isEditing = false }, + ) + } else { + val displayContent = if (!expanded && isLong) { + contentLines.take(COMPACT_MAX_LINES).joinToString("\n") + } else { + memo.content + } + + Box(modifier = Modifier.fillMaxWidth().animateContentSize()) { + MarkdownText(markdown = displayContent, modifier = Modifier.fillMaxWidth()) + } + + if (isLong) { + Spacer(Modifier.height(4.dp)) + Text( + if (expanded) "show less" else "show more", + fontSize = 12.sp, color = accent, + modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 2.dp), + ) + } + + if (memo.attachments.any { it.isImage } && serverUrl.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + AttachmentGrid(attachments = memo.attachments, serverUrl = serverUrl) + } + + if (memo.reactions.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + ReactionBar(reactions = memo.reactions) + } + } + } + + Spacer( + Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)) + ) + } +} + +@Composable +private fun MetroMenuItem(text: String, color: Color, onClick: () -> Unit) { + Text( + text = text, fontSize = 17.sp, color = color, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 10.dp), + ) +} + +@Composable +private fun InlineEditor( + content: String, visibility: MemoVisibility, accent: Color, + textColor: Color, subtleColor: Color, + onContentChange: (String) -> Unit, onVisibilityChange: (MemoVisibility) -> Unit, + onSave: () -> Unit, onCancel: () -> Unit, +) { + var showVisibilityMenu by remember { mutableStateOf(false) } + + TextField( + value = content, onValueChange = onContentChange, + modifier = Modifier.fillMaxWidth().height(180.dp), + textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = accent, unfocusedIndicatorColor = subtleColor.copy(alpha = 0.3f), cursorColor = accent, + ), + ) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Box { + Text(visibility.name.lowercase(), fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable { showVisibilityMenu = true }) + if (showVisibilityMenu) { + AlertDialog( + onDismissRequest = { showVisibilityMenu = false }, containerColor = MaterialTheme.colorScheme.surface, title = null, + text = { + Column { + MemoVisibility.entries.forEach { vis -> + Text(vis.name.lowercase(), fontSize = 17.sp, color = if (vis == visibility) accent else textColor, + modifier = Modifier.fillMaxWidth().clickable { onVisibilityChange(vis); showVisibilityMenu = false }.padding(vertical = 10.dp)) + } + } + }, + confirmButton = {}, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("cancel", fontSize = 14.sp, color = subtleColor, modifier = Modifier.clickable(onClick = onCancel)) + Text("save", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (content.isNotBlank()) accent else subtleColor, + modifier = Modifier.then(if (content.isNotBlank()) Modifier.clickable(onClick = onSave) else Modifier)) + } + } +} + +private val monthNames = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") +private val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + +private fun formatAbsoluteDate(instant: Instant): String { + val local = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val dow = dayNames[local.dayOfWeek.ordinal] + val month = monthNames[local.month.ordinal] + return "$dow, $month ${local.day}" +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/ReactionBar.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/ReactionBar.kt new file mode 100644 index 0000000..98bff99 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/ReactionBar.kt @@ -0,0 +1,53 @@ +package com.avinal.memos.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.avinal.memos.domain.Reaction + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReactionBar( + reactions: List, + modifier: Modifier = Modifier, +) { + val grouped = reactions.groupBy { it.reactionType } + + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + grouped.forEach { (emoji, reactionList) -> + Row( + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), RoundedCornerShape(50)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = emoji, fontSize = 14.sp) + if (reactionList.size > 1) { + Text( + text = "${reactionList.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/RelativeTimestamp.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/RelativeTimestamp.kt new file mode 100644 index 0000000..fd695f5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/RelativeTimestamp.kt @@ -0,0 +1,43 @@ +package com.avinal.memos.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +@Composable +fun RelativeTimestamp(instant: Instant, modifier: Modifier = Modifier) { + Text( + text = instant.toRelativeString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} + +private val monthNames = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + +fun Instant.toRelativeString(): String { + val now = Clock.System.now() + val diffMs = now.toEpochMilliseconds() - this.toEpochMilliseconds() + val seconds = diffMs / 1000 + val minutes = diffMs / 60_000 + val hours = diffMs / 3_600_000 + val days = diffMs / 86_400_000 + + return when { + seconds < 60 -> "just now" + minutes < 60 -> "${minutes}m ago" + hours < 24 -> "${hours}h ago" + days == 1L -> "yesterday" + days < 7 -> "${days}d ago" + else -> { + val local = this.toLocalDateTime(TimeZone.currentSystemDefault()) + "${monthNames[local.month.ordinal]} ${local.day}" + } + } +}