diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 11f98360c..dce6f9b6c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -263,6 +263,11 @@
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" />
+
Unit,
- viewModel: DictViewModel = koinViewModel(),
-) {
- val context = LocalContext.current
- var dictRules by remember { mutableStateOf>(emptyList()) }
- var selectedTab by remember { mutableIntStateOf(0) }
- var isLoading by remember { mutableStateOf(true) }
- var htmlContent by remember { mutableStateOf("") }
- var emptyMessage by remember { mutableStateOf(null) }
- var glideImageGetter by remember { mutableStateOf(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()
-,
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/io/legado/app/ui/dict/DictActivity.kt b/app/src/main/java/io/legado/app/ui/dict/DictActivity.kt
new file mode 100644
index 000000000..6ceb7a201
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/dict/DictActivity.kt
@@ -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)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/dict/DictContract.kt b/app/src/main/java/io/legado/app/ui/dict/DictContract.kt
new file mode 100644
index 000000000..61b3df729
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/dict/DictContract.kt
@@ -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 = 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
+}
diff --git a/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt b/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt
deleted file mode 100644
index 6c37137b1..000000000
--- a/app/src/main/java/io/legado/app/ui/dict/DictDialog.kt
+++ /dev/null
@@ -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()
- 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)
- }
-}
diff --git a/app/src/main/java/io/legado/app/ui/dict/DictSheet.kt b/app/src/main/java/io/legado/app/ui/dict/DictSheet.kt
new file mode 100644
index 000000000..387c0858f
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/dict/DictSheet.kt
@@ -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,
+ 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(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,
+ )
+}
diff --git a/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt b/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt
index cf4101ce1..6b6b98858 100644
--- a/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/dict/DictViewModel.kt
@@ -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? = null
+ private val _uiState = MutableStateFlow(DictUiState())
+ val uiState = _uiState.asStateFlow()
- fun initData(onSuccess: (List) -> Unit) {
- execute {
- dictRuleRepository.getEnabled()
- }.onSuccess {
- onSuccess.invoke(it)
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 16)
+ val effects = _effects.asSharedFlow()
+
+ private var dictRules: List = 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()
+ }
}
diff --git a/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt b/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt
index 353c4c21f..a76ae3427 100644
--- a/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt
+++ b/app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt
@@ -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 {
diff --git a/app/src/main/res/layout/dialog_dict.xml b/app/src/main/res/layout/dialog_dict.xml
deleted file mode 100644
index 041928fce..000000000
--- a/app/src/main/res/layout/dialog_dict.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-