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