1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +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?) {
super.onCreate(savedInstanceState)
deps.initialize()
com.avinal.memos.util.appContext = applicationContext
enableEdgeToEdge()
setContent {
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
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<String> = emptyList(),
): ApiResult<MemoDto> = 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<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 {
val response = httpClient.delete(url("/memos/$id"))
if (!response.status.isSuccess()) {
@@ -69,6 +69,12 @@ data class ListMemosResponse(
data class CreateMemoRequest(
val content: String,
val visibility: String = "PRIVATE",
val attachments: List<AttachmentRef> = 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,
)
@@ -86,8 +86,9 @@ class MemoRepository(
suspend fun createMemo(
content: String,
visibility: MemoVisibility = MemoVisibility.PRIVATE,
attachmentNames: List<String> = emptyList(),
): ApiResult<Memo> {
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<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> {
return when (apiClient.upsertReaction(memoId, emoji)) {
is ApiResult.Success -> {
@@ -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<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
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)
@@ -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) {
@@ -77,6 +77,10 @@ fun MainScreen(
val density = LocalDensity.current
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(
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)
}
@@ -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<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.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<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 {
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) },
)
}
}
@@ -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<String> = 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) }
}
@@ -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
}