Skip to content

Commit f6907a1

Browse files
committed
Wire per-row Rename in chat history overflow menu
1 parent d45fbfa commit f6907a1

12 files changed

Lines changed: 444 additions & 1 deletion

File tree

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
119119
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
120120
.onEach(::render)
121121
.launchIn(viewLifecycleOwner.lifecycleScope)
122+
123+
viewModel.navigationEvents
124+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
125+
.onEach(::onNavigationEvent)
126+
.launchIn(viewLifecycleOwner.lifecycleScope)
127+
}
128+
129+
private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
130+
when (event) {
131+
is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle)
132+
}
133+
}
134+
135+
private fun openRenameScreen(chatId: String, currentTitle: String) {
136+
parentFragmentManager.beginTransaction()
137+
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
138+
.addToBackStack(null)
139+
.commit()
122140
}
123141

124142
private fun render(state: ChatHistoryUiState) {
@@ -269,7 +287,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
269287
val popup = PopupMenu(layoutInflater, R.layout.popup_chat_history_row)
270288
val view = popup.contentView
271289
popup.onMenuItemClicked(view.findViewById(R.id.pin)) { showComingSoonSnackbar() }
272-
popup.onMenuItemClicked(view.findViewById(R.id.rename)) { showComingSoonSnackbar() }
290+
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
291+
viewModel.onRenameRequested(item.chatId, item.displayTitle)
292+
}
273293
popup.onMenuItemClicked(view.findViewById(R.id.download)) { showComingSoonSnackbar() }
274294
popup.onMenuItemClicked(view.findViewById(R.id.delete)) { viewModel.onDeleteSingleChat(item.chatId) }
275295
popup.show(binding.root, anchor)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface ChatHistoryRepository {
3535

3636
suspend fun deleteChat(chatId: String)
3737
suspend fun deleteAllChats()
38+
suspend fun renameChat(chatId: String, newTitle: String)
3839
}
3940

4041
@ContributesBinding(AppScope::class)
@@ -59,6 +60,10 @@ class RealChatHistoryRepository @Inject constructor(
5960
withContext(dispatchers.io()) { chatStore.deleteAllChats() }
6061
}
6162

