1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add archived view, comment delete, accessibility, entity tests

Archived memos:
- "view archived memos" in explorer navigates to memo list with
  archived filter active
- Fetches archived memos from API (state == ARCHIVED)
- MemoCard shows "restore" instead of "archive" in context menu
- Restore calls updateMemo(state=NORMAL), upserts to local cache
- Compose card hidden when viewing archived
- Filter banner shows "archived memos" with clear option

Comment deletion:
- Each comment shows "delete" link next to creator/date
- Calls deleteMemo on the comment (child memo) and removes locally

Accessibility:
- Task group collapse icons now have contentDescription
  ("Expand"/"Collapse")

Test suite: 107 tests (5 new entity mapper tests)
- MemoEntityMappersTest: round-trip field preservation, empty tags,
  cachedAt, all visibility variants, timestamp fidelity

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-21 19:31:23 +05:30
parent cc379c1828
commit 9bebc628bd
8 changed files with 202 additions and 35 deletions
@@ -167,6 +167,18 @@ class MemoRepository(
} }
} }
suspend fun restoreMemo(id: String): ApiResult<Memo> {
return when (val result = apiClient.updateMemo(id = id, state = "NORMAL")) {
is ApiResult.Success -> {
val memo = result.data.toDomain()
memoDao.upsert(memo.toEntity(nowMillis()))
ApiResult.Success(memo)
}
is ApiResult.Error -> result
is ApiResult.NetworkError -> result
}
}
suspend fun deleteMemo(id: String): ApiResult<Unit> { suspend fun deleteMemo(id: String): ApiResult<Unit> {
return when (val result = apiClient.deleteMemo(id)) { return when (val result = apiClient.deleteMemo(id)) {
is ApiResult.Success -> { is ApiResult.Success -> {
@@ -56,6 +56,7 @@ fun MemoCard(
onSave: ((String, MemoVisibility) -> Unit)? = null, onSave: ((String, MemoVisibility) -> Unit)? = null,
onReact: ((String) -> Unit)? = null, onReact: ((String) -> Unit)? = null,
onTaskToggle: ((Int, Boolean) -> Unit)? = null, onTaskToggle: ((Int, Boolean) -> Unit)? = null,
onRestore: (() -> Unit)? = null,
) { ) {
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
@@ -94,15 +95,23 @@ fun MemoCard(
title = null, title = null,
text = { text = {
Column { Column {
MetroMenuItem(if (memo.pinned) "unpin" else "pin", textColor) { showMenu = false; onPin?.invoke() } if (onRestore != null) {
MetroMenuItem("edit", textColor) { MetroMenuItem("restore", textColor) { showMenu = false; onRestore.invoke() }
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) }
Spacer(Modifier.height(8.dp))
MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true }
} else {
MetroMenuItem(if (memo.pinned) "unpin" else "pin", textColor) { showMenu = false; onPin?.invoke() }
MetroMenuItem("edit", textColor) {
showMenu = false; editContent = memo.content; editVisibility = memo.visibility; isEditing = true
}
MetroMenuItem("copy content", textColor) { showMenu = false; clipboardManager.setText(AnnotatedString(memo.content)) }
MetroMenuItem("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 }
} }
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 }
} }
}, },
confirmButton = {}, confirmButton = {},
@@ -79,6 +79,7 @@ fun MainScreen(
var dateFilter by remember { mutableStateOf<String?>(null) } var dateFilter by remember { mutableStateOf<String?>(null) }
var tagFilter by remember { mutableStateOf<String?>(null) } var tagFilter by remember { mutableStateOf<String?>(null) }
var searchFilter by remember { mutableStateOf<String?>(null) } var searchFilter by remember { mutableStateOf<String?>(null) }
var showArchived by remember { mutableStateOf(false) }
val navigateToMemosWithFilter: () -> Unit = { scope.launch { pagerState.animateScrollToPage(1) } } val navigateToMemosWithFilter: () -> Unit = { scope.launch { pagerState.animateScrollToPage(1) } }
@@ -135,15 +136,19 @@ fun MainScreen(
deps = deps, deps = deps,
onMemoClick = onMemoClick, onMemoClick = onMemoClick,
onDateSelected = { date -> onDateSelected = { date ->
dateFilter = date; tagFilter = null; searchFilter = null dateFilter = date; tagFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter() navigateToMemosWithFilter()
}, },
onTagSelected = { tag -> onTagSelected = { tag ->
tagFilter = tag; dateFilter = null; searchFilter = null tagFilter = tag; dateFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter() navigateToMemosWithFilter()
}, },
onSearchSubmit = { query -> onSearchSubmit = { query ->
searchFilter = query; dateFilter = null; tagFilter = null searchFilter = query; dateFilter = null; tagFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onShowArchived = {
showArchived = true; dateFilter = null; tagFilter = null; searchFilter = null
navigateToMemosWithFilter() navigateToMemosWithFilter()
}, },
) )
@@ -154,7 +159,8 @@ fun MainScreen(
dateFilter = dateFilter, dateFilter = dateFilter,
tagFilter = tagFilter, tagFilter = tagFilter,
searchFilter = searchFilter, searchFilter = searchFilter,
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null }, showArchived = showArchived,
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null; showArchived = false },
) )
2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick) 2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick)
3 -> SettingsScreen(deps = deps, onLogout = onLogout) 3 -> SettingsScreen(deps = deps, onLogout = onLogout)
@@ -171,6 +177,7 @@ private fun ExplorerPage(
onDateSelected: (String) -> Unit, onDateSelected: (String) -> Unit,
onTagSelected: (String) -> Unit, onTagSelected: (String) -> Unit,
onSearchSubmit: (String) -> Unit, onSearchSubmit: (String) -> Unit,
onShowArchived: () -> Unit,
) { ) {
val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList()) val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList())
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
@@ -252,7 +259,7 @@ private fun ExplorerPage(
"view archived memos", "view archived memos",
fontSize = 14.sp, fontSize = 14.sp,
color = accent, color = accent,
modifier = Modifier.clickable { /* navigate to archived */ }.padding(vertical = 4.dp), modifier = Modifier.clickable { onShowArchived() }.padding(vertical = 4.dp),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -192,9 +192,18 @@ private fun CommentsSection(
} else { } else {
comments.forEach { comment -> comments.forEach { comment ->
Column(modifier = Modifier.padding(bottom = 12.dp)) { Column(modifier = Modifier.padding(bottom = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(comment.creator, fontSize = 12.sp, color = subtleColor) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(formatDateTime(comment.createTime), fontSize = 12.sp, color = subtleColor) Text(comment.creator, fontSize = 12.sp, color = subtleColor)
Text(formatDateTime(comment.createTime), fontSize = 12.sp, color = subtleColor)
}
Text("delete", fontSize = 12.sp, color = subtleColor.copy(alpha = 0.5f),
modifier = Modifier.clickable {
scope.launch {
deps.apiClient.deleteMemo(comment.id)
comments = comments.filter { it.id != comment.id }
}
})
} }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
MarkdownText(markdown = comment.content) MarkdownText(markdown = comment.content)
@@ -40,6 +40,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.avinal.memos.AppDependencies import com.avinal.memos.AppDependencies
import com.avinal.memos.api.ApiResult
import com.avinal.memos.api.model.toDomain
import com.avinal.memos.domain.Memo
import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.components.MemoCard import com.avinal.memos.ui.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
@@ -59,22 +62,41 @@ fun MemoListScreen(
dateFilter: String? = null, dateFilter: String? = null,
tagFilter: String? = null, tagFilter: String? = null,
searchFilter: String? = null, searchFilter: String? = null,
showArchived: Boolean = false,
onClearFilter: (() -> Unit)? = null, onClearFilter: (() -> Unit)? = null,
) { ) {
val viewModel = viewModel { MemoListViewModel(deps.memoRepository) } val viewModel = viewModel { MemoListViewModel(deps.memoRepository) }
val allMemos by viewModel.memos.collectAsState() val allMemos by viewModel.memos.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val hasFilter = dateFilter != null || tagFilter != null || searchFilter != null var archivedMemos by remember { mutableStateOf<List<Memo>>(emptyList()) }
var isLoadingArchived by remember { mutableStateOf(false) }
LaunchedEffect(showArchived) {
if (showArchived) {
isLoadingArchived = true
when (val result = deps.apiClient.listArchivedMemos()) {
is ApiResult.Success -> {
archivedMemos = result.data.memos.map { it.toDomain() }
}
else -> {}
}
isLoadingArchived = false
}
}
val hasFilter = dateFilter != null || tagFilter != null || searchFilter != null || showArchived
val filterLabel = when { val filterLabel = when {
showArchived -> "archived memos"
dateFilter != null -> "date: $dateFilter" dateFilter != null -> "date: $dateFilter"
tagFilter != null -> "tag: #$tagFilter" tagFilter != null -> "tag: #$tagFilter"
searchFilter != null -> "search: $searchFilter" searchFilter != null -> "search: $searchFilter"
else -> "" else -> ""
} }
val memos = remember(allMemos, dateFilter, tagFilter, searchFilter) { val memos = remember(allMemos, dateFilter, tagFilter, searchFilter, showArchived, archivedMemos) {
when { when {
showArchived -> archivedMemos
dateFilter != null -> { dateFilter != null -> {
val parts = dateFilter.split("-") val parts = dateFilter.split("-")
if (parts.size == 3) { if (parts.size == 3) {
@@ -114,7 +136,7 @@ fun MemoListScreen(
uploadScope.launch { uploadScope.launch {
val base64 = Base64.encode(pickedFile.bytes) val base64 = Base64.encode(pickedFile.bytes)
when (val result = deps.memoRepository.uploadAttachment(pickedFile.name, pickedFile.mimeType, base64)) { when (val result = deps.memoRepository.uploadAttachment(pickedFile.name, pickedFile.mimeType, base64)) {
is com.avinal.memos.api.ApiResult.Success -> { is ApiResult.Success -> {
uploadedAttachmentNames = uploadedAttachmentNames + result.data uploadedAttachmentNames = uploadedAttachmentNames + result.data
} }
else -> {} else -> {}
@@ -185,6 +207,7 @@ fun MemoListScreen(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
if (!showArchived) {
item(key = "compose") { item(key = "compose") {
var showInsertMenu by remember { mutableStateOf(false) } var showInsertMenu by remember { mutableStateOf(false) }
@@ -348,8 +371,15 @@ fun MemoListScreen(
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
) )
} }
}
if (uiState.isInitialLoading && memos.isEmpty()) { if (showArchived && isLoadingArchived) {
item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
androidx.compose.material3.CircularProgressIndicator(color = accent, strokeWidth = 2.dp)
}
}
} else if (uiState.isInitialLoading && memos.isEmpty() && !showArchived) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
androidx.compose.material3.CircularProgressIndicator(color = accent, strokeWidth = 2.dp) androidx.compose.material3.CircularProgressIndicator(color = accent, strokeWidth = 2.dp)
@@ -358,7 +388,7 @@ fun MemoListScreen(
} else if (memos.isEmpty() && !uiState.isRefreshing) { } else if (memos.isEmpty() && !uiState.isRefreshing) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
Text("no memos yet", fontSize = 15.sp, color = subtleColor) Text(if (showArchived) "no archived memos" else "no memos yet", fontSize = 15.sp, color = subtleColor)
} }
} }
} }
@@ -373,19 +403,37 @@ fun MemoListScreen(
} }
items(memos, key = { it.id }) { memo -> items(memos, key = { it.id }) { memo ->
MemoCard( if (showArchived) {
memo = memo, MemoCard(
onClick = { onMemoClick(memo.id) }, memo = memo,
serverUrl = serverUrl, onClick = { onMemoClick(memo.id) },
onPin = { viewModel.togglePin(memo) }, serverUrl = serverUrl,
onArchive = { viewModel.archiveMemo(memo.id) }, onPin = null,
onDelete = { viewModel.deleteMemo(memo.id) }, onArchive = null,
onSave = { content, visibility -> onDelete = { viewModel.deleteMemo(memo.id) },
viewModel.updateMemo(memo.id, content, visibility) onSave = null,
}, onReact = null,
onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) }, onTaskToggle = null,
onTaskToggle = { lineIndex, checked -> viewModel.toggleTask(memo.id, lineIndex, checked) }, onRestore = {
) viewModel.restoreMemo(memo.id)
archivedMemos = archivedMemos.filter { it.id != memo.id }
},
)
} else {
MemoCard(
memo = memo,
onClick = { onMemoClick(memo.id) },
serverUrl = serverUrl,
onPin = { viewModel.togglePin(memo) },
onArchive = { viewModel.archiveMemo(memo.id) },
onDelete = { viewModel.deleteMemo(memo.id) },
onSave = { content, visibility ->
viewModel.updateMemo(memo.id, content, visibility)
},
onReact = { emoji -> viewModel.reactToMemo(memo.id, emoji) },
onTaskToggle = { lineIndex, checked -> viewModel.toggleTask(memo.id, lineIndex, checked) },
)
}
} }
} }
} }
@@ -122,6 +122,10 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel(
} }
} }
fun restoreMemo(id: String) {
viewModelScope.launch { memoRepository.restoreMemo(id) }
}
fun reactToMemo(memoId: String, emoji: String) { fun reactToMemo(memoId: String, emoji: String) {
viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) } viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) }
} }
@@ -125,7 +125,7 @@ fun TaskListScreen(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Icon( Icon(
if (group.collapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp, if (group.collapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp,
contentDescription = null, contentDescription = if (group.collapsed) "Expand" else "Collapse",
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = subtleColor, tint = subtleColor,
) )
@@ -0,0 +1,78 @@
package com.avinal.memos
import com.avinal.memos.db.entity.MemoEntity
import com.avinal.memos.db.entity.toDomain
import com.avinal.memos.db.entity.toEntity
import com.avinal.memos.domain.Memo
import com.avinal.memos.domain.MemoVisibility
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
class MemoEntityMappersTest {
private fun makeMemo(
id: String = "abc",
content: String = "hello",
tags: List<String> = listOf("work"),
pinned: Boolean = false,
commentCount: Int = 0,
) = Memo(
id = id, uid = "uid1", content = content, visibility = MemoVisibility.PRIVATE,
pinned = pinned, state = "NORMAL",
createTime = Instant.fromEpochMilliseconds(1000000),
updateTime = Instant.fromEpochMilliseconds(2000000),
displayTime = Instant.fromEpochMilliseconds(2000000),
creator = "test",
hasTaskList = false, hasIncompleteTasks = false, title = "Title",
tags = tags, snippet = "hello...",
commentCount = commentCount,
)
@Test
fun roundTripPreservesAllFields() {
val original = makeMemo(tags = listOf("work", "urgent"), pinned = true, commentCount = 3)
val entity = original.toEntity(999L)
val restored = entity.toDomain()
assertEquals(original.id, restored.id)
assertEquals(original.content, restored.content)
assertEquals(original.visibility, restored.visibility)
assertEquals(original.pinned, restored.pinned)
assertEquals(original.tags, restored.tags)
assertEquals(original.createTime, restored.createTime)
assertEquals(original.commentCount, restored.commentCount)
}
@Test
fun roundTripPreservesEmptyTags() {
val original = makeMemo(tags = emptyList())
val restored = original.toEntity(0).toDomain()
assertTrue(restored.tags.isEmpty())
}
@Test
fun cachedAtIsStored() {
val entity = makeMemo().toEntity(12345L)
assertEquals(12345L, entity.cachedAt)
}
@Test
fun visibilityRoundTrips() {
MemoVisibility.entries.forEach { vis ->
val memo = makeMemo().copy(visibility = vis)
val restored = memo.toEntity(0).toDomain()
assertEquals(vis, restored.visibility)
}
}
@Test
fun timestampsPreserved() {
val memo = makeMemo()
val entity = memo.toEntity(0)
assertEquals(1000000L, entity.createTime)
assertEquals(2000000L, entity.updateTime)
val restored = entity.toDomain()
assertEquals(memo.createTime, restored.createTime)
}
}