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