1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-04 05:50:10 +05:30

Add media upload, clickable links, tables, comments, share

Media upload:
- Android file picker via ActivityResultContracts.GetContent
- Upload attachment API (base64 content to /api/v1/attachments)
- Uploaded attachments linked to memo via CreateMemoRequest.attachments
- Upload status shown in compose card ("uploading...", "N attachment(s) ready")

Markdown improvements:
- Clickable links: URLs and [text](url) open in browser via LinkAnnotation
- Table rendering: pipe-delimited markdown tables with header row
- Fixed underscore italic (_text_) and bold (__text__) variants

Comments:
- Comments section at bottom of MemoDetailScreen
- List existing comments via /api/v1/memos/{id}/comments
- Add new comments with text input + send button
- Comments rendered with full markdown

Share:
- "share" option in memo long-press context menu
- Android share intent with memo content as plain text

Also:
- Task toggle now works in memo feed (onTaskToggle callback wired)
- Tag clicks in explorer filter memos page
- Search from explorer filters memos page instead of navigating
- All three filter types (date/tag/search) shown as banner with "clear"

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 18:24:45 +05:30
parent 70910f7788
commit 8c15660fce
17 changed files with 430 additions and 39 deletions
@@ -19,6 +19,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
deps.initialize() deps.initialize()
com.avinal.memos.util.appContext = applicationContext
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
CompositionLocalProvider(LocalAppDependencies provides deps) { CompositionLocalProvider(LocalAppDependencies provides deps) {
@@ -0,0 +1,23 @@
package com.avinal.memos.util
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun rememberFilePicker(onFilePicked: (PickedFile) -> Unit): () -> Unit {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
val contentResolver = context.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val name = uri.lastPathSegment ?: "file"
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
onFilePicked(PickedFile(name = name, mimeType = mimeType, bytes = bytes))
}
return { launcher.launch("*/*") }
}
@@ -0,0 +1,17 @@
package com.avinal.memos.util
import android.content.Intent
actual fun sharePlainText(text: String, title: String) {
val context = appContext ?: return
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
if (title.isNotEmpty()) putExtra(Intent.EXTRA_SUBJECT, title)
}
val chooser = Intent.createChooser(intent, "Share memo")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}
var appContext: android.content.Context? = null
@@ -1,5 +1,8 @@
package com.avinal.memos.api package com.avinal.memos.api
import com.avinal.memos.api.model.AttachmentDto
import com.avinal.memos.api.model.AttachmentRef
import com.avinal.memos.api.model.CreateAttachmentRequest
import com.avinal.memos.api.model.CreateMemoRequest import com.avinal.memos.api.model.CreateMemoRequest
import com.avinal.memos.api.model.FieldMask import com.avinal.memos.api.model.FieldMask
import com.avinal.memos.api.model.ListMemosResponse import com.avinal.memos.api.model.ListMemosResponse
@@ -51,10 +54,15 @@ class MemosApiClient(
suspend fun createMemo( suspend fun createMemo(
content: String, content: String,
visibility: String = "PRIVATE", visibility: String = "PRIVATE",
attachmentNames: List<String> = emptyList(),
): ApiResult<MemoDto> = apiCall { ): ApiResult<MemoDto> = apiCall {
httpClient.post(url("/memos")) { httpClient.post(url("/memos")) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(CreateMemoRequest(content = content, visibility = visibility)) setBody(CreateMemoRequest(
content = content,
visibility = visibility,
attachments = attachmentNames.map { AttachmentRef(it) },
))
}.body() }.body()
} }
@@ -106,6 +114,32 @@ class MemosApiClient(
} }
} }
suspend fun uploadAttachment(
filename: String,
type: String,
contentBase64: String,
): ApiResult<AttachmentDto> = apiCall {
httpClient.post(url("/attachments")) {
contentType(ContentType.Application.Json)
setBody(CreateAttachmentRequest(
filename = filename,
type = type,
content = contentBase64,
))
}.body()
}
suspend fun listComments(memoId: String): ApiResult<ListMemosResponse> = apiCall {
httpClient.get(url("/memos/$memoId/comments")).body()
}
suspend fun createComment(memoId: String, content: String): ApiResult<MemoDto> = apiCall {
httpClient.post(url("/memos/$memoId/comments")) {
contentType(ContentType.Application.Json)
setBody(CreateMemoRequest(content = content))
}.body()
}
suspend fun deleteMemo(id: String): ApiResult<Unit> = apiCall { suspend fun deleteMemo(id: String): ApiResult<Unit> = apiCall {
val response = httpClient.delete(url("/memos/$id")) val response = httpClient.delete(url("/memos/$id"))
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
@@ -69,6 +69,12 @@ data class ListMemosResponse(
data class CreateMemoRequest( data class CreateMemoRequest(
val content: String, val content: String,
val visibility: String = "PRIVATE", val visibility: String = "PRIVATE",
val attachments: List<AttachmentRef> = emptyList(),
)
@Serializable
data class AttachmentRef(
val name: String,
) )
@Serializable @Serializable
@@ -94,3 +100,10 @@ data class FieldMask(
data class UpsertReactionRequest( data class UpsertReactionRequest(
val reaction: ReactionDto, val reaction: ReactionDto,
) )
@Serializable
data class CreateAttachmentRequest(
val filename: String,
val type: String,
val content: String,
)
@@ -86,8 +86,9 @@ class MemoRepository(
suspend fun createMemo( suspend fun createMemo(
content: String, content: String,
visibility: MemoVisibility = MemoVisibility.PRIVATE, visibility: MemoVisibility = MemoVisibility.PRIVATE,
attachmentNames: List<String> = emptyList(),
): ApiResult<Memo> { ): ApiResult<Memo> {
return when (val result = apiClient.createMemo(content, visibility.toApiString())) { return when (val result = apiClient.createMemo(content, visibility.toApiString(), attachmentNames)) {
is ApiResult.Success -> { is ApiResult.Success -> {
val memo = result.data.toDomain() val memo = result.data.toDomain()
memoDao.upsert(memo.toEntity(nowMillis())) memoDao.upsert(memo.toEntity(nowMillis()))
@@ -120,6 +121,14 @@ class MemoRepository(
} }
} }
suspend fun uploadAttachment(filename: String, mimeType: String, contentBase64: String): ApiResult<String> {
return when (val result = apiClient.uploadAttachment(filename, mimeType, contentBase64)) {
is ApiResult.Success -> ApiResult.Success(result.data.name)
is ApiResult.Error -> result
is ApiResult.NetworkError -> result
}
}
suspend fun reactToMemo(memoId: String, emoji: String): ApiResult<Unit> { suspend fun reactToMemo(memoId: String, emoji: String): ApiResult<Unit> {
return when (apiClient.upsertReaction(memoId, emoji)) { return when (apiClient.upsertReaction(memoId, emoji)) {
is ApiResult.Success -> { is ApiResult.Success -> {
@@ -24,13 +24,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -98,6 +102,14 @@ fun MarkdownText(
line.trim() == "---" || line.trim() == "***" || line.trim() == "___" -> { line.trim() == "---" || line.trim() == "***" || line.trim() == "___" -> {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = subtleColor.copy(alpha = 0.3f)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = subtleColor.copy(alpha = 0.3f))
} }
line.trimStart().startsWith("|") && line.trimStart().indexOf("|", 1) > 0 -> {
val tableLines = mutableListOf(line)
while (lineIndex + 1 < lines.size && lines[lineIndex + 1].trimStart().startsWith("|")) {
lineIndex++
tableLines.add(lines[lineIndex])
}
TableBlock(tableLines, textColor, accent)
}
line.isBlank() -> Spacer(Modifier.height(4.dp)) line.isBlank() -> Spacer(Modifier.height(4.dp))
else -> { else -> {
val tagRegex = Regex("""^#(\w+)(\s+.*)?$""") val tagRegex = Regex("""^#(\w+)(\s+.*)?$""")
@@ -236,6 +248,41 @@ private fun BlockquoteBlock(text: String, subtleColor: Color, accent: Color) {
} }
} }
@Composable
private fun TableBlock(tableLines: List<String>, textColor: Color, accent: Color) {
val rows = tableLines
.filter { !it.trim().matches(Regex("""^\|[-:|\\s]+\|$""")) }
.map { line ->
line.trim().removePrefix("|").removeSuffix("|").split("|").map { it.trim() }
}
if (rows.isEmpty()) return
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
) {
rows.forEachIndexed { rowIndex, cells ->
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
cells.forEach { cell ->
Text(
text = cell,
fontSize = 13.sp,
fontWeight = if (rowIndex == 0) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
)
}
}
if (rowIndex == 0) {
Spacer(Modifier.fillMaxWidth().height(1.dp).background(textColor.copy(alpha = 0.1f)))
}
}
}
}
@Composable @Composable
private fun CodeBlock(code: String, textColor: Color) { private fun CodeBlock(code: String, textColor: Color) {
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
@@ -357,7 +404,7 @@ private fun parseInlineFormatting(text: String, textColor: Color, accent: Color)
when { when {
matchedRegex.value.startsWith("http") -> matchedRegex.value.startsWith("http") ->
withStyle(SpanStyle(color = accent)) { append(firstMatch.value) } withLink(LinkAnnotation.Url(firstMatch.value, TextLinkStyles(SpanStyle(color = accent)))) { append(firstMatch.value) }
matchedRegex.value.startsWith("***") || matchedRegex.value.startsWith("___") -> matchedRegex.value.startsWith("***") || matchedRegex.value.startsWith("___") ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic, color = textColor)) { append(inner) } withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic, color = textColor)) { append(inner) }
matchedRegex.value.startsWith("**") || matchedRegex.value.startsWith("__") -> matchedRegex.value.startsWith("**") || matchedRegex.value.startsWith("__") ->
@@ -368,9 +415,15 @@ private fun parseInlineFormatting(text: String, textColor: Color, accent: Color)
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = textColor)) { append(inner) } withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = textColor)) { append(inner) }
matchedRegex.value.startsWith("`") -> matchedRegex.value.startsWith("`") ->
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, background = accent.copy(alpha = 0.1f), color = textColor)) { append(inner) } withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, background = accent.copy(alpha = 0.1f), color = textColor)) { append(inner) }
matchedRegex.value.startsWith("[") -> matchedRegex.value.startsWith("[") -> {
val url = if (firstMatch.groupValues.size > 2) firstMatch.groupValues[2] else ""
if (url.startsWith("http")) {
withLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = accent)))) { append(inner) }
} else {
withStyle(SpanStyle(color = accent)) { append(inner) } withStyle(SpanStyle(color = accent)) { append(inner) }
} }
}
}
remaining = remaining.substring(firstMatch.range.last + 1) remaining = remaining.substring(firstMatch.range.last + 1)
} }
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.sp
import com.avinal.memos.domain.Memo import com.avinal.memos.domain.Memo
import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import com.avinal.memos.util.sharePlainText
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -54,6 +55,7 @@ fun MemoCard(
onDelete: (() -> Unit)? = null, onDelete: (() -> Unit)? = null,
onSave: ((String, MemoVisibility) -> Unit)? = null, onSave: ((String, MemoVisibility) -> Unit)? = null,
onReact: ((String) -> Unit)? = null, onReact: ((String) -> Unit)? = null,
onTaskToggle: ((Int, Boolean) -> Unit)? = null,
) { ) {
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
@@ -97,6 +99,7 @@ fun MemoCard(
showMenu = false; editContent = memo.content; editVisibility = memo.visibility; isEditing = true showMenu = false; editContent = memo.content; editVisibility = memo.visibility; isEditing = true
} }
MetroMenuItem("copy content", textColor) { showMenu = false; clipboardManager.setText(AnnotatedString(memo.content)) } MetroMenuItem("copy content", textColor) { showMenu = false; clipboardManager.setText(AnnotatedString(memo.content)) }
MetroMenuItem("share", textColor) { showMenu = false; sharePlainText(memo.content) }
MetroMenuItem("archive", textColor) { showMenu = false; onArchive?.invoke() } MetroMenuItem("archive", textColor) { showMenu = false; onArchive?.invoke() }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true } MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true }
@@ -142,7 +145,11 @@ fun MemoCard(
} }
Box(modifier = Modifier.fillMaxWidth().animateContentSize()) { Box(modifier = Modifier.fillMaxWidth().animateContentSize()) {
MarkdownText(markdown = displayContent, modifier = Modifier.fillMaxWidth()) MarkdownText(
markdown = displayContent,
modifier = Modifier.fillMaxWidth(),
onTaskToggle = onTaskToggle,
)
} }
if (isLong) { if (isLong) {
@@ -77,6 +77,10 @@ fun MainScreen(
val density = LocalDensity.current val density = LocalDensity.current
var dateFilter by remember { mutableStateOf<String?>(null) } var dateFilter by remember { mutableStateOf<String?>(null) }
var tagFilter by remember { mutableStateOf<String?>(null) }
var searchFilter by remember { mutableStateOf<String?>(null) }
val navigateToMemosWithFilter: () -> Unit = { scope.launch { pagerState.animateScrollToPage(1) } }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -131,8 +135,16 @@ fun MainScreen(
deps = deps, deps = deps,
onMemoClick = onMemoClick, onMemoClick = onMemoClick,
onDateSelected = { date -> onDateSelected = { date ->
dateFilter = date dateFilter = date; tagFilter = null; searchFilter = null
scope.launch { pagerState.animateScrollToPage(1) } navigateToMemosWithFilter()
},
onTagSelected = { tag ->
tagFilter = tag; dateFilter = null; searchFilter = null
navigateToMemosWithFilter()
},
onSearchSubmit = { query ->
searchFilter = query; dateFilter = null; tagFilter = null
navigateToMemosWithFilter()
}, },
) )
1 -> MemoListScreen( 1 -> MemoListScreen(
@@ -140,7 +152,9 @@ fun MainScreen(
onMemoClick = onMemoClick, onMemoClick = onMemoClick,
onCreateMemo = onCreateMemo, onCreateMemo = onCreateMemo,
dateFilter = dateFilter, dateFilter = dateFilter,
onClearDateFilter = { dateFilter = null }, tagFilter = tagFilter,
searchFilter = searchFilter,
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null },
) )
2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick) 2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick)
3 -> SettingsScreen(deps = deps, onLogout = onLogout) 3 -> SettingsScreen(deps = deps, onLogout = onLogout)
@@ -155,6 +169,8 @@ private fun ExplorerPage(
deps: AppDependencies, deps: AppDependencies,
onMemoClick: (String) -> Unit, onMemoClick: (String) -> Unit,
onDateSelected: (String) -> Unit, onDateSelected: (String) -> Unit,
onTagSelected: (String) -> Unit,
onSearchSubmit: (String) -> Unit,
) { ) {
val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList()) val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList())
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
@@ -220,17 +236,16 @@ private fun ExplorerPage(
} }
if (showSearch && searchQuery.isNotBlank()) { if (showSearch && searchQuery.isNotBlank()) {
val results = memos.filter { it.content.contains(searchQuery, ignoreCase = true) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("${results.size} results", fontSize = 12.sp, color = subtleColor)
results.take(10).forEach { memo ->
val snippet = memo.content.lines().first().take(60)
Text( Text(
snippet, fontSize = 14.sp, color = textColor, maxLines = 1, "search for \"$searchQuery\"",
modifier = Modifier.fillMaxWidth().clickable { onMemoClick(memo.id) }.padding(vertical = 6.dp), fontSize = 14.sp, color = accent,
modifier = Modifier.clickable {
onSearchSubmit(searchQuery)
showSearch = false
}.padding(vertical = 6.dp),
) )
} }
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -331,7 +346,10 @@ private fun ExplorerPage(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
allTags.forEach { tag -> allTags.forEach { tag ->
val count = memos.count { it.tags.contains(tag) } val count = memos.count { it.tags.contains(tag) }
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { Row(
modifier = Modifier.fillMaxWidth().clickable { onTagSelected(tag) }.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("#$tag", fontSize = 14.sp, color = accent) Text("#$tag", fontSize = 14.sp, color = accent)
Text("$count", fontSize = 12.sp, color = subtleColor) Text("$count", fontSize = 12.sp, color = subtleColor)
} }
@@ -2,13 +2,18 @@ package com.avinal.memos.ui.memos
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -16,7 +21,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@@ -28,9 +40,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.avinal.memos.AppDependencies import com.avinal.memos.AppDependencies
import com.avinal.memos.api.ApiResult
import com.avinal.memos.domain.Memo
import com.avinal.memos.api.model.toDomain
import com.avinal.memos.ui.components.AttachmentGrid import com.avinal.memos.ui.components.AttachmentGrid
import com.avinal.memos.ui.components.MarkdownText import com.avinal.memos.ui.components.MarkdownText
import com.avinal.memos.ui.components.ReactionBar import com.avinal.memos.ui.components.ReactionBar
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import kotlinx.coroutines.launch
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -108,9 +126,111 @@ fun MemoDetailScreen(
ReactionBar(reactions = memo!!.reactions) ReactionBar(reactions = memo!!.reactions)
} }
Spacer(Modifier.height(20.dp))
CommentsSection(memoId = memoId, deps = deps, accent = accent)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} }
} }
} }
} }
@Composable
private fun CommentsSection(
memoId: String,
deps: AppDependencies,
accent: Color,
) {
val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var comments by remember { mutableStateOf<List<Memo>>(emptyList()) }
var commentText by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
LaunchedEffect(memoId) {
isLoading = true
when (val result = deps.apiClient.listComments(memoId)) {
is ApiResult.Success -> comments = result.data.memos.map { it.toDomain() }
else -> {}
}
isLoading = false
}
Column {
Text("comments", fontSize = 19.sp, fontWeight = FontWeight.Light, color = textColor)
Spacer(Modifier.height(8.dp))
if (isLoading) {
Text("loading...", fontSize = 13.sp, color = subtleColor)
} else if (comments.isEmpty()) {
Text("no comments yet", fontSize = 13.sp, color = subtleColor)
} else {
comments.forEach { comment ->
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Text(comment.creator, fontSize = 12.sp, color = subtleColor)
Spacer(Modifier.height(2.dp))
MarkdownText(markdown = comment.content)
}
}
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
TextField(
value = commentText,
onValueChange = { commentText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("add a comment...", fontSize = 14.sp, color = subtleColor.copy(alpha = 0.4f)) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (commentText.isNotBlank()) {
val text = commentText
commentText = ""
scope.launch {
when (val result = deps.apiClient.createComment(memoId, text)) {
is ApiResult.Success -> {
comments = comments + result.data.toDomain()
}
else -> {}
}
}
}
}),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = accent,
unfocusedIndicatorColor = subtleColor.copy(alpha = 0.2f),
cursorColor = accent,
),
)
Text(
"send",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (commentText.isNotBlank()) accent else subtleColor.copy(alpha = 0.3f),
modifier = Modifier
.then(if (commentText.isNotBlank()) Modifier.clickable {
val text = commentText
commentText = ""
scope.launch {
when (val result = deps.apiClient.createComment(memoId, text)) {
is ApiResult.Success -> { comments = comments + result.data.toDomain() }
else -> {}
}
}
} else Modifier)
.padding(start = 8.dp),
)
}
}
}
@@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -42,25 +43,39 @@ import com.avinal.memos.AppDependencies
import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.components.MemoCard import com.avinal.memos.ui.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import com.avinal.memos.util.rememberFilePicker
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun MemoListScreen( fun MemoListScreen(
deps: AppDependencies, deps: AppDependencies,
onMemoClick: (String) -> Unit, onMemoClick: (String) -> Unit,
onCreateMemo: () -> Unit, onCreateMemo: () -> Unit,
dateFilter: String? = null, dateFilter: String? = null,
onClearDateFilter: (() -> Unit)? = null, tagFilter: String? = null,
searchFilter: String? = null,
onClearFilter: (() -> Unit)? = null,
) { ) {
val viewModel = viewModel { MemoListViewModel(deps.memoRepository) } val viewModel = viewModel { MemoListViewModel(deps.memoRepository) }
val allMemos by viewModel.memos.collectAsState() val allMemos by viewModel.memos.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val memos = remember(allMemos, dateFilter) { val hasFilter = dateFilter != null || tagFilter != null || searchFilter != null
if (dateFilter == null) allMemos val filterLabel = when {
else { dateFilter != null -> "date: $dateFilter"
tagFilter != null -> "tag: #$tagFilter"
searchFilter != null -> "search: $searchFilter"
else -> ""
}
val memos = remember(allMemos, dateFilter, tagFilter, searchFilter) {
when {
dateFilter != null -> {
val parts = dateFilter.split("-") val parts = dateFilter.split("-")
if (parts.size == 3) { if (parts.size == 3) {
val (year, month, day) = parts.map { it.toIntOrNull() ?: 0 } val (year, month, day) = parts.map { it.toIntOrNull() ?: 0 }
@@ -70,6 +85,13 @@ fun MemoListScreen(
} }
} else allMemos } else allMemos
} }
tagFilter != null -> allMemos.filter { it.tags.contains(tagFilter) }
searchFilter != null -> {
val q = searchFilter.lowercase()
allMemos.filter { it.content.lowercase().contains(q) || it.tags.any { t -> t.lowercase().contains(q) } }
}
else -> allMemos
}
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val serverUrl by produceState("") { value = deps.tokenStore.serverUrl.first() ?: "" } val serverUrl by produceState("") { value = deps.tokenStore.serverUrl.first() ?: "" }
@@ -80,6 +102,23 @@ fun MemoListScreen(
var composeText by remember { mutableStateOf("") } var composeText by remember { mutableStateOf("") }
var composeVisibility by remember { mutableStateOf(MemoVisibility.PRIVATE) } var composeVisibility by remember { mutableStateOf(MemoVisibility.PRIVATE) }
var showVisibilityPicker by remember { mutableStateOf(false) } var showVisibilityPicker by remember { mutableStateOf(false) }
var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) }
var isUploading by remember { mutableStateOf(false) }
val uploadScope = rememberCoroutineScope()
val launchFilePicker = rememberFilePicker { pickedFile ->
isUploading = true
uploadScope.launch {
val base64 = Base64.encode(pickedFile.bytes)
when (val result = deps.memoRepository.uploadAttachment(pickedFile.name, pickedFile.mimeType, base64)) {
is com.avinal.memos.api.ApiResult.Success -> {
uploadedAttachmentNames = uploadedAttachmentNames + result.data
}
else -> {}
}
isUploading = false
}
}
val reachedEnd by remember { val reachedEnd by remember {
derivedStateOf { derivedStateOf {
@@ -117,18 +156,18 @@ fun MemoListScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (dateFilter != null) { if (hasFilter) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("memos from $dateFilter", fontSize = 13.sp, color = accent) Text(filterLabel, fontSize = 13.sp, color = accent)
Text( Text(
"clear", "clear",
fontSize = 13.sp, fontSize = 13.sp,
color = subtleColor, color = subtleColor,
modifier = Modifier.clickable { onClearDateFilter?.invoke() }.padding(4.dp), modifier = Modifier.clickable { onClearFilter?.invoke() }.padding(4.dp),
) )
} }
Spacer(Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp).background(subtleColor.copy(alpha = 0.15f))) Spacer(Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp).background(subtleColor.copy(alpha = 0.15f)))
@@ -162,7 +201,7 @@ fun MemoListScreen(
when (item) { when (item) {
"code block" -> composeText += "\n```\n\n```" "code block" -> composeText += "\n```\n\n```"
"link memo" -> composeText += "\n[memo]()" "link memo" -> composeText += "\n[memo]()"
else -> { /* TODO: file picker */ } "media", "file" -> launchFilePicker()
} }
} }
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
@@ -195,6 +234,17 @@ fun MemoListScreen(
), ),
) )
if (uploadedAttachmentNames.isNotEmpty()) {
Text(
"${uploadedAttachmentNames.size} attachment(s) ready",
fontSize = 12.sp, color = accent,
modifier = Modifier.padding(top = 4.dp),
)
}
if (isUploading) {
Text("uploading...", fontSize = 12.sp, color = subtleColor, modifier = Modifier.padding(top = 4.dp))
}
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row( Row(
@@ -226,8 +276,9 @@ fun MemoListScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (composeText.isNotBlank()) Modifier.clickable { if (composeText.isNotBlank()) Modifier.clickable {
viewModel.createMemo(composeText, composeVisibility) viewModel.createMemo(composeText, composeVisibility, uploadedAttachmentNames)
composeText = "" composeText = ""
uploadedAttachmentNames = emptyList()
} else Modifier } else Modifier
) )
.padding(horizontal = 4.dp, vertical = 4.dp), .padding(horizontal = 4.dp, vertical = 4.dp),
@@ -262,6 +313,7 @@ fun MemoListScreen(
viewModel.updateMemo(memo.id, content, visibility) viewModel.updateMemo(memo.id, content, visibility)
}, },
onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) }, onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) },
onTaskToggle = { lineIndex, checked -> viewModel.toggleTask(memo.id, lineIndex, checked) },
) )
} }
} }
@@ -83,8 +83,8 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel(
} }
} }
fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility) { fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility, attachmentNames: List<String> = emptyList()) {
viewModelScope.launch { memoRepository.createMemo(content, visibility) } viewModelScope.launch { memoRepository.createMemo(content, visibility, attachmentNames) }
} }
fun deleteMemo(id: String) { fun deleteMemo(id: String) {
@@ -103,6 +103,21 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel(
viewModelScope.launch { memoRepository.updateMemo(id, content = content, visibility = visibility) } viewModelScope.launch { memoRepository.updateMemo(id, content = content, visibility = visibility) }
} }
fun toggleTask(memoId: String, lineIndex: Int, checked: Boolean) {
viewModelScope.launch {
val memo = memoRepository.getMemo(memoId) ?: return@launch
val lines = memo.content.lines().toMutableList()
if (lineIndex !in lines.indices) return@launch
val line = lines[lineIndex]
lines[lineIndex] = if (checked) {
line.replaceFirst("- [ ]", "- [x]")
} else {
line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]")
}
memoRepository.updateMemo(memoId, content = lines.joinToString("\n"))
}
}
fun reactToMemo(memoId: String, emoji: String) { fun reactToMemo(memoId: String, emoji: String) {
viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) } viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) }
} }
@@ -0,0 +1,7 @@
package com.avinal.memos.util
data class PickedFile(
val name: String,
val mimeType: String,
val bytes: ByteArray,
)
@@ -0,0 +1,6 @@
package com.avinal.memos.util
import androidx.compose.runtime.Composable
@Composable
expect fun rememberFilePicker(onFilePicked: (PickedFile) -> Unit): () -> Unit
@@ -0,0 +1,3 @@
package com.avinal.memos.util
expect fun sharePlainText(text: String, title: String = "")
@@ -0,0 +1,8 @@
package com.avinal.memos.util
import androidx.compose.runtime.Composable
@Composable
actual fun rememberFilePicker(onFilePicked: (PickedFile) -> Unit): () -> Unit {
return { /* TODO: iOS file picker */ }
}
@@ -0,0 +1,5 @@
package com.avinal.memos.util
actual fun sharePlainText(text: String, title: String) {
// TODO: iOS share sheet
}