From 8babadc33e8f97522af749c08c0b50faa18ad264 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <99822064+revonateB0T@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:19:56 +0800 Subject: [PATCH] Implement Tagger suggestion (#1968) * Implement Tagger suggestion * Refine suggestFlow impl * Refine suggest * Revert * Refine display * Refine * Refine * Refine * Refine * Update app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com> * Refine * Refine * Refine --------- Co-authored-by: Dude so hot Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com> --- .../hippo/ehviewer/client/EhTagDatabase.kt | 61 +++++----- .../ehviewer/ui/screen/SearchBarScreen.kt | 22 ++-- .../com/hippo/ehviewer/ui/tools/Dialog.kt | 113 ++++++++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 132 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt index e5ba345b0..b3e38dad6 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/EhTagDatabase.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import com.hippo.ehviewer.EhApplication.Companion.ktorClient import com.hippo.ehviewer.R import com.hippo.ehviewer.client.data.TagNamespace +import com.hippo.ehviewer.ui.screen.implicit import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.copyTo @@ -35,7 +36,7 @@ import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -63,60 +64,56 @@ object EhTagDatabase : CoroutineScope { fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null } - private fun internalSuggestFlow( - tags: Map, - keyword: String, - translate: Boolean, - exactly: Boolean, - ): Flow> = flow { + private fun internalSuggestFlow(tags: Map, keyword: String, translate: Boolean, exactly: Boolean) = flow { if (exactly) { tags[keyword]?.let { - emit(Pair(it.takeIf { translate }, keyword)) + emit(keyword to it.takeIf { translate }) } } else { if (translate) { tags.forEach { (tag, hint) -> - if (tag != keyword && - (tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword)) - ) { - emit(Pair(hint, tag)) + if (tag != keyword && (tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))) { + emit(tag to hint) } } } else { tags.keys.forEach { tag -> if (tag != keyword && tag.containsIgnoreSpace(keyword)) { - emit(Pair(null, tag)) + emit(tag to null) } } } } } - // Construct a cold flow for tag database suggestions - fun suggestFlow( - keyword: String, - translate: Boolean, - exactly: Boolean = false, - ): Flow> = flow { - var mKeyword = keyword - PREFIXES.forEach { mKeyword = mKeyword.removePrefix(it) } - val prefix = keyword.dropLast(mKeyword.length) - val namespace = mKeyword.substringBefore(':') - val tagKeyword = mKeyword.drop(namespace.length + 1) - val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace - val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }] + context(Context) + fun suggestions(keyword: String, translate: Boolean) = flow { + val doTranslate = translate && isTranslatable(implicit()) + if (initialized) { + emitAll(suggestFlow(keyword, doTranslate, true)) + emitAll(suggestFlow(keyword, doTranslate, false)) + } + } + + private fun suggestFlow(keyword: String, translate: Boolean, exactly: Boolean) = flow { + val kwd = PREFIXES.fold(keyword) { kwd, pfx -> kwd.removePrefix(pfx) } + val prefix = keyword.dropLast(kwd.length) + val ns = kwd.substringBefore(':') + val tag = kwd.drop(ns.length + 1) + val namespacePrefix = TagNamespace.from(ns)?.prefix ?: ns + val tags = tagGroups[namespacePrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }] tags?.let { - internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) -> - emit(Pair(hint, "$prefix$namespacePrefix:$tag")) + internalSuggestFlow(it, tag, translate, exactly).collect { (tag, hint) -> + emit("$prefix$namespacePrefix:$tag" to hint) } } ?: tagGroups.forEach { (namespacePrefix, tags) -> if (namespacePrefix != NAMESPACE_PREFIX) { - internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, tag) -> - emit(Pair(hint, "$prefix$namespacePrefix:$tag")) + internalSuggestFlow(tags, kwd, translate, exactly).collect { (tag, hint) -> + emit("$prefix$namespacePrefix:$tag" to hint) } } else { - internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, namespacePrefix) -> - emit(Pair(hint, "$prefix$namespacePrefix:")) + internalSuggestFlow(tags, kwd, translate, exactly).collect { (ns, hint) -> + emit("$prefix$ns:" to hint) } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt index 7714bc597..36ccdd257 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt @@ -143,19 +143,17 @@ fun SearchBarScreen( } } - fun mergedSuggestionFlow(): Flow = flow { - val query = searchFieldState.text.toString() - suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } } - mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) } - EhTagDatabase.takeIf { it.initialized }?.run { + fun mergedSuggestionFlow(): Flow = with(context) { + flow { + val query = searchFieldState.text.toString() + suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } } + mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) } if (query.isNotEmpty() && !query.endsWith(' ')) { - val keyword = query.substringAfterLast(' ') - val translate = Settings.showTagTranslations && isTranslatable(context) - suggestFlow(keyword, translate, true).collect { - emit(TagSuggestion(it.first, it.second)) - } - suggestFlow(keyword, translate).collect { - emit(TagSuggestion(it.first, it.second)) + EhTagDatabase.suggestions( + query.substringAfterLast(' '), + Settings.showTagTranslations, + ).collect { (tag, hint) -> + emit(TagSuggestion(hint, tag)) } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index 547770758..698ed32f5 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -1,5 +1,6 @@ package com.hippo.ehviewer.ui.tools +import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,10 +33,15 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DisplayMode +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip @@ -43,6 +49,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.RadioButton import androidx.compose.material3.SelectableDates import androidx.compose.material3.ShapeDefaults @@ -70,6 +77,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -83,12 +91,16 @@ import arrow.core.raise.Raise import arrow.core.raise.either import arrow.core.right import com.hippo.ehviewer.R +import com.hippo.ehviewer.client.EhTagDatabase +import com.hippo.ehviewer.client.EhTagDatabase.suggestions import com.jamal.composeprefs3.ui.ifNotNullThen import com.jamal.composeprefs3.ui.ifTrueThen import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -160,9 +172,11 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M ) } - suspend fun awaitSelectTags() = dialog { cont -> + context(Context) + suspend fun awaitSelectTags(): List = dialog { cont -> val selected = remember { mutableStateListOf() } val state = rememberTextFieldState() + var suggestionTranslate by rememberMutableStateInDataStore("SuggestionTranslate") { false } AlertDialog( onDismissRequest = { cont.cancel() }, confirmButton = { @@ -177,7 +191,23 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M content = { Text(text = stringResource(id = android.R.string.cancel)) }, ) }, - title = { Text(text = stringResource(id = R.string.action_add_tag)) }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = stringResource(id = R.string.action_add_tag)) + val context = LocalContext.current + if (EhTagDatabase.isTranslatable(context)) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(id = R.string.translate_tag_for_tagger), + style = MaterialTheme.typography.titleSmall, + ) + Checkbox( + checked = suggestionTranslate, + onCheckedChange = { suggestionTranslate = !suggestionTranslate }, + ) + } + } + }, text = { Column { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -196,27 +226,66 @@ value class DialogState(val field: MutableComposable = mutableStateOf(null)) : M ) } } - OutlinedTextField( - state = state, - label = { Text(text = stringResource(id = R.string.action_add_tag_tip)) }, - trailingIcon = { - IconButton( - onClick = { - val text = state.text.toString().trim() - if (text.isNotEmpty()) { - selected += text - state.clearText() + ExposedDropdownMenuBox( + expanded = true, + onExpandedChange = { }, + ) { + OutlinedTextField( + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + state = state, + label = { Text(text = stringResource(id = R.string.action_add_tag_tip)) }, + trailingIcon = { + IconButton( + onClick = { + val text = state.text.toString().trim() + if (text.isNotEmpty()) { + selected += text + state.clearText() + } + }, + content = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + ) + }, + ) + val query = state.text.toString().trim().takeIf { s -> s.isNotEmpty() } + Await(suggestionTranslate, { query?.let { suggestions(query, suggestionTranslate).take(4).toList() } }) { items -> + if (!items.isNullOrEmpty()) { + ExposedDropdownMenu( + expanded = true, + onDismissRequest = { }, + ) { + items.forEach { (tag, hint) -> + DropdownMenuItem( + text = { + Column { + Text(text = tag, maxLines = 1) + ProvideTextStyle(MaterialTheme.typography.bodySmall) { + if (hint != null) { + Text( + text = hint, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + onClick = { + selected += tag + state.clearText() + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) } - }, - content = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - ) - }, - ) - }, - ) + } + } + } + } } }, ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 27a2cb30c..c92bbabfb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -85,6 +85,7 @@ 点击“+”来添加快速搜索 添加快速搜索 名称为空 + 使用翻译标签 删除 画廊列表的状态将被保存为快速搜索。如果你想保存搜索面板的状态,请先执行搜索。 读我 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2d8e43ec..f5c074014 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,7 @@ Quick search Tap \"+\" to add Quick Search Add Quick Search + Use tag translation Name is empty Delete The state of gallery list will be saved as quick search. Perform a search first to save the state of search panel.