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> {
return when (val result = apiClient.deleteMemo(id)) {
is ApiResult.Success -> {
@@ -56,6 +56,7 @@ fun MemoCard(
onSave: ((String, MemoVisibility) -> Unit)? = null,
onReact: ((String) -> Unit)? = null,
onTaskToggle: ((Int, Boolean) -> Unit)? = null,
onRestore: (() -> Unit)? = null,
) {
val accent = LocalAccentColor.current
val textColor = MaterialTheme.colorScheme.onBackground
@@ -94,6 +95,13 @@ fun MemoCard(
title = null,
text = {
Column {
if (onRestore != null) {
MetroMenuItem("restore", textColor) { showMenu = false; onRestore.invoke() }
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
@@ -104,6 +112,7 @@ fun MemoCard(
Spacer(Modifier.height(8.dp))
MetroMenuItem("delete", MaterialTheme.colorScheme.error) { showMenu = false; showDeleteDialog = true }
}
}
},
confirmButton = {},
)
@@ -79,6 +79,7 @@ fun MainScreen(
var dateFilter by remember { mutableStateOf<String?>(null) }
var tagFilter 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) } }
@@ -135,15 +136,19 @@ fun MainScreen(
deps = deps,
onMemoClick = onMemoClick,
onDateSelected = { date ->
dateFilter = date; tagFilter = null; searchFilter = null
dateFilter = date; tagFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onTagSelected = { tag ->
tagFilter = tag; dateFilter = null; searchFilter = null
tagFilter = tag; dateFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
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()
},
)
@@ -154,7 +159,8 @@ fun MainScreen(
dateFilter = dateFilter,
tagFilter = tagFilter,
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)
3 -> SettingsScreen(deps = deps, onLogout = onLogout)
@@ -171,6 +177,7 @@ private fun ExplorerPage(
onDateSelected: (String) -> Unit,
onTagSelected: (String) -> Unit,
onSearchSubmit: (String) -> Unit,
onShowArchived: () -> Unit,
) {
val memos by deps.memoRepository.observeMemos().collectAsState(initial = emptyList())
val accent = LocalAccentColor.current
@@ -252,7 +259,7 @@ private fun ExplorerPage(
"view archived memos",
fontSize = 14.sp,
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))
@@ -192,10 +192,19 @@ private fun CommentsSection(
} else {
comments.forEach { comment ->
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
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))
MarkdownText(markdown = comment.content)
}
@@ -40,6 +40,9 @@ 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.api.model.toDomain
import com.avinal.memos.domain.Memo
import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor
@@ -59,22 +62,41 @@ fun MemoListScreen(
dateFilter: String? = null,
tagFilter: String? = null,
searchFilter: String? = null,
showArchived: Boolean = false,
onClearFilter: (() -> Unit)? = null,
) {
val viewModel = viewModel { MemoListViewModel(deps.memoRepository) }
val allMemos by viewModel.memos.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 {
showArchived -> "archived memos"
dateFilter != null -> "date: $dateFilter"
tagFilter != null -> "tag: #$tagFilter"
searchFilter != null -> "search: $searchFilter"
else -> ""
}
val memos = remember(allMemos, dateFilter, tagFilter, searchFilter) {
val memos = remember(allMemos, dateFilter, tagFilter, searchFilter, showArchived, archivedMemos) {
when {
showArchived -> archivedMemos
dateFilter != null -> {
val parts = dateFilter.split("-")
if (parts.size == 3) {
@@ -114,7 +136,7 @@ fun MemoListScreen(
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 -> {
is ApiResult.Success -> {
uploadedAttachmentNames = uploadedAttachmentNames + result.data
}
else -> {}
@@ -185,6 +207,7 @@ fun MemoListScreen(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
if (!showArchived) {
item(key = "compose") {
var showInsertMenu by remember { mutableStateOf(false) }
@@ -348,8 +371,15 @@ fun MemoListScreen(
.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 {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
androidx.compose.material3.CircularProgressIndicator(color = accent, strokeWidth = 2.dp)
@@ -358,7 +388,7 @@ fun MemoListScreen(
} else if (memos.isEmpty() && !uiState.isRefreshing) {
item {
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,6 +403,23 @@ fun MemoListScreen(
}
items(memos, key = { it.id }) { memo ->
if (showArchived) {
MemoCard(
memo = memo,
onClick = { onMemoClick(memo.id) },
serverUrl = serverUrl,
onPin = null,
onArchive = null,
onDelete = { viewModel.deleteMemo(memo.id) },
onSave = null,
onReact = null,
onTaskToggle = null,
onRestore = {
viewModel.restoreMemo(memo.id)
archivedMemos = archivedMemos.filter { it.id != memo.id }
},
)
} else {
MemoCard(
memo = memo,
onClick = { onMemoClick(memo.id) },
@@ -390,4 +437,5 @@ fun MemoListScreen(
}
}
}
}
}
@@ -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) {
viewModelScope.launch { memoRepository.reactToMemo(memoId, emoji) }
}
@@ -125,7 +125,7 @@ fun TaskListScreen(
Spacer(Modifier.width(6.dp))
Icon(
if (group.collapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp,
contentDescription = null,
contentDescription = if (group.collapsed) "Expand" else "Collapse",
modifier = Modifier.size(18.dp),
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)
}
}