From c10714f2f76334d53adcfefbe6cfd5c6435c9b2b Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:53:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/data/dao/BookSourceDao.kt | 9 +- .../data/repository/BookSourceRepository.kt | 4 + .../app/help/config/ThemeImportExport.kt | 3 + .../app/ui/book/toc/rule/TxtRuleScreen.kt | 19 +- .../java/io/legado/app/ui/main/MainScreen.kt | 58 ++-- .../io/legado/app/ui/main/home/HomeScreen.kt | 1 + .../app/ui/main/homepage/HomepageViewModel.kt | 40 ++- .../io/legado/app/ui/main/rss/RssScreen.kt | 251 +++++++++++------- .../app/ui/rss/read/RssReadRouteScreen.kt | 43 ++- .../ui/widget/components/FloatingBottomBar.kt | 13 +- .../widget/components/SelectionBottomBar.kt | 26 +- .../components/image/sourceIcon/SourceIcon.kt | 19 +- .../ui/widget/components/log/AppLogSheet.kt | 109 +------- 13 files changed, 359 insertions(+), 236 deletions(-) diff --git a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt index 28f8c9321..a7a6a81fc 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt @@ -96,6 +96,13 @@ interface BookSourceDao { ) fun flowExploreSources(): Flow> + @Query( + """select * from book_sources_part + where enabled = 1 and enabledExplore = 1 + order by customOrder asc""" + ) + fun flowExploreSourceParts(): Flow> + @Query( """select * from book_sources_part where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc""" @@ -393,4 +400,4 @@ interface BookSourceDao { dealGroups(list) }.flowOn(IO) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt index c3fa08406..ce01cdce4 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt @@ -25,6 +25,10 @@ class BookSourceRepository(private val bookSourceDao: BookSourceDao) { return bookSourceDao.flowExploreSources() } + fun flowExploreSourceParts(): Flow> { + return bookSourceDao.flowExploreSourceParts() + } + suspend fun getBookSource(sourceUrl: String): BookSource? { return withContext(Dispatchers.IO) { bookSourceDao.getBookSource(sourceUrl) diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 9be3c83fc..25b71bc14 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -2,6 +2,7 @@ package io.legado.app.help.config import android.content.Context import android.net.Uri +import androidx.annotation.Keep import com.google.gson.GsonBuilder import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.themeConfig.ThemeConfig @@ -530,6 +531,7 @@ object ThemeImportExport { /** * 主题导出数据类 */ +@Keep data class ThemeExportData( // 基础主题设置 val appTheme: String = "0", @@ -639,6 +641,7 @@ data class ThemeExportData( /** * 已保存的主题 */ +@Keep data class SavedTheme( val name: String, val data: ThemeExportData 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 6032e7f2a..e2148251f 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 @@ -41,14 +41,15 @@ import io.legado.app.ui.widget.components.button.series.SmallPlainButton import io.legado.app.ui.widget.components.card.ReorderableSelectionItem import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.icon.AppIcons -import io.legado.app.ui.widget.components.importComponents.BatchImportDialog import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.importComponents.BatchImportDialog import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.rules.RuleEditFields import io.legado.app.ui.widget.components.rules.RuleEditSheet import io.legado.app.ui.widget.components.rules.RuleListScaffold +import io.legado.app.utils.toastOnUi import kotlinx.coroutines.flow.Flow import org.koin.androidx.compose.koinViewModel import sh.calvin.reorderable.rememberReorderableLazyListState @@ -244,6 +245,22 @@ fun TxtRuleScreen( editingRule = null }, onSave = { updatedRule -> + when { + updatedRule.name.isBlank() -> { + context.toastOnUi(R.string.cannot_empty) + return@RuleEditSheet + } + + updatedRule.rule.isBlank() -> { + context.toastOnUi(R.string.cannot_empty) + return@RuleEditSheet + } + + runCatching { Regex(updatedRule.rule) }.isFailure -> { + context.toastOnUi(R.string.invalid_format) + return@RuleEditSheet + } + } onIntent(TxtTocRuleIntent.SaveRule(updatedRule, isNew = editingRule == null)) showEditSheet = false editingRule = null diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 6f14025bc..f32ebf597 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -2,6 +2,8 @@ package io.legado.app.ui.main import android.content.Intent import android.os.Build +import android.os.SystemClock +import android.view.ViewConfiguration import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibilityScope @@ -193,6 +195,31 @@ fun MainScreen( orientation = Orientation.Horizontal, ) var bookshelfScrollToTopRequest by remember { mutableLongStateOf(0L) } + var lastBookshelfNavClickTime by remember { mutableLongStateOf(0L) } + fun requestBookshelfScrollToTop(index: Int) { + bookshelfScrollToTopRequest++ + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } + + fun handleMainDestinationClick(index: Int, destination: MainDestination) { + if (destination == MainDestination.Bookshelf) { + val now = SystemClock.uptimeMillis() + if ( + (pagerState.currentPage == index || pagerState.targetPage == index) && + now - lastBookshelfNavClickTime <= ViewConfiguration.getDoubleTapTimeout() + ) { + requestBookshelfScrollToTop(index) + lastBookshelfNavClickTime = 0L + return + } + lastBookshelfNavClickTime = now + } + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } LaunchedEffect(destinations) { if (destinations.isNotEmpty() && pagerState.currentPage !in destinations.indices) { pagerState.scrollToPage(destinations.lastIndex) @@ -267,9 +294,7 @@ fun MainScreen( railExpanded = navState.targetValue == WideNavigationRailValue.Expanded, selected = selected, onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } + handleMainDestinationClick(index, destination) }, icon = { Box { @@ -278,10 +303,10 @@ fun MainScreen( selected = selected, modifier = if (destination == MainDestination.Bookshelf) { Modifier.combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } + handleMainDestinationClick(index, destination) }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -330,7 +355,7 @@ fun MainScreen( }, selected = selected, onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } + handleMainDestinationClick(index, destination) }, labelString = stringResource(destination.labelId), iconVector = AppIcons.mainDestination(destination, selected), @@ -432,6 +457,7 @@ fun MainScreen( onOpenExploreShow = onNavigateToExploreShow, ) MainDestination.Rss -> RssScreen( + isActive = page == pagerState.currentPage, onOpenSort = { sourceUrl, sortUrl, key -> onNavigateToRssSort(sourceUrl, sortUrl, key) }, @@ -478,8 +504,13 @@ fun MainScreen( ), selectedIndex = { pagerState.targetPage }, onSelected = { index -> - coroutineScope.launch { - pagerState.animateScrollToPage(index) + destinations.getOrNull(index)?.let { destination -> + handleMainDestinationClick(index, destination) + } + }, + onReselected = { index -> + destinations.getOrNull(index)?.let { destination -> + handleMainDestinationClick(index, destination) } }, backdrop = floatingBarBackdrop, @@ -494,16 +525,11 @@ fun MainScreen( val hasCustomIcon = destination.customIconPath.isNotEmpty() FloatingBottomBarItem( onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } + handleMainDestinationClick(index, destination) }, onDoubleClick = if (destination == MainDestination.Bookshelf) { { - bookshelfScrollToTopRequest++ - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } + requestBookshelfScrollToTop(index) } } else { null diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt index 0146b5808..cb9e2b6a4 100644 --- a/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt @@ -575,6 +575,7 @@ fun HomeScreen( modifier = Modifier .padding(horizontal = 12.dp) .clickable { showSourceMenu = true }, + cornerRadius = 32.dp ) { Row( modifier = Modifier.padding( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index a4d3c964b..972b5b535 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.SearchRepository @@ -92,7 +93,7 @@ class HomepageViewModel( val effects = _effects.asSharedFlow() private val loadJobs = ConcurrentHashMap() - private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() + private val exploreSourcePartsFlow = bookSourceRepository.flowExploreSourceParts() // 1. 基础原始状态 private val _isRefreshing = MutableStateFlow(false) @@ -100,6 +101,7 @@ class HomepageViewModel( private val _configVersion = MutableStateFlow(0L) private val _moduleContentStates = MutableStateFlow>(emptyMap()) private val _bookSourcesCache = MutableStateFlow>(emptyMap()) + private val _bookSourcePartsCache = MutableStateFlow>(emptyMap()) private val _layoutConfigCache = MutableStateFlow>>(emptyMap()) private val _pendingEnabled = MutableStateFlow>(emptyMap()) private val _pendingUserModules = MutableStateFlow>(emptyList()) @@ -138,7 +140,7 @@ class HomepageViewModel( }.toImmutableList() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) - val browseSourcesFlow = exploreSourcesFlow.map { sources -> + val browseSourcesFlow = exploreSourcePartsFlow.map { sources -> sources.map { source -> HomepageSourceManageUi( sourceUrl = source.bookSourceUrl, @@ -158,9 +160,9 @@ class HomepageViewModel( setsFlow, browseSourcesFlow, allModulesCache, - _bookSourcesCache, + _bookSourcePartsCache, _pendingEnabled - ) { sets, browseSources, allModules, sourcesCache, pendingEnabled -> + ) { sets, browseSources, allModules, sourcePartsCache, pendingEnabled -> HomepageManageUiState( sets = sets, browseSources = browseSources, @@ -180,17 +182,25 @@ class HomepageViewModel( originalTitle = module.title, ) }.toImmutableList(), - sourceNames = sourcesCache.mapValues { it.value.bookSourceName } + sourceNames = sourcePartsCache.mapValues { it.value.bookSourceName } ) } + private val sourceCachesFlow = combine( + _bookSourcePartsCache, + _bookSourcesCache + ) { sourceParts, sources -> + sourceParts to sources + } + private val rawModulesFlow = combine( orderedModuleDefsFlow, _moduleContentStates, - _bookSourcesCache, + sourceCachesFlow, customSetsFlow, _layoutConfigCache - ) { grouped, contentStates, sourcesCache, customSets, configCache -> + ) { grouped, contentStates, sourceCaches, customSets, configCache -> + val (sourcePartsCache, sourcesCache) = sourceCaches val setNames = customSets.associate { it.id to it.name } val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } @@ -199,7 +209,9 @@ class HomepageViewModel( val mods = grouped[setUrl] ?: emptyList() mods.map { module -> val source = sourcesCache[module.sourceUrl] - val sourceName = source?.bookSourceName ?: module.sourceUrl + val sourcePart = sourcePartsCache[module.sourceUrl] + val sourceName = + source?.bookSourceName ?: sourcePart?.bookSourceName ?: module.sourceUrl val setName = module.customSetId?.let { setNames[it] } ?: sourceName val exploreUrl = module.url ?: source?.exploreUrl val configMap = configCache[module.id] ?: emptyMap() @@ -300,7 +312,13 @@ class HomepageViewModel( } viewModelScope.launch { - exploreSourcesFlow.collect { sources -> + exploreSourcePartsFlow.collect { sources -> + _bookSourcePartsCache.value = sources.associateBy { it.bookSourceUrl } + } + } + + viewModelScope.launch { + bookSourceRepository.flowHomepageModules().collect { sources -> _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } } } @@ -713,7 +731,9 @@ class HomepageViewModel( .groupBy { it.sourceUrl } fun getSourceName(sourceUrl: String): String = - _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl + _bookSourcePartsCache.value[sourceUrl]?.bookSourceName + ?: _bookSourcesCache.value[sourceUrl]?.bookSourceName + ?: sourceUrl fun assignModuleToCustomSet(moduleId: String, customSetId: String?) { viewModelScope.launch { diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt index 9d6209c85..9e4a62940 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt @@ -28,8 +28,9 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,6 +46,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.data.entities.RssSource @@ -73,6 +79,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun RssScreen( viewModel: RssViewModel = koinViewModel(), + isActive: Boolean = true, onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit, onOpenRuleSub: () -> Unit, @@ -89,6 +96,7 @@ fun RssScreen( val currentOnOpenRead by rememberUpdatedState(onOpenRead) val currentOnOpenRuleSub by rememberUpdatedState(onOpenRuleSub) val currentOnOpenFavorites by rememberUpdatedState(onOpenFavorites) + val pageLifecycleOwner = rememberRssPageLifecycleOwner(isActive) LaunchedEffect(viewModel) { viewModel.effects.collectLatest { effect -> @@ -133,115 +141,117 @@ fun RssScreen( } } - ListScaffold( - title = stringResource(R.string.rss), - state = uiState, - subtitle = uiState.group.ifEmpty { stringResource(R.string.all) }, - onBackClick = null, - onSearchToggle = { viewModel.toggleSearchVisible(it) }, - onSearchQueryChange = { viewModel.search(it) }, - searchPlaceholder = stringResource(R.string.search_rss_source), - dropDownMenuContent = { dismiss -> - RoundDropdownMenuItem( - onClick = { - viewModel.openSourceManage() - dismiss() - }, - text = stringResource(R.string.rss_feed_management), - ) - PillDivider() - RoundDropdownMenuItem( - text = stringResource(R.string.all), - onClick = { - viewModel.setGroup("") - dismiss() - } - ) - uiState.groups.forEach { group -> + CompositionLocalProvider(LocalLifecycleOwner provides pageLifecycleOwner) { + ListScaffold( + title = stringResource(R.string.rss), + state = uiState, + subtitle = uiState.group.ifEmpty { stringResource(R.string.all) }, + onBackClick = null, + onSearchToggle = { viewModel.toggleSearchVisible(it) }, + onSearchQueryChange = { viewModel.search(it) }, + searchPlaceholder = stringResource(R.string.search_rss_source), + dropDownMenuContent = { dismiss -> RoundDropdownMenuItem( - text = group, onClick = { - viewModel.setGroup(group) + viewModel.openSourceManage() + dismiss() + }, + text = stringResource(R.string.rss_feed_management), + ) + PillDivider() + RoundDropdownMenuItem( + text = stringResource(R.string.all), + onClick = { + viewModel.setGroup("") dismiss() } ) - } - }, - contentWindowInsets = WindowInsets(0) - ) { paddingValues -> - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 72.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = adaptiveContentPadding( - top = paddingValues.calculateTopPadding(), - bottom = 120.dp - ), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - GlassCard( - modifier = Modifier.weight(1f), - onClick = { viewModel.openRuleSub() } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - AppIcon( - imageVector = Icons.Default.Subscriptions, - contentDescription = null - ) - Spacer(modifier = Modifier.width(12.dp)) - AppText( - text = stringResource(R.string.rule_subscription), - style = LegadoTheme.typography.labelMediumEmphasized - ) + uiState.groups.forEach { group -> + RoundDropdownMenuItem( + text = group, + onClick = { + viewModel.setGroup(group) + dismiss() } - } - GlassCard( - modifier = Modifier.weight(1f), - onClick = { viewModel.openFavorites() } + ) + } + }, + contentWindowInsets = WindowInsets(0) + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 72.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + GlassCard( + modifier = Modifier.weight(1f), + onClick = { viewModel.openRuleSub() } ) { - AppIcon( - imageVector = Icons.Default.Star, - contentDescription = null - ) - Spacer(modifier = Modifier.width(12.dp)) - AppText( - text = stringResource(R.string.favorite), - style = LegadoTheme.typography.labelMediumEmphasized - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AppIcon( + imageVector = Icons.Default.Subscriptions, + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + AppText( + text = stringResource(R.string.rule_subscription), + style = LegadoTheme.typography.labelMediumEmphasized + ) + } + } + GlassCard( + modifier = Modifier.weight(1f), + onClick = { viewModel.openFavorites() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AppIcon( + imageVector = Icons.Default.Star, + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + AppText( + text = stringResource(R.string.favorite), + style = LegadoTheme.typography.labelMediumEmphasized + ) + } } } } - } - items(uiState.items, key = { it.sourceUrl }) { source -> - RssSourceGridItem( - modifier = Modifier.animateItem(), - source = source, - onClick = { viewModel.openSource(source) }, - onTop = { viewModel.topSource(source) }, - onEdit = { viewModel.openSourceEdit(source) }, - onDelete = { sourceToDeleteUrl = source.sourceUrl }, - onDisable = { viewModel.disable(source) }, - onLogin = { viewModel.login(source) } - ) + items(uiState.items, key = { it.sourceUrl }) { source -> + RssSourceGridItem( + modifier = Modifier.animateItem(), + source = source, + onClick = { viewModel.openSource(source) }, + onTop = { viewModel.topSource(source) }, + onEdit = { viewModel.openSourceEdit(source) }, + onDelete = { sourceToDeleteUrl = source.sourceUrl }, + onDisable = { viewModel.disable(source) }, + onLogin = { viewModel.login(source) } + ) + } } } } @@ -260,6 +270,51 @@ fun RssScreen( ) } +@Composable +private fun rememberRssPageLifecycleOwner(isActive: Boolean): LifecycleOwner { + val parentLifecycle = LocalLifecycleOwner.current.lifecycle + val currentActive by rememberUpdatedState(isActive) + val owner = remember(parentLifecycle) { RssPageLifecycleOwner() } + + DisposableEffect(parentLifecycle) { + val observer = LifecycleEventObserver { _, _ -> + owner.update(parentLifecycle.currentState, currentActive) + } + parentLifecycle.addObserver(observer) + owner.update(parentLifecycle.currentState, currentActive) + onDispose { + parentLifecycle.removeObserver(observer) + owner.destroy() + } + } + + LaunchedEffect(isActive, parentLifecycle) { + owner.update(parentLifecycle.currentState, isActive) + } + + return owner +} + +private class RssPageLifecycleOwner : LifecycleOwner { + + private val registry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle = registry + + fun update(parentState: Lifecycle.State, isActive: Boolean) { + registry.currentState = when { + parentState == Lifecycle.State.DESTROYED -> Lifecycle.State.DESTROYED + !parentState.isAtLeast(Lifecycle.State.STARTED) -> Lifecycle.State.CREATED + isActive -> parentState + else -> Lifecycle.State.STARTED + } + } + + fun destroy() { + registry.currentState = Lifecycle.State.DESTROYED + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun RssSourceGridItem( 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 259b1322c..6d8736693 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 @@ -13,6 +13,7 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -55,6 +56,9 @@ import io.legado.app.help.http.CookieManager import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.LocalHazeState +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.responsiveHazeEffect import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow @@ -65,6 +69,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator import io.legado.app.ui.widget.components.topbar.GlassTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.NetworkUtils @@ -78,6 +83,7 @@ import io.legado.app.utils.toggleSystemBar import org.apache.commons.text.StringEscapeUtils import org.jsoup.Jsoup import org.koin.androidx.compose.koinViewModel +import top.yukonga.miuix.kmp.basic.SmallTopAppBar as MiuixSmallTopAppBar @SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @@ -200,7 +206,7 @@ fun RssReadRouteScreen( AppScaffold( disableHazeSource = true, topBar = { - GlassTopAppBar( + RssReadTopAppBar( title = pageTitle.ifBlank { defaultTopBarTitle }, navigationIcon = { TopBarNavigationButton(onClick = onBackClick) @@ -406,6 +412,41 @@ fun RssReadRouteScreen( ) } +@Composable +private fun RssReadTopAppBar( + title: String, + navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, +) { + if (!ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { + GlassTopAppBar( + title = title, + navigationIcon = navigationIcon, + actions = actions + ) + return + } + + val hazeState = LocalHazeState.current + val containerColor = GlassTopAppBarDefaults.getMiuixAppBarColor() + val modifier = if (hazeState != null) { + Modifier + .background(containerColor) + .responsiveHazeEffect(hazeState) + } else { + Modifier.background(containerColor) + } + + Column(modifier = modifier) { + MiuixSmallTopAppBar( + title = title, + navigationIcon = navigationIcon, + actions = actions, + color = Color.Transparent + ) + } +} + @Composable private fun FavoriteEditSheet( show: Boolean, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt index 6e8778288..3508cf0d7 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt @@ -122,6 +122,7 @@ fun FloatingBottomBar( modifier: Modifier = Modifier, selectedIndex: () -> Int, onSelected: (index: Int) -> Unit, + onReselected: (index: Int) -> Unit = {}, backdrop: Backdrop, tabsCount: Int, isBlurEnabled: Boolean = true, @@ -202,6 +203,8 @@ fun FloatingBottomBar( animateToValue(targetIndex.toFloat()) if (targetIndex != selectedIndex()) { onSelected(targetIndex) + } else { + onReselected(targetIndex) } animationScope.launch { offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f)) @@ -273,7 +276,10 @@ fun FloatingBottomBar( if (isBlurEnabled) { vibrancy() blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) - lens(ThemeConfig.bottomBarLensRadius.dp.toPx(), ThemeConfig.bottomBarLensRadius.dp.toPx()) + lens( + ThemeConfig.bottomBarLensRadius.dp.toPx(), + ThemeConfig.bottomBarLensRadius.dp.toPx() + ) } }, highlight = { @@ -330,7 +336,10 @@ fun FloatingBottomBar( val progress = dampedDragAnimation.pressProgress vibrancy() blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) - lens(ThemeConfig.bottomBarLensRadius.dp.toPx() * progress, ThemeConfig.bottomBarLensRadius.dp.toPx() * progress) + lens( + ThemeConfig.bottomBarLensRadius.dp.toPx() * progress, + ThemeConfig.bottomBarLensRadius.dp.toPx() * progress + ) } }, highlight = { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt index 08ec2af4a..27d93fc5b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt @@ -10,13 +10,12 @@ import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.PlainTooltip import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox @@ -37,10 +36,10 @@ import io.legado.app.ui.widget.components.icon.AppIcon 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.text.AppText +import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.FloatingToolbar as MiuixFloatingToolbar import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton -import top.yukonga.miuix.kmp.theme.MiuixTheme data class SelectionActions( val primaryAction: ActionItem, @@ -92,13 +91,13 @@ fun SelectionBottomBar( MiuixIconButton( onClick = primaryAction.onClick, - backgroundColor = MiuixTheme.colorScheme.primary, + backgroundColor = MiuixTheme.colorScheme.secondaryContainer, minWidth = 64.dp ) { MiuixIcon( imageVector = primaryAction.icon, contentDescription = null, - tint = MiuixTheme.colorScheme.onPrimary + tint = MiuixTheme.colorScheme.onSecondaryContainer ) } @@ -155,14 +154,19 @@ fun SelectionBottomBar( contentDescription = "More actions" ) } - DropdownMenu( + RoundDropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false } ) { secondaryActions.forEach { action -> - DropdownMenuItem( - text = { AppText(action.text) }, - leadingIcon = action.icon?.let { { AppIcon(imageVector = it, contentDescription = null) } }, + RoundDropdownMenuItem( + text = action.text, + leadingIcon = { + AppIcon( + imageVector = action.icon, + contentDescription = null + ) + }, onClick = { action.onClick() showMenu = false @@ -184,6 +188,10 @@ fun SelectionBottomBar( FilledIconButton( modifier = Modifier.width(64.dp), onClick = primaryAction.onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = LegadoTheme.colorScheme.secondaryContainer, + contentColor = LegadoTheme.colorScheme.onSecondaryContainer, + ), ) { AppIcon(imageVector = primaryAction.icon, contentDescription = null) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt index 8d50d3995..790fe5e44 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt @@ -8,6 +8,11 @@ import androidx.compose.material.icons.filled.RssFeed import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.draw.clip @@ -43,6 +48,11 @@ fun SourceIcon( } ) { val context = LocalContext.current + var imageLoaded by remember(path) { mutableStateOf(false) } + + LaunchedEffect(path) { + imageLoaded = false + } Box( modifier = modifier.clip(MaterialTheme.shapes.medium), @@ -51,6 +61,9 @@ fun SourceIcon( if (path == null || (path is String && path.isEmpty())) { placeholderIcon() } else { + if (!imageLoaded) { + placeholderIcon() + } AsyncImage( model = ImageRequest.Builder(context) .data(path) @@ -61,8 +74,10 @@ fun SourceIcon( imageLoader = imageLoader, contentDescription = null, contentScale = contentScale, // 不裁切 - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + onSuccess = { imageLoaded = true }, + onError = { imageLoaded = false } ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt index df4bff915..ab67f6552 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt @@ -19,16 +19,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.legado.app.R +import io.legado.app.constant.AppLog import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.button.series.MediumPlainButton import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.text.AppText import io.legado.app.utils.LogUtils -import splitties.init.appCtx -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -80,93 +78,25 @@ fun AppLogSheet( private data class LogEntry( val time: Long, val message: String, - val isCrash: Boolean = false, ) { val content: String get() = message } -/** - * 从本地日志文件加载所有日志(应用日志 + 崩溃日志) - */ private fun loadAllLogs(): List { - val entries = mutableListOf() - loadAppLogFiles(entries) - loadCrashLogFiles(entries) - return entries.sortedByDescending { it.time } -} - -/** - * 从 externalCacheDir/logs/ 读取应用日志文件 - * 文件格式: appLog-{date}.txt, 每行格式: yy-MM-dd HH:mm:ss.SSS: message - */ -private fun loadAppLogFiles(entries: MutableList) { - val logDir = appCtx.externalCacheDir?.resolve("logs") ?: return - if (!logDir.isDirectory) return - logDir.listFiles() - ?.filter { it.isFile && it.name.startsWith("appLog-") && it.name.endsWith(".txt") } - ?.sortedByDescending { it.name } - ?.forEach { file -> - runCatching { - file.readLines().forEach { line -> - parseLogLine(line)?.let { entries.add(it) } + return AppLog.logs.map { (time, message, throwable) -> + LogEntry( + time = time, + message = if (throwable == null) { + message + } else { + "$message\n${throwable.stackTraceToString()}" } - } + ) } } -/** - * 从 externalCacheDir/crash/ 读取崩溃日志文件 - */ -private fun loadCrashLogFiles(entries: MutableList) { - val crashDir = appCtx.externalCacheDir?.resolve("crash") ?: return - if (!crashDir.isDirectory) return - crashDir.listFiles() - ?.filter { it.isFile && it.name.startsWith("crash-") } - ?.forEach { file -> - runCatching { - entries.add( - LogEntry( - time = file.lastModified(), - message = file.readText(), - isCrash = true, - ) - ) - } - } -} - -/** - * 解析日志行: "yy-MM-dd HH:mm:ss.SSS: message" - */ -private fun parseLogLine(line: String): LogEntry? { - if (line.isBlank()) return null - // 格式: 25-06-07 12:34:56.789: message - val colonIdx = line.indexOf(": ") - if (colonIdx < 0 || colonIdx < 17) { - // 没有时间戳前缀,作为多行消息附加到上一条(此处忽略) - return null - } - val timeStr = line.substring(0, colonIdx) - val message = line.substring(colonIdx + 2) - val time = runCatching { - logFileDateFormat.parse(timeStr)?.time - }.getOrNull() ?: 0L - return LogEntry(time = time, message = message) -} - -private val logFileDateFormat = SimpleDateFormat("yy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) - private fun clearAllLogs() { - // 清除应用日志 - val logDir = appCtx.externalCacheDir?.resolve("logs") - if (logDir?.isDirectory == true) { - logDir.listFiles()?.forEach { it.delete() } - } - // 清除崩溃日志 - val crashDir = appCtx.externalCacheDir?.resolve("crash") - if (crashDir?.isDirectory == true) { - crashDir.listFiles()?.forEach { it.delete() } - } + AppLog.clear() } @Composable @@ -182,28 +112,15 @@ private fun LogItem( ) { AppText( text = buildString { - if (item.isCrash) append("[崩溃] ") if (item.time > 0) append(LogUtils.logTimeFormat.format(Date(item.time))) }, style = LegadoTheme.typography.labelMedium, - color = if (item.isCrash) { - LegadoTheme.colorScheme.error - } else { - LegadoTheme.colorScheme.outline - } + color = LegadoTheme.colorScheme.outline ) AppText( - text = if (item.isCrash) { - item.message.lineSequence().firstOrNull().orEmpty() - } else { - item.message - }, + text = item.message, style = LegadoTheme.typography.bodyMedium, - color = if (item.isCrash) { - LegadoTheme.colorScheme.error.copy(alpha = 0.8f) - } else { - LegadoTheme.colorScheme.onSurface - }, + color = LegadoTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 4.dp), maxLines = 2, )