63+
override suspend fun renameChat(chatId: String, newTitle: String) {
64+
withContext(dispatchers.io()) { chatStore.renameChat(chatId, newTitle) }
65+
}
66+
6267
private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
6368
chatId = chat.chatId,
6469
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,15 @@ import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Loaded
2828
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.Mode
2929
import com.duckduckgo.duckchat.impl.history.ChatHistoryUiState.PendingConfirmation
3030
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.channels.BufferOverflow
32+
import kotlinx.coroutines.channels.Channel
33+
import kotlinx.coroutines.flow.Flow
3134
import kotlinx.coroutines.flow.MutableStateFlow
3235
import kotlinx.coroutines.flow.SharingStarted
3336
import kotlinx.coroutines.flow.StateFlow
3437
import kotlinx.coroutines.flow.combine
3538
import kotlinx.coroutines.flow.onEach
39+
import kotlinx.coroutines.flow.receiveAsFlow
3640
import kotlinx.coroutines.flow.stateIn
3741
import kotlinx.coroutines.flow.update
3842
import kotlinx.coroutines.launch
@@ -48,6 +52,9 @@ class ChatHistoryViewModel @Inject constructor(
4852

4953
private val controls = MutableStateFlow(UiControls())
5054

55+
private val navigationChannel = Channel<NavigationEvent>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
56+
val navigationEvents: Flow<NavigationEvent> = navigationChannel.receiveAsFlow()
57+
5158
/** Cached snapshot so non-suspend action methods can read Recent without re-subscribing. */
5259
private var latestItems: List<ChatHistoryItem> = emptyList()
5360

@@ -139,6 +146,10 @@ class ChatHistoryViewModel @Inject constructor(
139146
dispatchSelectedClear(setOf(chatId))
140147
}
141148

149+
fun onRenameRequested(chatId: String, currentTitle: String) {
150+
navigationChannel.trySend(NavigationEvent.OpenRename(chatId = chatId, currentTitle = currentTitle))
151+
}
152+
142153
private fun dispatchSelectedClear(chatIds: Set<String>) {
143154
if (chatIds.isEmpty()) return
144155
val urls = chatIds.mapTo(mutableSetOf()) { duckChat.buildChatUrl(it) }
@@ -256,6 +267,10 @@ class ChatHistoryViewModel @Inject constructor(
256267
val query: String = "",
257268
)
258269

270+
sealed interface NavigationEvent {
271+
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
272+
}
273+
259274
private companion object {
260275
const val STOP_TIMEOUT_MILLIS = 5_000L
261276
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.history
18+
19+
import android.os.Bundle
20+
import android.text.Editable
21+
import android.view.View
22+
import androidx.lifecycle.Lifecycle
23+
import androidx.lifecycle.ViewModelProvider
24+
import androidx.lifecycle.flowWithLifecycle
25+
import androidx.lifecycle.lifecycleScope
26+
import com.duckduckgo.anvil.annotations.InjectWith
27+
import com.duckduckgo.common.ui.DuckDuckGoFragment
28+
import com.duckduckgo.common.ui.viewbinding.viewBinding
29+
import com.duckduckgo.common.utils.FragmentViewModelFactory
30+
import com.duckduckgo.common.utils.extensions.hideKeyboard
31+
import com.duckduckgo.common.utils.text.TextChangedWatcher
32+
import com.duckduckgo.di.scopes.FragmentScope
33+
import com.duckduckgo.duckchat.impl.R
34+
import com.duckduckgo.duckchat.impl.databinding.FragmentRenameChatBinding
35+
import com.google.android.material.snackbar.Snackbar
36+
import kotlinx.coroutines.flow.launchIn
37+
import kotlinx.coroutines.flow.onEach
38+
import javax.inject.Inject
39+
40+
@InjectWith(FragmentScope::class)
41+
class RenameChatFragment : DuckDuckGoFragment(R.layout.fragment_rename_chat) {
42+
43+
@Inject
44+
lateinit var viewModelFactory: FragmentViewModelFactory
45+
46+
private val binding: FragmentRenameChatBinding by viewBinding()
47+
private val viewModel: RenameChatViewModel by lazy {
48+
ViewModelProvider(this, viewModelFactory)[RenameChatViewModel::class.java]
49+
}
50+
51+
private val chatId: String by lazy { checkNotNull(requireArguments().getString(ARG_CHAT_ID)) }
52+
private val initialTitle: String by lazy { requireArguments().getString(ARG_CURRENT_TITLE).orEmpty() }
53+
54+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
55+
super.onViewCreated(view, savedInstanceState)
56+
57+
binding.toolbar.setTitle(R.string.duck_ai_chat_history_rename_title)
58+
binding.toolbar.setNavigationIcon(com.duckduckgo.mobile.android.R.drawable.ic_arrow_left_24)
59+
binding.toolbar.setNavigationOnClickListener { dismiss() }
60+
binding.toolbar.inflateMenu(R.menu.menu_rename_chat)
61+
binding.toolbar.setOnMenuItemClickListener { item ->
62+
if (item.itemId == R.id.action_rename_confirm) {
63+
onConfirmClicked()
64+
true
65+
} else {
66+
false
67+
}
68+
}
69+
70+
binding.titleInput.text = initialTitle
71+
binding.titleInput.addTextChangedListener(titleTextWatcher)
72+
binding.titleInput.showKeyboardDelayed()
73+
setConfirmEnabled(false)
74+
75+
viewModel.results
76+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
77+
.onEach(::onRenameResult)
78+
.launchIn(viewLifecycleOwner.lifecycleScope)
79+
}
80+
81+
private fun onConfirmClicked() {
82+
viewModel.onSaveClicked(chatId, binding.titleInput.text)
83+
}
84+
85+
private fun onRenameResult(result: RenameChatViewModel.RenameResult) {
86+
when (result) {
87+
RenameChatViewModel.RenameResult.Success -> dismiss()
88+
is RenameChatViewModel.RenameResult.Error ->
89+
Snackbar.make(binding.root, R.string.duck_ai_chat_history_rename_error, Snackbar.LENGTH_SHORT).show()
90+
}
91+
}
92+
93+
private fun dismiss() {
94+
requireActivity().hideKeyboard()
95+
parentFragmentManager.popBackStack()
96+
}
97+
98+
private fun setConfirmEnabled(enabled: Boolean) {
99+
binding.toolbar.menu.findItem(R.id.action_rename_confirm)?.isEnabled = enabled
100+
}
101+
102+
private val titleTextWatcher = object : TextChangedWatcher() {
103+
override fun afterTextChanged(editable: Editable) {
104+
val candidate = editable.toString().trim()
105+
setConfirmEnabled(candidate.isNotBlank() && candidate != initialTitle.trim())
106+
}
107+
}
108+
109+
companion object {
110+
private const val ARG_CHAT_ID = "arg_chat_id"
111+
private const val ARG_CURRENT_TITLE = "arg_current_title"
112+
113+
fun newInstance(chatId: String, currentTitle: String): RenameChatFragment = RenameChatFragment().apply {
114+
arguments = Bundle().apply {
115+
putString(ARG_CHAT_ID, chatId)
116+
putString(ARG_CURRENT_TITLE, currentTitle)
117+
}
118+
}
119+
}
120+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.history
18+
19+
import androidx.lifecycle.ViewModel
20+
import com.duckduckgo.anvil.annotations.ContributesViewModel
21+
import com.duckduckgo.app.di.AppCoroutineScope
22+
import com.duckduckgo.di.scopes.FragmentScope
23+
import kotlinx.coroutines.CoroutineScope
24+
import kotlinx.coroutines.channels.BufferOverflow
25+
import kotlinx.coroutines.channels.Channel
26+
import kotlinx.coroutines.flow.Flow
27+
import kotlinx.coroutines.flow.receiveAsFlow
28+
import kotlinx.coroutines.launch
29+
import javax.inject.Inject
30+
31+
@ContributesViewModel(FragmentScope::class)
32+
class RenameChatViewModel @Inject constructor(
33+
private val chatHistoryRepository: ChatHistoryRepository,
34+
@AppCoroutineScope private val appScope: CoroutineScope,
35+
) : ViewModel() {
36+
37+
private val resultChannel = Channel<RenameResult>(capacity = Channel.BUFFERED, onBufferOverflow = BufferOverflow.DROP_OLDEST)
38+
val results: Flow<RenameResult> = resultChannel.receiveAsFlow()
39+
40+
fun onSaveClicked(chatId: String, newTitle: String) {
41+
appScope.launch {
42+
runCatching { chatHistoryRepository.renameChat(chatId, newTitle.trim()) }
43+
.onSuccess { resultChannel.trySend(RenameResult.Success) }
44+
.onFailure { error -> resultChannel.trySend(RenameResult.Error(error)) }
45+
}
46+
}
47+
48+
sealed interface RenameResult {
49+
data object Success : RenameResult
50+
data class Error(val throwable: Throwable) : RenameResult
51+
}
52+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2026 DuckDuckGo
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
17+
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto"
19+
xmlns:tools="http://schemas.android.com/tools"
20+
android:layout_width="match_parent"
21+
android:layout_height="match_parent"
22+
tools:context="com.duckduckgo.duckchat.impl.history.RenameChatFragment">
23+
24+
<com.google.android.material.appbar.AppBarLayout
25+
android:id="@+id/appBarLayout"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
29+
app:layout_constraintStart_toStartOf="parent"
30+
app:layout_constraintTop_toTopOf="parent">
31+
32+
<androidx.appcompat.widget.Toolbar
33+
android:id="@+id/toolbar"
34+
android:layout_width="match_parent"
35+
android:layout_height="?attr/actionBarSize"
36+
android:background="?attr/daxColorToolbar"
37+
android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"
38+
app:popupTheme="@style/Widget.DuckDuckGo.PopUpOverflowMenu" />
39+
</com.google.android.material.appbar.AppBarLayout>
40+
41+
<com.duckduckgo.common.ui.view.text.DaxTextInput
42+
android:id="@+id/titleInput"
43+
android:layout_width="match_parent"
44+
android:layout_height="wrap_content"
45+
android:layout_margin="@dimen/keyline_4"
46+
android:hint="@string/duck_ai_chat_history_rename_chat_title_hint"
47+
android:inputType="text|textCapWords"
48+
app:layout_constraintEnd_toEndOf="parent"
49+
app:layout_constraintStart_toStartOf="parent"
50+
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
51+
52+
</androidx.constraintlayout.widget.ConstraintLayout>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2026 DuckDuckGo
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
17+
<menu xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto">
19+
<item
20+
android:id="@+id/action_rename_confirm"
21+
android:enabled="false"
22+
android:icon="@drawable/ic_check_24"
23+
android:title="@string/duck_ai_chat_history_rename_confirm_content_description"
24+
app:showAsAction="always" />
25+
</menu>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,8 @@
103103
<string name="duck_ai_chat_history_row_selected_content_description">Selected</string>
104104
<string name="duck_ai_chat_history_row_unselected_content_description">Not selected</string>
105105
<string name="duck_ai_chat_history_exit_select_mode_content_description">Exit selection mode</string>
106+
<string name="duck_ai_chat_history_rename_title">Rename Chat</string>
107+
<string name="duck_ai_chat_history_rename_chat_title_hint">Chat Title</string>
108+
<string name="duck_ai_chat_history_rename_confirm_content_description">Save</string>
109+
<string name="duck_ai_chat_history_rename_error">Couldn\'t rename chat</string>
106110
</resources>

0 commit comments

Comments
 (0)