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:
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user