Skip to content

Commit 2aea312

Browse files
committed
Show Pin/Unpin snackbar with Undo action
Surfaces a snackbar after the per-row Pin/Unpin overflow action so the user can recover from a mistap. The toggle still fires immediately; the snackbar is purely informational + an Undo affordance that re-issues setPinned with the previous value. Also fixes a pre-existing build break in RecordingRenameRepository, which was missing the setPinned override added in b3157b9.
1 parent 01a96ad commit 2aea312

5 files changed

Lines changed: 103 additions & 1 deletion

File tree

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
125125
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
126126
.onEach(::onNavigationEvent)
127127
.launchIn(viewLifecycleOwner.lifecycleScope)
128+
129+
viewModel.messageEvents
130+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
131+
.onEach(::onMessageEvent)
132+
.launchIn(viewLifecycleOwner.lifecycleScope)
128133
}
129134

130135
private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
@@ -133,6 +138,25 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
133138
}
134139
}
135140

141+
private fun onMessageEvent(event: ChatHistoryViewModel.MessageEvent) {
142+
when (event) {
143+
is ChatHistoryViewModel.MessageEvent.PinToggled -> showPinToggledSnackbar(event)
144+
}
145+
}
146+
147+
private fun showPinToggledSnackbar(event: ChatHistoryViewModel.MessageEvent.PinToggled) {
148+
val messageRes = if (event.wasPinned) {
149+
R.string.duck_ai_chat_history_unpin_snackbar
150+
} else {
151+
R.string.duck_ai_chat_history_pin_snackbar
152+
}
153+
Snackbar.make(binding.root, messageRes, Snackbar.LENGTH_LONG)
154+
.setAction(R.string.duck_ai_chat_history_undo) {
155+
viewModel.onUndoTogglePin(event.chatId, restorePinned = event.wasPinned)
156+
}
157+
.show()
158+
}
159+
136160
private fun openRenameScreen(chatId: String, currentTitle: String) {
137161
parentFragmentManager.beginTransaction()
138162
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class ChatHistoryViewModel @Inject constructor(
5757
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
5858
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()
5959

60+
private val messageChannel = Channel<MessageEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
61+
val messageEvents: Flow<MessageEvent> = messageChannel.receiveAsFlow()
62+
6063
/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
6164
private var latestItems: List<ChatHistoryItem> = emptyList()
6265

@@ -136,7 +139,13 @@ class ChatHistoryViewModel @Inject constructor(
136139

137140
fun onTogglePin(chatId: String) {
138141
val current = latestItems.firstOrNull { it.chatId == chatId } ?: return
139-
appScope.launch { chatHistoryRepository.setPinned(chatId, !current.pinned) }
142+
val wasPinned = current.pinned
143+
appScope.launch { chatHistoryRepository.setPinned(chatId, !wasPinned) }
144+
messageChannel.trySend(MessageEvent.PinToggled(chatId = chatId, wasPinned = wasPinned))
145+
}
146+
147+
fun onUndoTogglePin(chatId: String, restorePinned: Boolean) {
148+
appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) }
140149
}
141150

142151
private fun dispatchSelectedClear(chatIds: Set<String>) {
@@ -267,6 +276,10 @@ class ChatHistoryViewModel @Inject constructor(
267276
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
268277
}
269278

279+
sealed interface MessageEvent {
280+
data class PinToggled(val chatId: String, val wasPinned: Boolean) : MessageEvent
281+
}
282+
270283
private companion object {
271284
const val STOP_TIMEOUT_MILLIS = 5_000L
272285
}

duckchat/duckchat-impl/src/main/res/values/donottranslate.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,7 @@
107107
<string name="duck_ai_chat_history_rename_confirm_title">Save</string>
108108
<string name="duck_ai_chat_history_rename_confirm_content_description">Save chat title</string>
109109
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
110+
<string name="duck_ai_chat_history_pin_snackbar">Chat pinned</string>
111+
<string name="duck_ai_chat_history_unpin_snackbar">Chat unpinned</string>
112+
<string name="duck_ai_chat_history_undo">Undo</string>
110113
</resources>

duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModelTest.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,66 @@ class ChatHistoryViewModelTest {
858858
assertTrue(repository.pinnedChats.isEmpty())
859859
}
860860

861+
@Test
862+
fun `onTogglePin emits PinToggled message event with previous pinned state false`() = coroutineRule.testScope.runTest {
863+
source.value = listOf(item("a", pinned = false))
864+
865+
viewModel.messageEvents.test {
866+
// Drain the initial Loaded state so latestItems is primed.
867+
viewModel.uiState.test { awaitInitialLoaded() }
868+
viewModel.onTogglePin("a")
869+
870+
val event = awaitItem()
871+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
872+
event as ChatHistoryViewModel.MessageEvent.PinToggled
873+
assertEquals("a", event.chatId)
874+
assertEquals(false, event.wasPinned)
875+
}
876+
}
877+
878+
@Test
879+
fun `onTogglePin emits PinToggled message event with previous pinned state true`() = coroutineRule.testScope.runTest {
880+
source.value = listOf(item("a", pinned = true))
881+
882+
viewModel.messageEvents.test {
883+
viewModel.uiState.test { awaitInitialLoaded() }
884+
viewModel.onTogglePin("a")
885+
886+
val event = awaitItem()
887+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
888+
event as ChatHistoryViewModel.MessageEvent.PinToggled
889+
assertEquals("a", event.chatId)
890+
assertEquals(true, event.wasPinned)
891+
}
892+
}
893+
894+
@Test
895+
fun `onTogglePin emits no message event when chatId is unknown`() = coroutineRule.testScope.runTest {
896+
source.value = listOf(item("a", pinned = false))
897+
898+
viewModel.messageEvents.test {
899+
viewModel.uiState.test { awaitInitialLoaded() }
900+
viewModel.onTogglePin("missing")
901+
expectNoEvents()
902+
}
903+
}
904+
905+
@Test
906+
fun `onUndoTogglePin restores the requested pinned state via the repository`() = coroutineRule.testScope.runTest {
907+
source.value = listOf(item("a", pinned = true))
908+
909+
viewModel.uiState.test {
910+
awaitInitialLoaded()
911+
viewModel.onTogglePin("a") // a → unpinned
912+
awaitItem() // Loaded after the unpin write
913+
viewModel.onUndoTogglePin("a", restorePinned = true)
914+
val restored = awaitItem() as Loaded
915+
assertEquals(listOf("a"), restored.pinned.map { it.chatId })
916+
assertEquals(emptyList<String>(), restored.recent.map { it.chatId })
917+
}
918+
assertEquals(listOf("a" to false, "a" to true), repository.pinnedChats)
919+
}
920+
861921
/**
862922
* `stateIn(WhileSubscribed)` does not guarantee subscribers observe the `Loading` initial
863923
* value — the upstream may emit before the StateFlow can replay it. Tolerate both orderings.

duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/history/RenameChatViewModelTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ private class RecordingRenameRepository : ChatHistoryRepository {
8181
renames += chatId to newTitle
8282
return nextResult
8383
}
84+
85+
override suspend fun setPinned(chatId: String, pinned: Boolean) = Unit
8486
}

0 commit comments

Comments
 (0)