From a191d80e02a5c9c3595b870ac0fe4f3f9f08d480 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:27:46 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=BA=90=E8=A7=86=E9=A2=91=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=92=8C=E6=96=B0=E5=A2=9E=E6=BA=90=E7=BC=96?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/base/BaseRuleViewModel.kt | 13 +- .../app/ui/book/toc/rule/TxtRuleScreen.kt | 1 + .../legado/app/ui/dict/rule/DictRuleScreen.kt | 1 + .../app/ui/replace/ReplaceRuleScreen.kt | 1 + .../app/ui/rss/article/RssArticlesCompose.kt | 12 +- .../app/ui/rss/article/RssSortRouteScreen.kt | 3 +- .../app/ui/rss/article/RssSortScreen.kt | 9 +- .../app/ui/rss/read/RssReadRouteScreen.kt | 362 +++++++++++------- .../app/ui/rss/read/RssReadWebController.kt | 27 +- .../legado/app/ui/rss/read/VisibleWebView.kt | 26 +- .../ui/rss/source/manage/RssSourceScreen.kt | 9 +- .../importComponents/ImportComponents.kt | 333 ++++++++++------ .../importComponents/ImportState.kt | 3 +- 13 files changed, 515 insertions(+), 285 deletions(-) diff --git a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt index 4181f0791..0b0263415 100644 --- a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt @@ -301,4 +301,15 @@ abstract class BaseRuleViewModel, Entity, ID, S : ListUiS _importState.value = currentState.copy(items = newItems) } -} \ No newline at end of file + fun updateImportItem(index: Int, data: Entity) { + val currentState = _importState.value as? BaseImportUiState.Success ?: return + if (index !in currentState.items.indices) return + val newItems = currentState.items.toMutableList() + newItems[index] = newItems[index].copy(data = data) + _importState.value = currentState.copy( + items = newItems, + version = currentState.version + 1 + ) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt index db7d49be9..4e81910ee 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt @@ -191,6 +191,7 @@ fun TxtRuleScreen( onDismissRequest = { viewModel.cancelImport() }, onToggleItem = { viewModel.toggleImportSelection(it) }, onToggleAll = { viewModel.toggleImportAll(it) }, + onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) }, onConfirm = { viewModel.saveImportedRules() }, itemTitle = { rule -> rule.name }, itemSubtitle = { rule -> diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt index 22c41a364..89aefe0fb 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt @@ -182,6 +182,7 @@ fun DictRuleScreen( onDismissRequest = { viewModel.cancelImport() }, onToggleItem = { viewModel.toggleImportSelection(it) }, onToggleAll = { viewModel.toggleImportAll(it) }, + onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) }, onConfirm = { viewModel.saveImportedRules() }, itemTitle = { rule -> rule.name }, itemSubtitle = { rule -> diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 82fc93ff0..3aded58b6 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -188,6 +188,7 @@ fun ReplaceRuleScreen( onDismissRequest = { viewModel.cancelImport() }, onToggleItem = { viewModel.toggleImportSelection(it) }, onToggleAll = { viewModel.toggleImportAll(it) }, + onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) }, onConfirm = { viewModel.saveImportedRules() }, topBarActions = {}, itemTitle = { rule -> rule.name }, diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt index 8d477e57e..03be5cf2b 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt @@ -59,6 +59,7 @@ import io.legado.app.data.appDb import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssSource import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.cover.buildCoverImageRequest @@ -92,6 +93,7 @@ fun RssArticlesPage( rssSource: RssSource?, viewModel: RssArticlesViewModel, onRead: (RssArticle) -> Unit, + paddingValues: PaddingValues = PaddingValues(0.dp), modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -123,6 +125,10 @@ fun RssArticlesPage( } val refreshState = rememberPullToRefreshState() + val contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ) PullToRefreshBox( isRefreshing = loadState.isRefreshing, @@ -141,7 +147,7 @@ fun RssArticlesPage( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp), + contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( @@ -174,7 +180,7 @@ fun RssArticlesPage( columns = GridCells.Fixed(2), state = gridState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp), + contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -208,7 +214,7 @@ fun RssArticlesPage( columns = StaggeredGridCells.Fixed(2), state = staggeredState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp), + contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalItemSpacing = 8.dp ) { diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt index 4ec799923..595ef3e77 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssSortRouteScreen.kt @@ -156,7 +156,7 @@ fun RssSortRouteScreen( } context.toastOnUi("重定向策略已更新") }, - pagerContent = { _, sort -> + pagerContent = { _, sort, paddingValues -> val pageViewModel: RssArticlesViewModel = koinViewModel( key = "rss_${viewModel.url}_${sort.first}_${sort.second}" ) @@ -167,6 +167,7 @@ fun RssSortRouteScreen( rssUrl = viewModel.url, rssSource = viewModel.rssSource, viewModel = pageViewModel, + paddingValues = paddingValues, onRead = { article -> viewModel.read(article) onOpenRead(article.title, article.origin, article.link, null) diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt index 8042c7158..bce6868c3 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt @@ -89,7 +89,7 @@ fun RssSortScreen( onOpenReadRecord: (RssReadRecord) -> Unit, onClearArticles: () -> Unit, onRedirectPolicyChanged: (RedirectPolicy) -> Unit, - pagerContent: @Composable (index: Int, item: Pair) -> Unit + pagerContent: @Composable (index: Int, item: Pair, paddingValues: PaddingValues) -> Unit ) { val scope = rememberCoroutineScope() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() @@ -291,12 +291,10 @@ fun RssSortScreen( HorizontalPager( state = pagerState, contentPadding = PaddingValues(bottom = 0.dp), - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = Modifier.fillMaxSize() ) { page -> val item = sortList.getOrNull(page) ?: return@HorizontalPager - pagerContent(page, item) + pagerContent(page, item, paddingValues) } } } @@ -370,4 +368,3 @@ private fun RssReadRecordSheet( } } } - diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt index 18317437e..b3ef11e90 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.net.http.SslError import android.os.SystemClock import android.view.View +import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler import android.webkit.URLUtil @@ -19,10 +20,13 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons @@ -43,17 +47,22 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -61,6 +70,7 @@ import com.script.rhino.runScriptWithContext import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog +import io.legado.app.help.config.AppConfig import io.legado.app.help.http.CookieManager import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.selector @@ -69,6 +79,7 @@ import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.button.SmallIconButton @@ -87,6 +98,7 @@ import io.legado.app.utils.setDarkeningAllowed import io.legado.app.utils.share import io.legado.app.utils.startActivity import io.legado.app.utils.toastOnUi +import io.legado.app.utils.toggleSystemBar import org.apache.commons.text.StringEscapeUtils import org.jsoup.Jsoup import java.net.URLDecoder @@ -115,6 +127,10 @@ fun RssReadRouteScreen( var showMenu by remember { mutableStateOf(false) } var showRedirectMenu by remember { mutableStateOf(false) } var webView by remember { mutableStateOf(null) } + var customView by remember { mutableStateOf(null) } + var customViewCallback by remember { + mutableStateOf(null) + } var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) } @@ -128,6 +144,15 @@ fun RssReadRouteScreen( val isSpeaking by viewModel.isSpeakingState.collectAsStateWithLifecycle() val fallbackUserAgent = OtherConfig.userAgent + fun hideCustomView() { + val currentCustomView = customView ?: return + (currentCustomView.parent as? ViewGroup)?.removeView(currentCustomView) + customView = null + customViewCallback = null + activity?.keepScreenOn(false) + activity?.toggleSystemBar(AppConfig.showStatusBar) + } + LaunchedEffect(origin, link, openUrl, title) { viewModel.initData( ReadRssArgs( @@ -167,169 +192,207 @@ fun RssReadRouteScreen( BackHandler { when { + customView != null -> customViewCallback?.onCustomViewHidden() ?: hideCustomView() webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size ?: 0) > 1 -> webView?.goBack() else -> onBackClick() } } - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = pageTitle.ifBlank { defaultTopBarTitle }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - TopBarNavigationButton(onClick = onBackClick) - }, - actions = { - TopBarActionButton( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.refresh), - onClick = { viewModel.refresh { webView?.reload() } } - ) - TopBarActionButton( - imageVector = Icons.Default.Star, - contentDescription = stringResource(R.string.favorite), - onClick = { - viewModel.addFavorite() - favoriteTitle = viewModel.rssArticle?.title.orEmpty() - favoriteGroup = viewModel.rssArticle?.group.orEmpty() - showFavoriteSheet = true - } - ) - Box { - TopBarActionButton( - imageVector = Icons.Default.MoreVert, - contentDescription = "Menu", - onClick = { showMenu = true } + DisposableEffect(Unit) { + onDispose { + customViewCallback?.onCustomViewHidden() + hideCustomView() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = pageTitle.ifBlank { defaultTopBarTitle }, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - RoundDropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { dismiss -> - RoundDropdownMenuItem( - text = stringResource(R.string.share), - leadingIcon = { MenuItemIcon(Icons.Default.Share) }, - onClick = { - dismiss() - webView?.url?.let { - context.share(it) - } ?: viewModel.rssArticle?.let { - context.share(it.link) - } ?: context.toastOnUi(R.string.null_url) - } + }, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + }, + actions = { + TopBarActionButton( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.refresh), + onClick = { viewModel.refresh { webView?.reload() } } + ) + TopBarActionButton( + imageVector = Icons.Default.Star, + contentDescription = stringResource(R.string.favorite), + onClick = { + viewModel.addFavorite() + favoriteTitle = viewModel.rssArticle?.title.orEmpty() + favoriteGroup = viewModel.rssArticle?.group.orEmpty() + showFavoriteSheet = true + } + ) + Box { + TopBarActionButton( + imageVector = Icons.Default.MoreVert, + contentDescription = "Menu", + onClick = { showMenu = true } ) - RoundDropdownMenuItem( - text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource( - R.string.read_aloud - ), - leadingIcon = { - MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp) - }, - onClick = { - dismiss() - if (isSpeaking) { - viewModel.stopReadAloud() - } else { - webView?.evaluateJavascript("document.documentElement.outerHTML") { - val html = StringEscapeUtils.unescapeJson(it) - .replace("^\"|\"$".toRegex(), "") - viewModel.readAloud( - Jsoup.parse(html).text() - ) - } - } - } - ) - if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) { + RoundDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { dismiss -> RoundDropdownMenuItem( - text = stringResource(R.string.login), - leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) }, + text = stringResource(R.string.share), + leadingIcon = { MenuItemIcon(Icons.Default.Share) }, onClick = { dismiss() - context.startActivity { - putExtra("type", "rssSource") - putExtra("key", viewModel.rssSource?.sourceUrl) + webView?.url?.let { + context.share(it) + } ?: viewModel.rssArticle?.let { + context.share(it.link) + } ?: context.toastOnUi(R.string.null_url) + } + ) + RoundDropdownMenuItem( + text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource( + R.string.read_aloud + ), + leadingIcon = { + MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp) + }, + onClick = { + dismiss() + if (isSpeaking) { + viewModel.stopReadAloud() + } else { + webView?.evaluateJavascript("document.documentElement.outerHTML") { + val html = StringEscapeUtils.unescapeJson(it) + .replace("^\"|\"$".toRegex(), "") + viewModel.readAloud( + Jsoup.parse(html).text() + ) + } } } ) + if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) { + RoundDropdownMenuItem( + text = stringResource(R.string.login), + leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) }, + onClick = { + dismiss() + context.startActivity { + putExtra("type", "rssSource") + putExtra("key", viewModel.rssSource?.sourceUrl) + } + } + ) + } + RoundDropdownMenuItem( + text = stringResource(R.string.redirect_policy), + onClick = { showRedirectMenu = true } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.open_in_browser), + leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) }, + onClick = { + dismiss() + webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null") + } + ) } - RoundDropdownMenuItem( - text = stringResource(R.string.redirect_policy), - onClick = { showRedirectMenu = true } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.open_in_browser), - leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) }, - onClick = { - dismiss() - webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null") + RoundDropdownMenu( + expanded = showRedirectMenu, + onDismissRequest = { showRedirectMenu = false } + ) { dismiss -> + RedirectPolicy.entries.forEach { policy -> + RoundDropdownMenuItem( + text = policy.title(), + onClick = { + dismiss() + showMenu = false + viewModel.rssSource?.let { source -> + viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name) + redirectPolicy = policy + } + context.toastOnUi("重定向策略已更新") + }, + trailingIcon = { + if (policy == redirectPolicy) { + Icon(Icons.Default.Check, null) + } + } + ) } - ) - } - RoundDropdownMenu( - expanded = showRedirectMenu, - onDismissRequest = { showRedirectMenu = false } - ) { dismiss -> - RedirectPolicy.entries.forEach { policy -> - RoundDropdownMenuItem( - text = policy.title(), - onClick = { - dismiss() - showMenu = false - viewModel.rssSource?.let { source -> - viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name) - redirectPolicy = policy - } - context.toastOnUi("重定向策略已更新") - }, - trailingIcon = { - if (policy == redirectPolicy) { - Icon(Icons.Default.Check, null) - } - } - ) } } } - } - ) - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - VisibleWebViewCompose( - modifier = Modifier.fillMaxSize(), - onCreated = { createdWebView -> - webView = createdWebView - configureRssReadWebView( - webView = createdWebView, - context = context, - activity = activity, - appCompatActivity = appCompatActivity, - viewModel = viewModel, - initialTitle = title, - redirectPolicyProvider = { redirectPolicy }, - callbacks = RssReadWebControllerCallbacks( - onProgressChanged = { webProgress = it }, - onPageTitleResolved = { resolved -> - pageTitle = resolved.ifBlank { defaultTopBarTitle } - } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + VisibleWebViewCompose( + modifier = Modifier.fillMaxSize(), + onCreated = { createdWebView -> + webView = createdWebView + configureRssReadWebView( + webView = createdWebView, + context = context, + activity = activity, + appCompatActivity = appCompatActivity, + viewModel = viewModel, + initialTitle = title, + redirectPolicyProvider = { redirectPolicy }, + callbacks = RssReadWebControllerCallbacks( + onProgressChanged = { webProgress = it }, + onPageTitleResolved = { resolved -> + pageTitle = resolved.ifBlank { defaultTopBarTitle } + }, + onShowCustomView = { view, callback -> + if (view == null) { + callback?.onCustomViewHidden() + } else if (customView != null) { + callback?.onCustomViewHidden() + } else { + customView = view + customViewCallback = callback + activity?.keepScreenOn(true) + activity?.toggleSystemBar(false) + } + }, + onHideCustomView = { hideCustomView() } + ) ) + } + ) + if (webProgress in 0..99) { + LinearProgressIndicator( + progress = { webProgress / 100f }, + modifier = Modifier.fillMaxWidth() ) } - ) - if (webProgress in 0..99) { - LinearProgressIndicator( - progress = { webProgress / 100f }, - modifier = Modifier.fillMaxWidth() + } + } + + customView?.let { view -> + key(view) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .zIndex(1f), + factory = { + (view.parent as? ViewGroup)?.removeView(view) + view + } ) } } @@ -377,7 +440,7 @@ private fun FavoriteEditSheet( } ) { - Column(modifier = Modifier.padding(12.dp)) { + Column { AppTextField( value = titleValue, onValueChange = onTitleChange, @@ -396,11 +459,14 @@ private fun FavoriteEditSheet( ) } - RoundDropdownMenuItem( - text = stringResource(R.string.action_save), - leadingIcon = { MenuItemIcon(Icons.Default.Check) }, - onClick = onSave, - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp) + Spacer(modifier = Modifier.height(16.dp)) + + ConfirmDismissButtonsRow( + modifier = Modifier.fillMaxWidth(), + onDismiss = onDismissRequest, + onConfirm = onSave, + dismissText = stringResource(R.string.cancel), + confirmText = stringResource(R.string.action_save) ) } } diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt index a79c3c219..69cb0f174 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt +++ b/app/src/main/java/io/legado/app/ui/rss/read/RssReadWebController.kt @@ -5,6 +5,7 @@ package io.legado.app.ui.rss.read import android.annotation.SuppressLint import android.app.Activity import android.content.Context +import android.graphics.Bitmap import android.net.Uri import android.net.http.SslError import android.os.SystemClock @@ -19,10 +20,12 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import com.script.rhino.runScriptWithContext import io.legado.app.R +import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.SelectItem @@ -39,7 +42,9 @@ import java.net.URLDecoder internal data class RssReadWebControllerCallbacks( val onProgressChanged: (Int) -> Unit, - val onPageTitleResolved: (String) -> Unit + val onPageTitleResolved: (String) -> Unit, + val onShowCustomView: (View?, WebChromeClient.CustomViewCallback?) -> Unit, + val onHideCustomView: () -> Unit ) @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") @@ -54,9 +59,21 @@ internal fun configureRssReadWebView( callbacks: RssReadWebControllerCallbacks ) { webView.webChromeClient = object : WebChromeClient() { + override fun getDefaultVideoPoster(): Bitmap { + return super.getDefaultVideoPoster() ?: createBitmap(100, 100) + } + override fun onProgressChanged(view: WebView?, newProgress: Int) { callbacks.onProgressChanged(newProgress) } + + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + callbacks.onShowCustomView(view, callback) + } + + override fun onHideCustomView() { + callbacks.onHideCustomView() + } } webView.webViewClient = object : WebViewClient() { @@ -218,13 +235,17 @@ internal fun configureRssReadWebView( webView.settings.apply { mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false allowContentAccess = true builtInZoomControls = true displayZoomControls = false + textZoom = 100 setDarkeningAllowed(AppConfig.isNightTheme) - if (viewModel.rssSource?.enableJs == true) { - javaScriptEnabled = true + userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: AppConfig.userAgent + viewModel.rssSource?.let { source -> + javaScriptEnabled = source.enableJs } + cacheMode = WebSettings.LOAD_DEFAULT } webView.addJavascriptInterface(object { 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 44334c885..937fbd054 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 @@ -6,8 +6,10 @@ import android.util.AttributeSet import android.view.ActionMode import android.view.Menu import android.view.MenuItem +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 @@ -144,19 +146,31 @@ fun VisibleWebViewCompose( AndroidView( modifier = modifier, factory = { context -> - VisibleWebView(context).also { - webViewHolder.webView = it - onCreated(it) + FrameLayout(context).apply { + clipChildren = false + clipToPadding = false + val webView = VisibleWebView(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + addView(webView) + webViewHolder.webView = webView + onCreated(webView) } }, - update = { - webViewHolder.webView = it + update = { container -> + webViewHolder.webView = container.getChildAt(0) as? VisibleWebView } ) DisposableEffect(Unit) { onDispose { onDestroyed?.invoke() - webViewHolder.webView?.destroy() + webViewHolder.webView?.let { webView -> + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + } webViewHolder.webView = null } } diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt index fc7113ffd..993dc281b 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt @@ -40,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.base.BaseRuleEvent import io.legado.app.data.entities.RssSource +import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.DraggableSelectionHandler import io.legado.app.ui.widget.components.GroupManageBottomSheet @@ -55,7 +56,6 @@ import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.rules.RuleListScaffold -import io.legado.app.ui.widget.components.text.AppText import org.koin.androidx.compose.koinViewModel import sh.calvin.reorderable.rememberReorderableLazyListState @@ -200,6 +200,7 @@ fun RssSourceScreen( onDismissRequest = { viewModel.cancelImport() }, onToggleItem = { viewModel.toggleImportSelection(it) }, onToggleAll = { viewModel.toggleImportAll(it) }, + onUpdateItem = { index, source -> viewModel.updateImportItem(index, source) }, onConfirm = { viewModel.saveImportedRules() }, itemTitle = { rule -> rule.sourceName }, itemSubtitle = { rule -> @@ -344,9 +345,9 @@ fun RssSourceScreen( FastScrollLazyColumn( modifier = Modifier.fillMaxSize(), state = listState, - contentPadding = PaddingValues( - top = paddingValues.calculateTopPadding() + 8.dp, - bottom = paddingValues.calculateBottomPadding() + 80.dp + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 120.dp ), verticalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index e347208ce..156579ae0 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.importComponents import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,43 +18,38 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material3.AssistChip -import androidx.compose.material3.Button -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedToggleButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet -import io.legado.app.ui.widget.components.text.AnimatedText +import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem import io.legado.app.ui.widget.components.text.AppText -import kotlinx.coroutines.launch +import io.legado.app.utils.GSON @Composable fun SourceInputDialog( @@ -117,6 +113,7 @@ fun BatchImportDialog( onToggleItem: (index: Int) -> Unit, onToggleAll: (isSelected: Boolean) -> Unit, onItemInfoClick: (index: Int) -> Unit = {}, + onUpdateItem: (index: Int, data: T) -> Unit = { _, _ -> }, topBarActions: @Composable RowScope.() -> Unit = {}, itemTitle: (data: T) -> String, itemSubtitle: (data: T) -> String? = { null } @@ -130,81 +127,187 @@ fun BatchImportDialog( if (!show && cachedState == null) return - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - val currentState = cachedState!! + var editingIndex by remember(currentState.source) { mutableStateOf(null) } + val editingItem = editingIndex?.let { currentState.items.getOrNull(it) } + val isEditing = editingItem != null val selectedCount = currentState.items.count { it.isSelected } val totalCount = currentState.items.size + val allSelected = selectedCount == totalCount + val sheetTitle = when { + isEditing -> itemTitle(editingItem.data) + selectedCount > 0 -> { + stringResource( + R.string.select_count, + selectedCount, + totalCount + ) + } + + else -> title + } AppModalBottomSheet( show = show, - onDismissRequest = onDismissRequest - ) { - AppScaffold( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f), - topBar = { - CenterAlignedTopAppBar( - title = { - AnimatedText( - if (selectedCount > 0) - stringResource( - R.string.select_count, - selectedCount, - totalCount - ) - else - title - ) - }, - actions = topBarActions, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - bottomBar = { - ImportBottomBar( - selectedCount = selectedCount, - totalCount = totalCount, - onToggleSelectAll = onToggleAll, - onConfirm = { - val selectedData = - currentState.items.filter { it.isSelected }.map { it.data } - onConfirm(selectedData) - }, - onCancel = onDismissRequest + onDismissRequest = onDismissRequest, + modifier = Modifier.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f), + title = sheetTitle, + startAction = if (isEditing) { + { + SmallIconButton( + onClick = { editingIndex = null }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" ) } - ) { padding -> - LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed( - currentState.items, - key = { _, item -> item.data.hashCode() }) { index, itemWrapper -> - ImportItemRow( - title = itemTitle(itemWrapper.data), - subtitle = itemSubtitle(itemWrapper.data), - isSelected = itemWrapper.isSelected, - status = itemWrapper.status, - onClick = { onToggleItem(index) }, - onInfoClick = { - scope.launch { snackbarHostState.showSnackbar("其实还没写桀桀桀") } - onItemInfoClick(index) - } + } else { + null + }, + endAction = if (!isEditing) { + { + Row { + topBarActions() + SmallIconButton( + onClick = { onToggleAll(!allSelected) }, + imageVector = Icons.Default.SelectAll, + contentDescription = if (allSelected) "全不选" else "全选" ) } } + } else { + null + } + ) { + if (isEditing) { + BatchImportJsonEditContent( + data = editingItem.data, + version = currentState.version + ) { data -> + editingIndex?.let { onUpdateItem(it, data) } + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.58f) + ) { + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + currentState.items, + key = { _, item -> item.data.hashCode() } + ) { index, itemWrapper -> + ImportItemRow( + title = itemTitle(itemWrapper.data), + subtitle = itemSubtitle(itemWrapper.data), + isSelected = itemWrapper.isSelected, + status = itemWrapper.status, + onClick = { onToggleItem(index) }, + onInfoClick = { + onItemInfoClick(index) + editingIndex = index + } + ) + } + } + } + } + + ConfirmDismissButtonsRow( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(top = 8.dp, bottom = 8.dp), + onDismiss = onDismissRequest, + onConfirm = { + val selectedData = currentState.items.filter { it.isSelected }.map { it.data } + onConfirm(selectedData) + }, + dismissText = "取消", + confirmText = "导入", + confirmEnabled = selectedCount > 0 + ) + } +} + +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +private fun BatchImportJsonEditContent( + data: T, + version: Int, + onDataChange: (T) -> Unit +) { + val jsonObject = remember(version) { data.toImportJsonObject() } + + if (jsonObject == null) { + AppText("不支持编辑") + return + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.58f), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = jsonObject.entrySet().toList(), + key = { it.key } + ) { entry -> + BatchImportJsonField( + name = entry.key, + value = entry.value, + onValueChange = { value -> + val updatedJsonObject = data.toImportJsonObject() ?: return@BatchImportJsonField + updatedJsonObject.add(entry.key, value) + updatedJsonObject.toImportDataLike(data)?.let(onDataChange) + } + ) } } } +@Composable +private fun BatchImportJsonField( + name: String, + value: JsonElement, + onValueChange: (JsonElement) -> Unit +) { + val primitive = value.takeIf { it.isJsonPrimitive }?.asJsonPrimitive + if (primitive?.isBoolean == true) { + GlassCard( + containerColor = LegadoTheme.colorScheme.onSheetContent + ) { + SwitchSettingItem( + title = name, + checked = primitive.asBoolean, + onCheckedChange = { onValueChange(JsonPrimitive(it)) } + ) + } + + return + } + + val isJsonText = value.isJsonObject || value.isJsonArray + val initialText = value.toImportEditText() + var text by remember(name, initialText) { mutableStateOf(initialText) } + + AppTextField( + value = text, + onValueChange = { newText -> + text = newText + newText.toImportJsonElement(value)?.let(onValueChange) + }, + label = name, + modifier = Modifier.fillMaxWidth(), + singleLine = !isJsonText, + maxLines = if (isJsonText) 8 else 1 + ) +} + @Composable fun ImportItemRow( title: String, @@ -220,7 +323,7 @@ fun ImportItemRow( isSelected = isSelected, inSelectionMode = true, onToggleSelection = onClick, - modifier = Modifier.padding(vertical = 4.dp), + containerColor = LegadoTheme.colorScheme.onSheetContent, trailingAction = { AppText( text = when (status) { @@ -248,43 +351,49 @@ fun ImportItemRow( ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun ImportBottomBar( - selectedCount: Int, - totalCount: Int, - onToggleSelectAll: (Boolean) -> Unit, - onConfirm: () -> Unit, - onCancel: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val allSelected = selectedCount == totalCount - OutlinedToggleButton( - checked = allSelected, - onCheckedChange = { checked -> - onToggleSelectAll(checked) - }, - ) { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = if (allSelected) "全不选" else "全选" - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onCancel) { AppText("取消") } - Button( - enabled = selectedCount > 0, - onClick = onConfirm - ) { - AppText("导入") +private fun Any?.toImportJsonObject(): JsonObject? { + return GSON.toJsonTree(this).takeIf { it.isJsonObject }?.asJsonObject +} + +@Suppress("UNCHECKED_CAST") +private fun JsonObject.toImportDataLike(data: T): T? { + val clazz = data?.let { it::class.java } ?: return null + return runCatching { GSON.fromJson(this, clazz) as T }.getOrNull() +} + +private fun JsonElement.toImportEditText(): String { + return when { + this is JsonNull || isJsonNull -> "" + isJsonObject || isJsonArray -> GSON.toJson(this) + isJsonPrimitive -> asJsonPrimitive.asString + else -> toString() + } +} + +private fun String.toImportJsonElement(oldValue: JsonElement): JsonElement? { + val text = trim() + if (oldValue.isJsonNull) { + return if (text.isEmpty()) JsonNull.INSTANCE else JsonPrimitive(this) + } + + if (oldValue.isJsonObject || oldValue.isJsonArray) { + if (text.isEmpty()) return JsonNull.INSTANCE + return runCatching { JsonParser.parseString(this) }.getOrNull() + } + + if (!oldValue.isJsonPrimitive) return JsonPrimitive(this) + + val primitive = oldValue.asJsonPrimitive + return when { + primitive.isNumber -> { + if (text.isEmpty()) { + JsonNull.INSTANCE + } else { + text.toLongOrNull()?.let { JsonPrimitive(it) } + ?: text.toDoubleOrNull()?.let { JsonPrimitive(it) } } } + + else -> JsonPrimitive(this) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt index f8f6673a9..21ac531f1 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportState.kt @@ -24,9 +24,10 @@ sealed interface BaseImportUiState { data class Success( val source: String, val items: List>, + val version: Int = 0, // 导入配置项 val keepOriginalName: Boolean = false, val customGroup: String? = null, val isAddGroup: Boolean = false ) : BaseImportUiState -} \ No newline at end of file +}