Skip to content

Commit 23dda12

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 b3157b9 commit 23dda12

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
@@ -55,6 +55,9 @@ class ChatHistoryViewModel @Inject constructor(
5555
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
5656
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()
5757

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

@@ -152,7 +155,13 @@ class ChatHistoryViewModel @Inject constructor(
152155

153156
fun onTogglePin(chatId: String) {
154157
val current = latestItems.firstOrNull { it.chatId == chatId } ?: return
155-
appScope.launch { chatHistoryRepository.setPinned(chatId, !current.pinned) }
158+
val wasPinned = current.pinned
159+
appScope.launch { chatHistoryRepository.setPinned(chatId, !wasPinned) }
160+
messageChannel.trySend(MessageEvent.PinToggled(chatId = chatId, wasPinned = wasPinned))
161+
}
162+
163+
fun onUndoTogglePin(chatId: String, restorePinned: Boolean) {
164+
appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) }
156165
}
157166

158167
private fun dispatchSelectedClear(chatIds: Set<String>) {
@@ -276,6 +285,10 @@ class ChatHistoryViewModel @Inject constructor(
276285
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
277286
}
278287

288+
sealed interface MessageEvent {
289+
data class PinToggled(val chatId: String, val wasPinned: Boolean) : MessageEvent
290+
}
291+
279292
private companion object {
280293
const val STOP_TIMEOUT_MILLIS = 5_000L
281294
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,7 @@
101101
<string name="duck_ai_chat_history_rename_chat_title_hint">Chat Title</string>
102102
<string name="duck_ai_chat_history_rename_confirm_content_description">Save</string>
103103
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
104+
<string name="duck_ai_chat_history_pin_snackbar">Chat pinned</string>
105+
<string name="duck_ai_chat_history_unpin_snackbar">Chat unpinned</string>
106+
<string name="duck_ai_chat_history_undo">Undo</string>
104107
</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
@@ -750,6 +750,66 @@ class ChatHistoryViewModelTest {
750750
assertTrue(repository.pinnedChats.isEmpty())
751751
}
752752

753+
@Test
754+
fun `onTogglePin emits PinToggled message event with previous pinned state false`() = coroutineRule.testScope.runTest {
755+
source.value = listOf(item("a", pinned = false))
756+
757+
viewModel.messageEvents.test {
758+
// Drain the initial Loaded state so latestItems is primed.
759+
viewModel.uiState.test { awaitInitialLoaded() }
760+
viewModel.onTogglePin("a")
761+
762+
val event = awaitItem()
763+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
764+
event as ChatHistoryViewModel.MessageEvent.PinToggled
765+
assertEquals("a", event.chatId)
766+
assertEquals(false, event.wasPinned)
767+
}
768+
}
769+
770+
@Test
771+
fun `onTogglePin emits PinToggled message event with previous pinned state true`() = coroutineRule.testScope.runTest {
772+
source.value = listOf(item("a", pinned = true))
773+
774+
viewModel.messageEvents.test {
775+
viewModel.uiState.test { awaitInitialLoaded() }
776+
viewModel.onTogglePin("a")
777+
778+
val event = awaitItem()
779+
assertTrue(event is ChatHistoryViewModel.MessageEvent.PinToggled)
780+
event as ChatHistoryViewModel.MessageEvent.PinToggled
781+
assertEquals("a", event.chatId)
782+
assertEquals(true, event.wasPinned)
783+
}
784+
}
785+
786+
@Test
787+
fun `onTogglePin emits no message event when chatId is unknown`() = coroutineRule.testScope.runTest {
788+
source.value = listOf(item("a", pinned = false))
789+
790+
viewModel.messageEvents.test {
791+
viewModel.uiState.test { awaitInitialLoaded() }
792+
viewModel.onTogglePin("missing")
793+
expectNoEvents()
794+
}
795+
}
796+
797+
@Test
798+
fun `onUndoTogglePin restores the requested pinned state via the repository`() = coroutineRule.testScope.runTest {
799+
source.value = listOf(item("a", pinned = true))
800+
801+
viewModel.uiState.test {
802+
awaitInitialLoaded()
803+
viewModel.onTogglePin("a") // a → unpinned
804+
awaitItem() // Loaded after the unpin write
805+
viewModel.onUndoTogglePin("a", restorePinned = true)
806+
val restored = awaitItem() as Loaded
807+
assertEquals(listOf("a"), restored.pinned.map { it.chatId })
808+
assertEquals(emptyList<String>(), restored.recent.map { it.chatId })
809+
}
810+
assertEquals(listOf("a" to false, "a" to true), repository.pinnedChats)
811+
}
812+
753813
@Test
754814
fun `concurrent repository emission removing a selected id reconciles the selection`() = runTest {
755815
source.value = listOf(item("a"), item("b"), item("c"))

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
@@ -72,4 +72,6 @@ private class RecordingRenameRepository : ChatHistoryRepository {
7272
errorToThrow?.let { throw it }
7373
renames += chatId to newTitle
7474
}
75+
76+
override suspend fun setPinned(chatId: String, pinned: Boolean) = Unit
7577
}

0 commit comments

Comments
 (0)