1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add shared UI components: markdown, memo card, attachments, reactions

MarkdownText: custom Compose renderer supporting headings, bold (**/__),
italic (*/_), strikethrough (~~), inline code with accent background,
fenced code blocks with syntax highlighting (keywords/strings/comments/numbers),
task checkboxes, bullet lists, blockquotes, links, bare URLs, #tag pills.
All text colors respect theme (onBackground/onSurfaceVariant).

MemoCard: chrome-free content block with parallax-aware layout.
Long-press context menu (Metro AlertDialog style): pin, edit, copy, archive, delete.
Inline editing with accent-colored text field and visibility picker.
Compact mode with "show more/less" for long memos.

AttachmentGrid: full-width image rendering via Coil with FillWidth scaling.
ReactionBar: grouped emoji display with count badges.
RelativeTimestamp: "just now", "5m ago", "yesterday", "Jan 15" formatting.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-19 17:10:00 +05:30
parent 3b1d996574
commit 3b4e45484f
5 changed files with 793 additions and 0 deletions
@@ -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<Attachment>,
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}"
}
@@ -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<String>()
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<Span>()
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("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""")
private val italicUnderRegex = Regex("""(?<!_)_(?!_)(.+?)(?<!_)_(?!_)""")
private val strikethroughRegex = Regex("""~~(.+?)~~""")
private val codeRegex = Regex("""`(.+?)`""")
private val linkRegex = Regex("""\[(.+?)]\((.+?)\)""")
private val urlRegex = Regex("""https?://\S+""")
private val inlineTagRegex = Regex("""#(\w+)""")
private fun parseInlineFormatting(text: String, textColor: Color, accent: Color): AnnotatedString = buildAnnotatedString {
var remaining = text
while (remaining.isNotEmpty()) {
val matches = listOfNotNull(
boldItalicRegex.find(remaining),
boldItalicUnderRegex.find(remaining),
boldRegex.find(remaining),
boldUnderRegex.find(remaining),
strikethroughRegex.find(remaining),
italicRegex.find(remaining),
italicUnderRegex.find(remaining),
codeRegex.find(remaining),
linkRegex.find(remaining),
urlRegex.find(remaining),
)
val firstMatch = matches.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)) }
val matchedRegex = matches.first { it.range == firstMatch.range }
val inner = if (firstMatch.groupValues.size > 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)
}
}
@@ -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}"
}
@@ -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<Reaction>,
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,
)
}
}
}
}
}
@@ -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}"
}
}
}