diff --git a/shared/src/commonMain/composeResources/values-en/strings.xml b/shared/src/commonMain/composeResources/values-en/strings.xml
index 9365cad..ddcc4eb 100644
--- a/shared/src/commonMain/composeResources/values-en/strings.xml
+++ b/shared/src/commonMain/composeResources/values-en/strings.xml
@@ -55,6 +55,7 @@
Search videos, creators and articles
Recent searches
+ Manage search history
Remove this search
Describe what you want to watch
No results
@@ -103,6 +104,7 @@
Lowest level
Open profile of UID %1$s
Suggestions
+ Fill %1$s
Videos
Creators
Articles
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index e337c67..717a9f3 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -71,6 +71,7 @@
搜索视频、UP 主与专栏
最近搜索
+ 管理搜索记录
删除这条搜索记录
描述想看的内容
没有结果
@@ -119,6 +120,7 @@
等级最低
打开 UID %1$s 的空间
补全
+ 填入 %1$s
视频
用户
专栏
diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/components/SearchField.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/components/SearchField.kt
index 85bda9a..d43c2f5 100644
--- a/shared/src/commonMain/kotlin/dev/bilby/ui/components/SearchField.kt
+++ b/shared/src/commonMain/kotlin/dev/bilby/ui/components/SearchField.kt
@@ -25,7 +25,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -36,7 +38,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import dev.bilby.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import dev.bilby.resources.*
import dev.bilby.ui.theme.Dimens
@@ -86,6 +90,17 @@ fun SearchField(
releaseFocusWithKeyboard: Boolean = true,
) {
val keyboard = LocalSoftwareKeyboardController.current
+ val effectiveFocusRequester = focusRequester ?: remember { FocusRequester() }
+ var textFieldValue by remember {
+ mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length)))
+ }
+ var previousValueProp by remember { mutableStateOf(value) }
+ if (value != previousValueProp) {
+ previousValueProp = value
+ if (value != textFieldValue.text) {
+ textFieldValue = TextFieldValue(text = value, selection = TextRange(value.length))
+ }
+ }
// 焦点态。**必须是同一个 interactionSource 既交给 BasicTextField 又在这里读**:
// 光标在框里而容器没反应,是这个组件没有描边、没有 label 之后唯一缺的那点反馈。
val interactionSource = remember { MutableInteractionSource() }
@@ -131,8 +146,12 @@ fun SearchField(
)
}
BasicTextField(
- value = value,
- onValueChange = onValueChange,
+ value = textFieldValue,
+ onValueChange = { next ->
+ val changed = next.text != textFieldValue.text
+ textFieldValue = next
+ if (changed) onValueChange(next.text)
+ },
singleLine = true,
textStyle = LocalTextStyle.current.merge(
MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
@@ -155,11 +174,18 @@ fun SearchField(
.semantics { contentDescription = placeholder }
.trackEditableFocus()
.onFocusChanged { onFocusChange(it.isFocused) }
- .let { if (focusRequester != null) it.focusRequester(focusRequester) else it },
+ .focusRequester(effectiveFocusRequester),
)
}
if (value.isNotEmpty()) {
- IconButton(onClick = { onValueChange("") }) {
+ IconButton(
+ onClick = {
+ textFieldValue = TextFieldValue("")
+ onValueChange("")
+ effectiveFocusRequester.requestFocus()
+ keyboard?.show()
+ },
+ ) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(Res.string.action_clear),
diff --git a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt
index 90e4c21..34bedc5 100644
--- a/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt
+++ b/shared/src/commonMain/kotlin/dev/bilby/ui/search/SearchChatScreen.kt
@@ -11,7 +11,6 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -45,7 +44,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
+import dev.bilby.ui.theme.Dimens
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -53,10 +54,11 @@ import androidx.compose.material.icons.Icons
import dev.bilby.ui.components.PillInputField
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.AutoAwesome
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.NorthWest
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.IconButton
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
@@ -78,6 +80,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -329,6 +332,7 @@ private fun NormalSearch(
) {
var focused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
+ val keyboard = LocalSoftwareKeyboardController.current
val searchFocusRequester = remember { FocusRequester() }
LaunchedEffect(focusSearchNonce) {
if (focusSearchNonce > 0) {
@@ -368,7 +372,18 @@ private fun NormalSearch(
// 聚焦时盖在结果上的那一块:正在打字而且有补全就是补全,否则是历史。
val typing = focused && input.isNotBlank() && suggestions.isNotEmpty()
val focusPanel: (@Composable (Modifier) -> Unit)? = when {
- typing -> { panelModifier -> SuggestionPanel(suggestions, onSuggestionClick, panelModifier) }
+ typing -> { panelModifier ->
+ SuggestionPanel(
+ suggestions = suggestions,
+ onClick = onSuggestionClick,
+ onFill = { term ->
+ onInputChange(term)
+ runCatching { searchFocusRequester.requestFocus() }
+ keyboard?.show()
+ },
+ modifier = panelModifier,
+ )
+ }
history.isNotEmpty() -> historyPanel
else -> null
}
@@ -609,6 +624,7 @@ private val AskAgentFabClearance = 88.dp
private fun SuggestionPanel(
suggestions: List,
onClick: (String) -> Unit,
+ onFill: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(Res.string.search_suggestions)
@@ -627,7 +643,7 @@ private fun SuggestionPanel(
.fillMaxWidth()
.heightIn(min = SuggestionRowHeight)
.clickable(role = Role.Button) { onClick(suggestion.term) }
- .padding(horizontal = Spacing.Comfortable),
+ .padding(start = Spacing.Comfortable, end = Spacing.Hair),
) {
Icon(
Icons.Outlined.Search,
@@ -646,7 +662,16 @@ private fun SuggestionPanel(
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
)
+ IconButton(onClick = { onFill(suggestion.term) }) {
+ Icon(
+ imageVector = Icons.Outlined.NorthWest,
+ contentDescription = stringResource(Res.string.search_suggestion_fill, suggestion.term),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(Dimens.IconInline),
+ )
+ }
}
}
}
@@ -745,11 +770,9 @@ private class CollapsingBar {
* **chip 而不是一行一条。** 一行一条时五条就是一屏,上限因此只敢给五;chip 按词长排,几十个
* 词也只占几行,要找的那个一眼扫得到。历史本身不设上限(见 SettingsStore.searchHistory)。
*
- * **点一下直接搜,长按出菜单删这一条。** 删除从常驻的 × 收进长按:每个 chip 各带一个 ×,
- * 一半的面积是删除按钮,点偏一点就删掉了想搜的那个。
- *
- * **有「清空」,而且要确认。** 五条时逐条删就够;没有上限之后一条条长按删是折磨。清空不可
- * 撤销,所以隔一个确认框。
+ * **默认点一下直接搜;点右上角删除图标或长按任一词进入编辑态,逐条点选删除。**
+ * 常驻 × 会让每个 chip 一半面积变成删除键,平时点偏就误删;把 × 收进编辑态后,平时只管点按
+ * 搜索,需要清理时既能点选删掉单独几条,也能在编辑态里点「清空」(带二次确认)全部清掉。
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -760,7 +783,12 @@ private fun SearchHistoryPanel(
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
+ var editing by remember { mutableStateOf(false) }
var confirmingClear by remember { mutableStateOf(false) }
+ LaunchedEffect(history.isEmpty()) {
+ if (history.isEmpty()) editing = false
+ }
+ BackHandler(enabled = editing) { editing = false }
Column(modifier = modifier.padding(horizontal = Spacing.Comfortable, vertical = Spacing.Tight)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -769,8 +797,21 @@ private fun SearchHistoryPanel(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
- TextButton(onClick = { confirmingClear = true }) {
- Text(stringResource(Res.string.action_clear))
+ if (editing) {
+ TextButton(onClick = { confirmingClear = true }) {
+ Text(stringResource(Res.string.action_clear))
+ }
+ TextButton(onClick = { editing = false }) {
+ Text(stringResource(Res.string.action_done))
+ }
+ } else {
+ IconButton(onClick = { editing = true }) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(Res.string.search_history_manage),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
FlowRow(
@@ -779,7 +820,12 @@ private fun SearchHistoryPanel(
modifier = Modifier.padding(bottom = Spacing.Tight),
) {
history.forEach { query ->
- HistoryChip(query = query, onClick = { onClick(query) }, onRemove = { onRemove(query) })
+ HistoryChip(
+ query = query,
+ editing = editing,
+ onClick = { if (editing) onRemove(query) else onClick(query) },
+ onLongClick = { editing = true },
+ )
}
}
}
@@ -791,6 +837,7 @@ private fun SearchHistoryPanel(
confirmButton = {
TextButton(onClick = {
confirmingClear = false
+ editing = false
onClear()
}) { Text(stringResource(Res.string.action_clear)) }
},
@@ -804,25 +851,38 @@ private fun SearchHistoryPanel(
}
/**
- * 历史里的一个词。形状与底色取 M3 的 suggestion chip,但自己拼:material3 的 chip 只给单击,
- * 这里还要长按。菜单挂在 chip 上,从按下去的那个词旁边弹出来。
+ * 历史里的一个词。默认态点击直接搜索、长按进入编辑态;编辑态下词尾露出 ×,点击即删这一条。
*/
@Composable
-private fun HistoryChip(query: String, onClick: () -> Unit, onRemove: () -> Unit) {
- var menuOpen by remember { mutableStateOf(false) }
- Box {
- Surface(
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
+private fun HistoryChip(
+ query: String,
+ editing: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ modifier = Modifier
+ // 视觉 32dp,触控撑到 48dp(风格指南 §3)。
+ .minimumInteractiveComponentSize()
+ .clip(MaterialTheme.shapes.small)
+ .combinedClickable(
+ role = Role.Button,
+ onClick = onClick,
+ onClickLabel = if (editing) stringResource(Res.string.search_history_remove) else null,
+ onLongClick = if (editing) null else onLongClick,
+ onLongClickLabel = if (editing) null else stringResource(Res.string.search_history_manage),
+ ),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Spacing.Hair),
modifier = Modifier
- // 视觉 32dp,触控撑到 48dp(风格指南 §3)。
- .minimumInteractiveComponentSize()
- .clip(MaterialTheme.shapes.small)
- .combinedClickable(
- role = Role.Button,
- onClick = onClick,
- onLongClick = { menuOpen = true },
- onLongClickLabel = stringResource(Res.string.search_history_remove),
+ .heightIn(min = HistoryChipHeight)
+ .padding(
+ start = Spacing.Cozy,
+ end = if (editing) Spacing.Tight else Spacing.Cozy,
),
) {
Text(
@@ -830,26 +890,23 @@ private fun HistoryChip(query: String, onClick: () -> Unit, onRemove: () -> Unit
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier
- .heightIn(min = HistoryChipHeight)
- .wrapContentHeight()
- .padding(horizontal = Spacing.Cozy),
- )
- }
- DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.search_history_remove)) },
- onClick = {
- menuOpen = false
- onRemove()
- },
+ modifier = Modifier.weight(1f, fill = false),
)
+ if (editing) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(HistoryChipCloseIconSize),
+ )
+ }
}
}
}
-/** M3 chip 的容器高度。 */
+/** M3 chip 的容器高度与行尾关闭图标尺寸。 */
private val HistoryChipHeight = 32.dp
+private val HistoryChipCloseIconSize = 16.dp
/** 助理:上面是对话,下面是追问框。窄屏的助理一屏和宽屏的侧栏是同一块。 */
@Composable