fix:修复词典查询弹窗问题

This commit is contained in:
HapeLee
2026-06-13 12:38:55 +08:00
parent 97627be0da
commit 3833ca1f7c
10 changed files with 351 additions and 407 deletions
@@ -20,7 +20,7 @@ import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet
import io.legado.app.ui.book.read.sheet.CharsetConfigSheet
import io.legado.app.ui.book.read.sheet.ClickActionConfigSheet
import io.legado.app.ui.book.read.sheet.ContentEditSheet
import io.legado.app.ui.book.read.sheet.DictSheet
import io.legado.app.ui.dict.DictSheet
import io.legado.app.ui.book.read.sheet.DownloadSheet
import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet
import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
@@ -1,170 +0,0 @@
package io.legado.app.ui.book.read.sheet
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.legado.app.R
import io.legado.app.data.entities.DictRule
import io.legado.app.help.GlideImageGetter
import io.legado.app.ui.dict.DictViewModel
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.utils.setHtml
import org.koin.androidx.compose.koinViewModel
@Composable
fun DictSheet(
show: Boolean,
word: String,
onDismissRequest: () -> Unit,
viewModel: DictViewModel = koinViewModel(),
) {
val context = LocalContext.current
var dictRules by remember { mutableStateOf<List<DictRule>>(emptyList()) }
var selectedTab by remember { mutableIntStateOf(0) }
var isLoading by remember { mutableStateOf(true) }
var htmlContent by remember { mutableStateOf("") }
var emptyMessage by remember { mutableStateOf<String?>(null) }
var glideImageGetter by remember { mutableStateOf<GlideImageGetter?>(null) }
LaunchedEffect(show) {
if (!show) return@LaunchedEffect
viewModel.initData { rules ->
dictRules = rules
if (rules.isEmpty()) {
emptyMessage = context.getString(R.string.empty)
isLoading = false
} else {
// Auto-select first tab
viewModel.dict(rules[0], word) { result ->
isLoading = false
if (result.isBlank()) {
emptyMessage = "没有查询到结果"
} else {
htmlContent = result
emptyMessage = null
}
}
}
}
}
AppModalBottomSheet(
show = show,
onDismissRequest = {
glideImageGetter?.clear()
glideImageGetter = null
onDismissRequest()
},
title = word,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
) {
// Tab row
if (dictRules.size > 1) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTab.coerceIn(
0,
(dictRules.size - 1).coerceAtLeast(0)
),
modifier = Modifier.fillMaxWidth(),
edgePadding = 0.dp,
) {
dictRules.forEachIndexed { index, rule ->
Tab(
selected = selectedTab == index,
onClick = {
selectedTab = index
isLoading = true
emptyMessage = null
viewModel.dict(rule, word) { result ->
isLoading = false
if (result.isBlank()) {
emptyMessage = "没有查询到结果"
} else {
htmlContent = result
emptyMessage = null
}
}
},
text = { Text(rule.name) },
)
}
}
}
// Content
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
emptyMessage != null -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = emptyMessage!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
AndroidView(
factory = { ctx ->
TextView(ctx).apply {
movementMethod = LinkMovementMethod()
setPadding(32, 16, 32, 16)
}
},
update = { textView ->
textView.setHtml(htmlContent)
glideImageGetter?.clear()
glideImageGetter =
GlideImageGetter.create(context, textView, htmlContent)
textView.setHtml(htmlContent, glideImageGetter)
},
modifier = Modifier
.fillMaxWidth()
,
)
}
}
}
}
}
@@ -0,0 +1,33 @@
package io.legado.app.ui.dict
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
class DictActivity : BaseComposeActivity() {
@Composable
override fun Content() {
DictSheet(
show = true,
word = intent.getStringExtra(EXTRA_WORD).orEmpty(),
onDismissRequest = { finish() },
)
}
companion object {
private const val EXTRA_WORD = "word"
fun startIntent(context: Context, word: String): Intent {
return Intent(context, DictActivity::class.java)
.putExtra(EXTRA_WORD, word)
.apply {
if (context !is Activity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
}
}
@@ -0,0 +1,35 @@
package io.legado.app.ui.dict
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class DictUiState(
val word: String = "",
val isLoading: Boolean = false,
val rules: ImmutableList<DictRuleUi> = persistentListOf(),
val selectedIndex: Int = 0,
val htmlContent: String = "",
val emptyReason: DictEmptyReason? = null,
)
@Stable
data class DictRuleUi(
val name: String,
)
sealed interface DictEmptyReason {
data object BlankWord : DictEmptyReason
data object NoRules : DictEmptyReason
data object NoResult : DictEmptyReason
}
sealed interface DictIntent {
data class Load(val word: String) : DictIntent
data class SelectRule(val index: Int) : DictIntent
}
sealed interface DictEffect {
data class ShowToast(val message: String) : DictEffect
}
@@ -1,123 +0,0 @@
package io.legado.app.ui.dict
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.View
import com.google.android.material.tabs.TabLayout
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.data.entities.DictRule
import io.legado.app.databinding.DialogDictBinding
import io.legado.app.help.GlideImageGetter
import io.legado.app.utils.gone
import io.legado.app.utils.setHtml
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* 词典
*/
class DictDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_dict) {
constructor(word: String) : this() {
arguments = Bundle().apply {
putString("word", word)
}
}
private val viewModel by viewModel<DictViewModel>()
private val binding by viewBinding(DialogDictBinding::bind)
private var word: String? = null
private var glideImageGetter: GlideImageGetter? = null
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.tvDict.movementMethod = LinkMovementMethod()
word = arguments?.getString("word")
if (word.isNullOrEmpty()) {
toastOnUi(R.string.cannot_empty)
dismiss()
return
}
setupTabs()
observeDicts()
}
override fun onDestroyView() {
glideImageGetter?.clear()
glideImageGetter = null
super.onDestroyView()
}
private fun setupTabs() {
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabReselected(tab: TabLayout.Tab) = Unit
override fun onTabUnselected(tab: TabLayout.Tab) = Unit
override fun onTabSelected(tab: TabLayout.Tab) {
val dictRule = tab.tag as DictRule
loadDict(dictRule)
}
})
}
private fun observeDicts() {
viewModel.initData { dictRules ->
if (dictRules.isEmpty()) {
showEmptyView("暂无可用词典")
return@initData
}
binding.emptyMessageView.gone()
dictRules.forEach {
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
text = it.name
tag = it
})
}
setupTabLayoutMode(dictRules.size)
binding.tabLayout.getTabAt(0)?.select()
}
}
private fun loadDict(dictRule: DictRule) {
binding.tvDict.gone()
binding.emptyMessageView.gone()
binding.rotateLoading.visible()
viewModel.dict(dictRule, word!!) { result ->
binding.rotateLoading.gone()
if (result.isBlank()) {
showEmptyView("没有查询到结果")
} else {
binding.tvDict.visible()
binding.tvDict.setHtml(result)
glideImageGetter?.clear()
glideImageGetter = GlideImageGetter.create(requireContext(), binding.tvDict, result)
binding.tvDict.setHtml(result, glideImageGetter)
}
}
}
private fun setupTabLayoutMode(dictCount: Int) {
if (dictCount <= 4) {
binding.tabLayout.tabMode = TabLayout.MODE_FIXED
binding.tabLayout.tabGravity = TabLayout.GRAVITY_FILL
} else {
binding.tabLayout.tabMode = TabLayout.MODE_SCROLLABLE
binding.tabLayout.tabGravity = TabLayout.GRAVITY_CENTER
}
}
private fun showEmptyView(message: String) {
binding.tvDict.gone()
binding.rotateLoading.gone()
binding.emptyMessageView.visible()
binding.emptyMessageView.setMessage(message)
}
}
@@ -0,0 +1,179 @@
package io.legado.app.ui.dict
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.help.GlideImageGetter
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.utils.setHtml
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel
@Composable
fun DictSheet(
show: Boolean,
word: String,
onDismissRequest: () -> Unit,
viewModel: DictViewModel = koinViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(show, word) {
if (show) {
viewModel.onIntent(DictIntent.Load(word))
}
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = word,
) {
DictSheetContent(
state = state,
onIntent = viewModel::onIntent,
effects = viewModel.effects,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
}
}
@Composable
private fun DictSheetContent(
state: DictUiState,
onIntent: (DictIntent) -> Unit,
effects: Flow<DictEffect>,
modifier: Modifier = Modifier,
) {
LaunchedEffect(effects) {
effects.collect { effect ->
when (effect) {
is DictEffect.ShowToast -> Unit
}
}
}
DictContent(
state = state,
onIntent = onIntent,
modifier = modifier,
)
}
@Composable
private fun DictContent(
state: DictUiState,
onIntent: (DictIntent) -> Unit,
modifier: Modifier = Modifier,
) {
val blankWordText = stringResource(R.string.cannot_empty)
val emptyText = stringResource(R.string.empty)
val searchEmptyText = stringResource(R.string.search_empty)
val tabTitles = remember(state.rules) { state.rules.map { it.name } }
val emptyMessage = when (state.emptyReason) {
DictEmptyReason.BlankWord -> blankWordText
DictEmptyReason.NoRules -> emptyText
DictEmptyReason.NoResult -> searchEmptyText
null -> null
}
Column(modifier = modifier) {
if (state.rules.size > 1) {
AppTabRow(
tabTitles = tabTitles,
selectedTabIndex = state.selectedIndex.coerceIn(0, state.rules.lastIndex),
onTabSelected = { index ->
onIntent(DictIntent.SelectRule(index))
},
modifier = Modifier.fillMaxWidth(),
isScrollable = true,
)
}
when {
state.isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center,
) {
AppCircularProgressIndicator()
}
}
emptyMessage != null -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
EmptyMessage(message = emptyMessage)
}
}
state.htmlContent.isNotBlank() -> {
DictHtmlContent(
htmlContent = state.htmlContent,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Composable
private fun DictHtmlContent(
htmlContent: String,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var glideImageGetter by remember { mutableStateOf<GlideImageGetter?>(null) }
DisposableEffect(Unit) {
onDispose {
glideImageGetter?.clear()
glideImageGetter = null
}
}
AndroidView(
factory = { ctx ->
TextView(ctx).apply {
movementMethod = LinkMovementMethod()
setPadding(32, 16, 32, 16)
}
},
update = { textView ->
glideImageGetter?.clear()
glideImageGetter = GlideImageGetter.create(context, textView, htmlContent)
textView.setHtml(htmlContent, glideImageGetter)
},
modifier = modifier,
)
}
@@ -1,40 +1,112 @@
package io.legado.app.ui.dict
import android.app.Application
import io.legado.app.base.BaseViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.DictRule
import io.legado.app.data.repository.DictRuleRepository
import io.legado.app.help.coroutine.Coroutine
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DictViewModel(
application: Application,
private val dictRuleRepository: DictRuleRepository
) : BaseViewModel(application) {
) : ViewModel() {
private var dictJob: Coroutine<String>? = null
private val _uiState = MutableStateFlow(DictUiState())
val uiState = _uiState.asStateFlow()
fun initData(onSuccess: (List<DictRule>) -> Unit) {
execute {
dictRuleRepository.getEnabled()
}.onSuccess {
onSuccess.invoke(it)
private val _effects = MutableSharedFlow<DictEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
private var dictRules: List<DictRule> = emptyList()
private var dictJob: Job? = null
fun onIntent(intent: DictIntent) {
when (intent) {
is DictIntent.Load -> load(intent.word)
is DictIntent.SelectRule -> selectRule(intent.index)
}
}
fun dict(
dictRule: DictRule,
word: String,
onFinally: (String) -> Unit
) {
private fun load(word: String) {
dictJob?.cancel()
dictJob = execute {
dictRule.search(word)
}.onSuccess {
onFinally.invoke(it)
}.onError {
onFinally.invoke(it.localizedMessage ?: "ERROR")
val query = word.trim()
_uiState.value = DictUiState(
word = query,
isLoading = query.isNotBlank(),
emptyReason = if (query.isBlank()) DictEmptyReason.BlankWord else null,
)
if (query.isBlank()) return
viewModelScope.launch {
dictRules = withContext(Dispatchers.IO) {
dictRuleRepository.getEnabled()
}
_uiState.update { state ->
state.copy(
rules = dictRules.map { DictRuleUi(it.name) }.toImmutableList(),
isLoading = dictRules.isNotEmpty(),
emptyReason = if (dictRules.isEmpty()) DictEmptyReason.NoRules else null,
)
}
if (dictRules.isNotEmpty()) {
searchRule(index = 0, word = query)
}
}
}
private fun selectRule(index: Int) {
val state = _uiState.value
if (index !in dictRules.indices || state.selectedIndex == index && state.isLoading) return
searchRule(index = index, word = state.word)
}
private fun searchRule(index: Int, word: String) {
dictJob?.cancel()
val rule = dictRules.getOrNull(index) ?: return
_uiState.update {
it.copy(
selectedIndex = index,
isLoading = true,
htmlContent = "",
emptyReason = null,
)
}
dictJob = viewModelScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
rule.search(word)
}
}.getOrElse { error ->
error.localizedMessage ?: "ERROR"
}
_uiState.update {
if (result.isBlank()) {
it.copy(
isLoading = false,
htmlContent = "",
emptyReason = DictEmptyReason.NoResult,
)
} else {
it.copy(
isLoading = false,
htmlContent = result,
emptyReason = null,
)
}
}
}
}
override fun onCleared() {
dictJob?.cancel()
super.onCleared()
}
}
@@ -10,14 +10,13 @@ import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import io.legado.app.R
import io.legado.app.ui.dict.DictDialog
import io.legado.app.ui.dict.DictActivity
import io.legado.app.utils.toastOnUi
@SuppressLint("SetJavaScriptEnabled")
@@ -83,7 +82,7 @@ class VisibleWebView(
postDelayed({
getSelectedText { selectedText ->
if (selectedText.isNotBlank()) {
showDictDialog(selectedText)
showDictSheet(selectedText)
} else {
context.toastOnUi("未获取到选中文本,请重试")
}
@@ -124,12 +123,8 @@ class VisibleWebView(
}
}
private fun showDictDialog(selectedText: String) {
val activity = context as? AppCompatActivity ?: return
val dialog = DictDialog(selectedText)
activity.supportFragmentManager.beginTransaction()
.add(dialog, "DictDialog")
.commitAllowingStateLoss()
private fun showDictSheet(selectedText: String) {
context.startActivity(DictActivity.startIntent(context, selectedText))
}
companion object {