From 8c15660fcee78d060c83d698ace6bc937d8c3bdb Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Tue, 19 May 2026 18:24:45 +0530 Subject: [PATCH] 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 --- .../kotlin/com/avinal/memos/MainActivity.kt | 1 + .../memos/util/PlatformFilePicker.android.kt | 23 ++++ .../memos/util/PlatformShare.android.kt | 17 +++ .../com/avinal/memos/api/MemosApiClient.kt | 36 +++++- .../com/avinal/memos/api/model/MemoDto.kt | 13 ++ .../com/avinal/memos/domain/MemoRepository.kt | 11 +- .../memos/ui/components/MarkdownText.kt | 59 ++++++++- .../avinal/memos/ui/components/MemoCard.kt | 9 +- .../com/avinal/memos/ui/memos/MainScreen.kt | 44 +++++-- .../avinal/memos/ui/memos/MemoDetailScreen.kt | 120 ++++++++++++++++++ .../avinal/memos/ui/memos/MemoListScreen.kt | 88 ++++++++++--- .../memos/ui/memos/MemoListViewModel.kt | 19 ++- .../com/avinal/memos/util/FilePicker.kt | 7 + .../avinal/memos/util/PlatformFilePicker.kt | 6 + .../com/avinal/memos/util/PlatformShare.kt | 3 + .../memos/util/PlatformFilePicker.ios.kt | 8 ++ .../avinal/memos/util/PlatformShare.ios.kt | 5 + 17 files changed, 430 insertions(+), 39 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformFilePicker.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformShare.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/util/FilePicker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformFilePicker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformShare.kt create mode 100644 composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformFilePicker.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformShare.ios.kt diff --git a/androidApp/src/main/kotlin/com/avinal/memos/MainActivity.kt b/androidApp/src/main/kotlin/com/avinal/memos/MainActivity.kt index 73310bc..eab8950 100644 --- a/androidApp/src/main/kotlin/com/avinal/memos/MainActivity.kt +++ b/androidApp/src/main/kotlin/com/avinal/memos/MainActivity.kt @@ -19,6 +19,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) deps.initialize() + com.avinal.memos.util.appContext = applicationContext enableEdgeToEdge() setContent { CompositionLocalProvider(LocalAppDependencies provides deps) { diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformFilePicker.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformFilePicker.android.kt new file mode 100644 index 0000000..801d6e3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformFilePicker.android.kt @@ -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("*/*") } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformShare.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformShare.android.kt new file mode 100644 index 0000000..59d204c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformShare.android.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt index 3033d96..bd66666 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/MemosApiClient.kt @@ -1,5 +1,8 @@ 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.FieldMask import com.avinal.memos.api.model.ListMemosResponse @@ -51,10 +54,15 @@ class MemosApiClient( suspend fun createMemo( content: String, visibility: String = "PRIVATE", + attachmentNames: List = emptyList(), ): ApiResult = apiCall { httpClient.post(url("/memos")) { contentType(ContentType.Application.Json) - setBody(CreateMemoRequest(content = content, visibility = visibility)) + setBody(CreateMemoRequest( + content = content, + visibility = visibility, + attachments = attachmentNames.map { AttachmentRef(it) }, + )) }.body() } @@ -106,6 +114,32 @@ class MemosApiClient( } } + suspend fun uploadAttachment( + filename: String, + type: String, + contentBase64: String, + ): ApiResult = apiCall { + httpClient.post(url("/attachments")) { + contentType(ContentType.Application.Json) + setBody(CreateAttachmentRequest( + filename = filename, + type = type, + content = contentBase64, + )) + }.body() + } + + suspend fun listComments(memoId: String): ApiResult = apiCall { + httpClient.get(url("/memos/$memoId/comments")).body() + } + + suspend fun createComment(memoId: String, content: String): ApiResult = apiCall { + httpClient.post(url("/memos/$memoId/comments")) { + contentType(ContentType.Application.Json) + setBody(CreateMemoRequest(content = content)) + }.body() + } + suspend fun deleteMemo(id: String): ApiResult = apiCall { val response = httpClient.delete(url("/memos/$id")) if (!response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt index d29cf37..9ea0d7d 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/api/model/MemoDto.kt @@ -69,6 +69,12 @@ data class ListMemosResponse( data class CreateMemoRequest( val content: String, val visibility: String = "PRIVATE", + val attachments: List = emptyList(), +) + +@Serializable +data class AttachmentRef( + val name: String, ) @Serializable @@ -94,3 +100,10 @@ data class FieldMask( data class UpsertReactionRequest( val reaction: ReactionDto, ) + +@Serializable +data class CreateAttachmentRequest( + val filename: String, + val type: String, + val content: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt index 5400a2b..86859c1 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt @@ -86,8 +86,9 @@ class MemoRepository( suspend fun createMemo( content: String, visibility: MemoVisibility = MemoVisibility.PRIVATE, + attachmentNames: List = emptyList(), ): ApiResult { - return when (val result = apiClient.createMemo(content, visibility.toApiString())) { + return when (val result = apiClient.createMemo(content, visibility.toApiString(), attachmentNames)) { is ApiResult.Success -> { val memo = result.data.toDomain() memoDao.upsert(memo.toEntity(nowMillis())) @@ -120,6 +121,14 @@ class MemoRepository( } } + suspend fun uploadAttachment(filename: String, mimeType: String, contentBase64: String): ApiResult { + 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 { return when (apiClient.upsertReaction(memoId, emoji)) { is ApiResult.Success -> { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt index 050f56b..0690fd7 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MarkdownText.kt @@ -24,13 +24,17 @@ 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.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles 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.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -98,6 +102,14 @@ fun MarkdownText( line.trim() == "---" || line.trim() == "***" || line.trim() == "___" -> { 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)) else -> { val tagRegex = Regex("""^#(\w+)(\s+.*)?$""") @@ -236,6 +248,41 @@ private fun BlockquoteBlock(text: String, subtleColor: Color, accent: Color) { } } +@Composable +private fun TableBlock(tableLines: List, 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 private fun CodeBlock(code: String, textColor: Color) { val accent = LocalAccentColor.current @@ -357,7 +404,7 @@ private fun parseInlineFormatting(text: String, textColor: Color, accent: Color) when { 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("___") -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic, color = textColor)) { append(inner) } matchedRegex.value.startsWith("**") || matchedRegex.value.startsWith("__") -> @@ -368,8 +415,14 @@ private fun parseInlineFormatting(text: String, textColor: Color, accent: Color) 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) } + 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) } + } + } } remaining = remaining.substring(firstMatch.range.last + 1) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt index eeb1c4b..6097487 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt @@ -36,6 +36,7 @@ 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 com.avinal.memos.util.sharePlainText import kotlin.time.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -54,6 +55,7 @@ fun MemoCard( onDelete: (() -> Unit)? = null, onSave: ((String, MemoVisibility) -> Unit)? = null, onReact: ((String) -> Unit)? = null, + onTaskToggle: ((Int, Boolean) -> Unit)? = null, ) { val accent = LocalAccentColor.current val textColor = MaterialTheme.colorScheme.onBackground @@ -97,6 +99,7 @@ fun MemoCard( showMenu = false; editContent = memo.content; editVisibility = memo.visibility; isEditing = true } 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() } Spacer(Modifier.height(8.dp)) MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true } @@ -142,7 +145,11 @@ fun MemoCard( } Box(modifier = Modifier.fillMaxWidth().animateContentSize()) { - MarkdownText(markdown = displayContent, modifier = Modifier.fillMaxWidth()) + MarkdownText( + markdown = displayContent, + modifier = Modifier.fillMaxWidth(), + onTaskToggle = onTaskToggle, + ) } if (isLong) { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt index 6b3e43d..d182222 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt @@ -77,6 +77,10 @@ fun MainScreen( val density = LocalDensity.current var dateFilter by remember { mutableStateOf(null) } + var tagFilter by remember { mutableStateOf(null) } + var searchFilter by remember { mutableStateOf(null) } + + val navigateToMemosWithFilter: () -> Unit = { scope.launch { pagerState.animateScrollToPage(1) } } Column( modifier = Modifier @@ -131,8 +135,16 @@ fun MainScreen( deps = deps, onMemoClick = onMemoClick, onDateSelected = { date -> - dateFilter = date - scope.launch { pagerState.animateScrollToPage(1) } + dateFilter = date; tagFilter = null; searchFilter = null + navigateToMemosWithFilter() + }, + onTagSelected = { tag -> + tagFilter = tag; dateFilter = null; searchFilter = null + navigateToMemosWithFilter() + }, + onSearchSubmit = { query -> + searchFilter = query; dateFilter = null; tagFilter = null + navigateToMemosWithFilter() }, ) 1 -> MemoListScreen( @@ -140,7 +152,9 @@ fun MainScreen( onMemoClick = onMemoClick, onCreateMemo = onCreateMemo, dateFilter = dateFilter, - onClearDateFilter = { dateFilter = null }, + tagFilter = tagFilter, + searchFilter = searchFilter, + onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null }, ) 2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick) 3 -> SettingsScreen(deps = deps, onLogout = onLogout) @@ -155,6 +169,8 @@ private fun ExplorerPage( deps: AppDependencies, onMemoClick: (String) -> Unit, onDateSelected: (String) -> Unit, + onTagSelected: (String) -> Unit, + onSearchSubmit: (String) -> Unit, ) { val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList()) val accent = LocalAccentColor.current @@ -220,16 +236,15 @@ private fun ExplorerPage( } if (showSearch && searchQuery.isNotBlank()) { - val results = memos.filter { it.content.contains(searchQuery, ignoreCase = true) } 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( - snippet, fontSize = 14.sp, color = textColor, maxLines = 1, - modifier = Modifier.fillMaxWidth().clickable { onMemoClick(memo.id) }.padding(vertical = 6.dp), - ) - } + Text( + "search for \"$searchQuery\"", + fontSize = 14.sp, color = accent, + modifier = Modifier.clickable { + onSearchSubmit(searchQuery) + showSearch = false + }.padding(vertical = 6.dp), + ) } Spacer(Modifier.height(16.dp)) @@ -331,7 +346,10 @@ private fun ExplorerPage( Spacer(Modifier.height(8.dp)) allTags.forEach { 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("$count", fontSize = 12.sp, color = subtleColor) } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt index c3bd258..38ecf04 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt @@ -2,13 +2,18 @@ package com.avinal.memos.ui.memos import androidx.compose.foundation.background import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.verticalScroll import androidx.compose.material3.CircularProgressIndicator @@ -16,7 +21,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults 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.getValue import androidx.compose.runtime.produceState @@ -28,9 +40,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel 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.MarkdownText 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 kotlinx.coroutines.flow.first @@ -108,9 +126,111 @@ fun MemoDetailScreen( ReactionBar(reactions = memo!!.reactions) } + Spacer(Modifier.height(20.dp)) + CommentsSection(memoId = memoId, deps = deps, accent = accent) + 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>(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), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt index 4fdb2c6..c03ae0f 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,33 +43,54 @@ import com.avinal.memos.AppDependencies import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.ui.components.MemoCard import com.avinal.memos.ui.theme.LocalAccentColor +import com.avinal.memos.util.rememberFilePicker import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.datetime.toLocalDateTime -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable fun MemoListScreen( deps: AppDependencies, onMemoClick: (String) -> Unit, onCreateMemo: () -> Unit, dateFilter: String? = null, - onClearDateFilter: (() -> Unit)? = null, + tagFilter: String? = null, + searchFilter: String? = null, + onClearFilter: (() -> Unit)? = null, ) { val viewModel = viewModel { MemoListViewModel(deps.memoRepository) } val allMemos by viewModel.memos.collectAsState() val uiState by viewModel.uiState.collectAsState() - val memos = remember(allMemos, dateFilter) { - if (dateFilter == null) allMemos - else { - val parts = dateFilter.split("-") - if (parts.size == 3) { - val (year, month, day) = parts.map { it.toIntOrNull() ?: 0 } - allMemos.filter { memo -> - val local = memo.displayTime.toLocalDateTime(kotlinx.datetime.TimeZone.currentSystemDefault()) - local.year == year && (local.month.ordinal + 1) == month && local.day == day - } - } else allMemos + val hasFilter = dateFilter != null || tagFilter != null || searchFilter != null + val filterLabel = when { + 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("-") + if (parts.size == 3) { + val (year, month, day) = parts.map { it.toIntOrNull() ?: 0 } + allMemos.filter { memo -> + val local = memo.displayTime.toLocalDateTime(kotlinx.datetime.TimeZone.currentSystemDefault()) + local.year == year && (local.month.ordinal + 1) == month && local.day == day + } + } 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() @@ -80,6 +102,23 @@ fun MemoListScreen( var composeText by remember { mutableStateOf("") } var composeVisibility by remember { mutableStateOf(MemoVisibility.PRIVATE) } var showVisibilityPicker by remember { mutableStateOf(false) } + var uploadedAttachmentNames by remember { mutableStateOf>(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 { derivedStateOf { @@ -117,18 +156,18 @@ fun MemoListScreen( } Column(modifier = Modifier.fillMaxSize()) { - if (dateFilter != null) { + if (hasFilter) { Row( modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("memos from $dateFilter", fontSize = 13.sp, color = accent) + Text(filterLabel, fontSize = 13.sp, color = accent) Text( "clear", fontSize = 13.sp, 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))) @@ -162,7 +201,7 @@ fun MemoListScreen( when (item) { "code block" -> composeText += "\n```\n\n```" "link memo" -> composeText += "\n[memo]()" - else -> { /* TODO: file picker */ } + "media", "file" -> launchFilePicker() } } .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)) Row( @@ -226,8 +276,9 @@ fun MemoListScreen( modifier = Modifier .then( if (composeText.isNotBlank()) Modifier.clickable { - viewModel.createMemo(composeText, composeVisibility) + viewModel.createMemo(composeText, composeVisibility, uploadedAttachmentNames) composeText = "" + uploadedAttachmentNames = emptyList() } else Modifier ) .padding(horizontal = 4.dp, vertical = 4.dp), @@ -262,6 +313,7 @@ fun MemoListScreen( viewModel.updateMemo(memo.id, content, visibility) }, onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) }, + onTaskToggle = { lineIndex, checked -> viewModel.toggleTask(memo.id, lineIndex, checked) }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt index 50dc742..1f33a75 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt @@ -83,8 +83,8 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel( } } - fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility) { - viewModelScope.launch { memoRepository.createMemo(content, visibility) } + fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility, attachmentNames: List = emptyList()) { + viewModelScope.launch { memoRepository.createMemo(content, visibility, attachmentNames) } } fun deleteMemo(id: String) { @@ -103,6 +103,21 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel( 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) { viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/FilePicker.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/FilePicker.kt new file mode 100644 index 0000000..04edfdd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/FilePicker.kt @@ -0,0 +1,7 @@ +package com.avinal.memos.util + +data class PickedFile( + val name: String, + val mimeType: String, + val bytes: ByteArray, +) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformFilePicker.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformFilePicker.kt new file mode 100644 index 0000000..5c4de55 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformFilePicker.kt @@ -0,0 +1,6 @@ +package com.avinal.memos.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberFilePicker(onFilePicked: (PickedFile) -> Unit): () -> Unit diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformShare.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformShare.kt new file mode 100644 index 0000000..4a3798d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformShare.kt @@ -0,0 +1,3 @@ +package com.avinal.memos.util + +expect fun sharePlainText(text: String, title: String = "") diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformFilePicker.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformFilePicker.ios.kt new file mode 100644 index 0000000..a6a5b50 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformFilePicker.ios.kt @@ -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 */ } +} diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformShare.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformShare.ios.kt new file mode 100644 index 0000000..e79dd4a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformShare.ios.kt @@ -0,0 +1,5 @@ +package com.avinal.memos.util + +actual fun sharePlainText(text: String, title: String) { + // TODO: iOS share sheet +}