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