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 @@ - - - - - - - - - - - - - - - - - - - - - - -