From 9bebc628bd2b0870a4540892f539b0f0b29611e8 Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Thu, 21 May 2026 19:31:23 +0530 Subject: [PATCH] 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 --- .../com/avinal/memos/domain/MemoRepository.kt | 12 +++ .../avinal/memos/ui/components/MemoCard.kt | 25 ++++-- .../com/avinal/memos/ui/memos/MainScreen.kt | 17 ++-- .../avinal/memos/ui/memos/MemoDetailScreen.kt | 15 +++- .../avinal/memos/ui/memos/MemoListScreen.kt | 84 +++++++++++++++---- .../memos/ui/memos/MemoListViewModel.kt | 4 + .../avinal/memos/ui/tasks/TaskListScreen.kt | 2 +- .../com/avinal/memos/MemoEntityMappersTest.kt | 78 +++++++++++++++++ 8 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/MemoEntityMappersTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt index c0e3a02..0e14683 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt @@ -167,6 +167,18 @@ class MemoRepository( } } + suspend fun restoreMemo(id: String): ApiResult { + 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 { return when (val result = apiClient.deleteMemo(id)) { is ApiResult.Success -> { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt index 2341017..89384eb 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/components/MemoCard.kt @@ -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,15 +95,23 @@ fun MemoCard( title = null, text = { Column { - 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 + 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 + } + 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 = {}, diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt index 4ad6342..94f7a77 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt @@ -79,6 +79,7 @@ fun MainScreen( var dateFilter by remember { mutableStateOf(null) } var tagFilter by remember { mutableStateOf(null) } var searchFilter by remember { mutableStateOf(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)) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt index 4381516..83edad1 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailScreen.kt @@ -192,9 +192,18 @@ private fun CommentsSection( } else { comments.forEach { comment -> Column(modifier = Modifier.padding(bottom = 12.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(comment.creator, fontSize = 12.sp, color = subtleColor) - Text(formatDateTime(comment.createTime), fontSize = 12.sp, color = subtleColor) + 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) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt index 09205cd..37237f7 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt @@ -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>(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,19 +403,37 @@ fun MemoListScreen( } items(memos, key = { it.id }) { memo -> - 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) }, - ) + 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) }, + 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) }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt index be775c7..74c0b1c 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt @@ -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) } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt index 40d2b7a..3b4ec20 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt @@ -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, ) diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoEntityMappersTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoEntityMappersTest.kt new file mode 100644 index 0000000..e041456 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoEntityMappersTest.kt @@ -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 = 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) + } +}