修复一些问题

This commit is contained in:
HapeLee
2026-06-30 01:53:21 +08:00
parent 9f897ddd27
commit c10714f2f7
13 changed files with 359 additions and 236 deletions
@@ -96,6 +96,13 @@ interface BookSourceDao {
) )
fun flowExploreSources(): Flow<List<BookSource>> fun flowExploreSources(): Flow<List<BookSource>>
@Query(
"""select * from book_sources_part
where enabled = 1 and enabledExplore = 1
order by customOrder asc"""
)
fun flowExploreSourceParts(): Flow<List<BookSourcePart>>
@Query( @Query(
"""select * from book_sources_part """select * from book_sources_part
where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc""" where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc"""
@@ -393,4 +400,4 @@ interface BookSourceDao {
dealGroups(list) dealGroups(list)
}.flowOn(IO) }.flowOn(IO)
} }
} }
@@ -25,6 +25,10 @@ class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
return bookSourceDao.flowExploreSources() return bookSourceDao.flowExploreSources()
} }
fun flowExploreSourceParts(): Flow<List<BookSourcePart>> {
return bookSourceDao.flowExploreSourceParts()
}
suspend fun getBookSource(sourceUrl: String): BookSource? { suspend fun getBookSource(sourceUrl: String): BookSource? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
bookSourceDao.getBookSource(sourceUrl) bookSourceDao.getBookSource(sourceUrl)
@@ -2,6 +2,7 @@ package io.legado.app.help.config
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.annotation.Keep
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
@@ -530,6 +531,7 @@ object ThemeImportExport {
/** /**
* 主题导出数据类 * 主题导出数据类
*/ */
@Keep
data class ThemeExportData( data class ThemeExportData(
// 基础主题设置 // 基础主题设置
val appTheme: String = "0", val appTheme: String = "0",
@@ -639,6 +641,7 @@ data class ThemeExportData(
/** /**
* 已保存的主题 * 已保存的主题
*/ */
@Keep
data class SavedTheme( data class SavedTheme(
val name: String, val name: String,
val data: ThemeExportData val data: ThemeExportData
@@ -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.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet 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.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.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.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn 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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleEditFields 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.RuleEditSheet
import io.legado.app.ui.widget.components.rules.RuleListScaffold import io.legado.app.ui.widget.components.rules.RuleListScaffold
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -244,6 +245,22 @@ fun TxtRuleScreen(
editingRule = null editingRule = null
}, },
onSave = { updatedRule -> 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)) onIntent(TxtTocRuleIntent.SaveRule(updatedRule, isNew = editingRule == null))
showEditSheet = false showEditSheet = false
editingRule = null editingRule = null
@@ -2,6 +2,8 @@ package io.legado.app.ui.main
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.SystemClock
import android.view.ViewConfiguration
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
@@ -193,6 +195,31 @@ fun MainScreen(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
) )
var bookshelfScrollToTopRequest by remember { mutableLongStateOf(0L) } 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) { LaunchedEffect(destinations) {
if (destinations.isNotEmpty() && pagerState.currentPage !in destinations.indices) { if (destinations.isNotEmpty() && pagerState.currentPage !in destinations.indices) {
pagerState.scrollToPage(destinations.lastIndex) pagerState.scrollToPage(destinations.lastIndex)
@@ -267,9 +294,7 @@ fun MainScreen(
railExpanded = navState.targetValue == WideNavigationRailValue.Expanded, railExpanded = navState.targetValue == WideNavigationRailValue.Expanded,
selected = selected, selected = selected,
onClick = { onClick = {
coroutineScope.launch { handleMainDestinationClick(index, destination)
pagerState.animateScrollToPage(index)
}
}, },
icon = { icon = {
Box { Box {
@@ -278,10 +303,10 @@ fun MainScreen(
selected = selected, selected = selected,
modifier = if (destination == MainDestination.Bookshelf) { modifier = if (destination == MainDestination.Bookshelf) {
Modifier.combinedClickable( Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onClick = {
coroutineScope.launch { handleMainDestinationClick(index, destination)
pagerState.animateScrollToPage(index)
}
}, },
onLongClick = { onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -330,7 +355,7 @@ fun MainScreen(
}, },
selected = selected, selected = selected,
onClick = { onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) } handleMainDestinationClick(index, destination)
}, },
labelString = stringResource(destination.labelId), labelString = stringResource(destination.labelId),
iconVector = AppIcons.mainDestination(destination, selected), iconVector = AppIcons.mainDestination(destination, selected),
@@ -432,6 +457,7 @@ fun MainScreen(
onOpenExploreShow = onNavigateToExploreShow, onOpenExploreShow = onNavigateToExploreShow,
) )
MainDestination.Rss -> RssScreen( MainDestination.Rss -> RssScreen(
isActive = page == pagerState.currentPage,
onOpenSort = { sourceUrl, sortUrl, key -> onOpenSort = { sourceUrl, sortUrl, key ->
onNavigateToRssSort(sourceUrl, sortUrl, key) onNavigateToRssSort(sourceUrl, sortUrl, key)
}, },
@@ -478,8 +504,13 @@ fun MainScreen(
), ),
selectedIndex = { pagerState.targetPage }, selectedIndex = { pagerState.targetPage },
onSelected = { index -> onSelected = { index ->
coroutineScope.launch { destinations.getOrNull(index)?.let { destination ->
pagerState.animateScrollToPage(index) handleMainDestinationClick(index, destination)
}
},
onReselected = { index ->
destinations.getOrNull(index)?.let { destination ->
handleMainDestinationClick(index, destination)
} }
}, },
backdrop = floatingBarBackdrop, backdrop = floatingBarBackdrop,
@@ -494,16 +525,11 @@ fun MainScreen(
val hasCustomIcon = destination.customIconPath.isNotEmpty() val hasCustomIcon = destination.customIconPath.isNotEmpty()
FloatingBottomBarItem( FloatingBottomBarItem(
onClick = { onClick = {
coroutineScope.launch { handleMainDestinationClick(index, destination)
pagerState.animateScrollToPage(index)
}
}, },
onDoubleClick = if (destination == MainDestination.Bookshelf) { onDoubleClick = if (destination == MainDestination.Bookshelf) {
{ {
bookshelfScrollToTopRequest++ requestBookshelfScrollToTop(index)
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
} }
} else { } else {
null null
@@ -575,6 +575,7 @@ fun HomeScreen(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.clickable { showSourceMenu = true }, .clickable { showSourceMenu = true },
cornerRadius = 32.dp
) { ) {
Row( Row(
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.data.entities.BookSource 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.entities.SearchBook
import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.data.repository.SearchRepository import io.legado.app.data.repository.SearchRepository
@@ -92,7 +93,7 @@ class HomepageViewModel(
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private val loadJobs = ConcurrentHashMap<String, Job>() private val loadJobs = ConcurrentHashMap<String, Job>()
private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() private val exploreSourcePartsFlow = bookSourceRepository.flowExploreSourceParts()
// 1. 基础原始状态 // 1. 基础原始状态
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
@@ -100,6 +101,7 @@ class HomepageViewModel(
private val _configVersion = MutableStateFlow(0L) private val _configVersion = MutableStateFlow(0L)
private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap()) private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap()) private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
private val _bookSourcePartsCache = MutableStateFlow<Map<String, BookSourcePart>>(emptyMap())
private val _layoutConfigCache = MutableStateFlow<Map<String, Map<String, String>>>(emptyMap()) private val _layoutConfigCache = MutableStateFlow<Map<String, Map<String, String>>>(emptyMap())
private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap()) private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap())
private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList()) private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList())
@@ -138,7 +140,7 @@ class HomepageViewModel(
}.toImmutableList() }.toImmutableList()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf())
val browseSourcesFlow = exploreSourcesFlow.map { sources -> val browseSourcesFlow = exploreSourcePartsFlow.map { sources ->
sources.map { source -> sources.map { source ->
HomepageSourceManageUi( HomepageSourceManageUi(
sourceUrl = source.bookSourceUrl, sourceUrl = source.bookSourceUrl,
@@ -158,9 +160,9 @@ class HomepageViewModel(
setsFlow, setsFlow,
browseSourcesFlow, browseSourcesFlow,
allModulesCache, allModulesCache,
_bookSourcesCache, _bookSourcePartsCache,
_pendingEnabled _pendingEnabled
) { sets, browseSources, allModules, sourcesCache, pendingEnabled -> ) { sets, browseSources, allModules, sourcePartsCache, pendingEnabled ->
HomepageManageUiState( HomepageManageUiState(
sets = sets, sets = sets,
browseSources = browseSources, browseSources = browseSources,
@@ -180,17 +182,25 @@ class HomepageViewModel(
originalTitle = module.title, originalTitle = module.title,
) )
}.toImmutableList(), }.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( private val rawModulesFlow = combine(
orderedModuleDefsFlow, orderedModuleDefsFlow,
_moduleContentStates, _moduleContentStates,
_bookSourcesCache, sourceCachesFlow,
customSetsFlow, customSetsFlow,
_layoutConfigCache _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 setNames = customSets.associate { it.id to it.name }
val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id }
@@ -199,7 +209,9 @@ class HomepageViewModel(
val mods = grouped[setUrl] ?: emptyList() val mods = grouped[setUrl] ?: emptyList()
mods.map { module -> mods.map { module ->
val source = sourcesCache[module.sourceUrl] 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 setName = module.customSetId?.let { setNames[it] } ?: sourceName
val exploreUrl = module.url ?: source?.exploreUrl val exploreUrl = module.url ?: source?.exploreUrl
val configMap = configCache[module.id] ?: emptyMap() val configMap = configCache[module.id] ?: emptyMap()
@@ -300,7 +312,13 @@ class HomepageViewModel(
} }
viewModelScope.launch { 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 } _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
} }
} }
@@ -713,7 +731,9 @@ class HomepageViewModel(
.groupBy { it.sourceUrl } .groupBy { it.sourceUrl }
fun getSourceName(sourceUrl: String): String = 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?) { fun assignModuleToCustomSet(moduleId: String, customSetId: String?) {
viewModelScope.launch { viewModelScope.launch {
@@ -28,8 +28,9 @@ import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.Subscriptions
import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
@@ -73,6 +79,7 @@ import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun RssScreen( fun RssScreen(
viewModel: RssViewModel = koinViewModel(), viewModel: RssViewModel = koinViewModel(),
isActive: Boolean = true,
onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit, onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
onOpenRuleSub: () -> Unit, onOpenRuleSub: () -> Unit,
@@ -89,6 +96,7 @@ fun RssScreen(
val currentOnOpenRead by rememberUpdatedState(onOpenRead) val currentOnOpenRead by rememberUpdatedState(onOpenRead)
val currentOnOpenRuleSub by rememberUpdatedState(onOpenRuleSub) val currentOnOpenRuleSub by rememberUpdatedState(onOpenRuleSub)
val currentOnOpenFavorites by rememberUpdatedState(onOpenFavorites) val currentOnOpenFavorites by rememberUpdatedState(onOpenFavorites)
val pageLifecycleOwner = rememberRssPageLifecycleOwner(isActive)
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect -> viewModel.effects.collectLatest { effect ->
@@ -133,115 +141,117 @@ fun RssScreen(
} }
} }
ListScaffold( CompositionLocalProvider(LocalLifecycleOwner provides pageLifecycleOwner) {
title = stringResource(R.string.rss), ListScaffold(
state = uiState, title = stringResource(R.string.rss),
subtitle = uiState.group.ifEmpty { stringResource(R.string.all) }, state = uiState,
onBackClick = null, subtitle = uiState.group.ifEmpty { stringResource(R.string.all) },
onSearchToggle = { viewModel.toggleSearchVisible(it) }, onBackClick = null,
onSearchQueryChange = { viewModel.search(it) }, onSearchToggle = { viewModel.toggleSearchVisible(it) },
searchPlaceholder = stringResource(R.string.search_rss_source), onSearchQueryChange = { viewModel.search(it) },
dropDownMenuContent = { dismiss -> searchPlaceholder = stringResource(R.string.search_rss_source),
RoundDropdownMenuItem( dropDownMenuContent = { dismiss ->
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 ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group,
onClick = { 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() dismiss()
} }
) )
} uiState.groups.forEach { group ->
}, RoundDropdownMenuItem(
contentWindowInsets = WindowInsets(0) text = group,
) { paddingValues -> onClick = {
LazyVerticalGrid( viewModel.setGroup(group)
columns = GridCells.Adaptive(minSize = 72.dp), dismiss()
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
)
} }
} )
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( GlassCard(
modifier = Modifier modifier = Modifier.weight(1f),
.fillMaxWidth() onClick = { viewModel.openRuleSub() }
.padding(all = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) { ) {
AppIcon( Row(
imageVector = Icons.Default.Star, modifier = Modifier
contentDescription = null .fillMaxWidth()
) .padding(all = 12.dp),
Spacer(modifier = Modifier.width(12.dp)) verticalAlignment = Alignment.CenterVertically,
AppText( horizontalArrangement = Arrangement.Center
text = stringResource(R.string.favorite), ) {
style = LegadoTheme.typography.labelMediumEmphasized 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 -> items(uiState.items, key = { it.sourceUrl }) { source ->
RssSourceGridItem( RssSourceGridItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
source = source, source = source,
onClick = { viewModel.openSource(source) }, onClick = { viewModel.openSource(source) },
onTop = { viewModel.topSource(source) }, onTop = { viewModel.topSource(source) },
onEdit = { viewModel.openSourceEdit(source) }, onEdit = { viewModel.openSourceEdit(source) },
onDelete = { sourceToDeleteUrl = source.sourceUrl }, onDelete = { sourceToDeleteUrl = source.sourceUrl },
onDisable = { viewModel.disable(source) }, onDisable = { viewModel.disable(source) },
onLogin = { viewModel.login(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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RssSourceGridItem( fun RssSourceGridItem(
@@ -13,6 +13,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.config.otherConfig.OtherConfig
import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.theme.LegadoTheme 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.AppScaffold
import io.legado.app.ui.widget.components.AppTextField 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.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.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator 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.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.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.utils.NetworkUtils import io.legado.app.utils.NetworkUtils
@@ -78,6 +83,7 @@ import io.legado.app.utils.toggleSystemBar
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import top.yukonga.miuix.kmp.basic.SmallTopAppBar as MiuixSmallTopAppBar
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -200,7 +206,7 @@ fun RssReadRouteScreen(
AppScaffold( AppScaffold(
disableHazeSource = true, disableHazeSource = true,
topBar = { topBar = {
GlassTopAppBar( RssReadTopAppBar(
title = pageTitle.ifBlank { defaultTopBarTitle }, title = pageTitle.ifBlank { defaultTopBarTitle },
navigationIcon = { navigationIcon = {
TopBarNavigationButton(onClick = onBackClick) 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 @Composable
private fun FavoriteEditSheet( private fun FavoriteEditSheet(
show: Boolean, show: Boolean,
@@ -122,6 +122,7 @@ fun FloatingBottomBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectedIndex: () -> Int, selectedIndex: () -> Int,
onSelected: (index: Int) -> Unit, onSelected: (index: Int) -> Unit,
onReselected: (index: Int) -> Unit = {},
backdrop: Backdrop, backdrop: Backdrop,
tabsCount: Int, tabsCount: Int,
isBlurEnabled: Boolean = true, isBlurEnabled: Boolean = true,
@@ -202,6 +203,8 @@ fun FloatingBottomBar(
animateToValue(targetIndex.toFloat()) animateToValue(targetIndex.toFloat())
if (targetIndex != selectedIndex()) { if (targetIndex != selectedIndex()) {
onSelected(targetIndex) onSelected(targetIndex)
} else {
onReselected(targetIndex)
} }
animationScope.launch { animationScope.launch {
offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f)) offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
@@ -273,7 +276,10 @@ fun FloatingBottomBar(
if (isBlurEnabled) { if (isBlurEnabled) {
vibrancy() vibrancy()
blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) 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 = { highlight = {
@@ -330,7 +336,10 @@ fun FloatingBottomBar(
val progress = dampedDragAnimation.pressProgress val progress = dampedDragAnimation.pressProgress
vibrancy() vibrancy()
blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) 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 = { highlight = {
@@ -10,13 +10,12 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox 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.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText 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.FloatingToolbar as MiuixFloatingToolbar
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.theme.MiuixTheme
data class SelectionActions( data class SelectionActions(
val primaryAction: ActionItem, val primaryAction: ActionItem,
@@ -92,13 +91,13 @@ fun SelectionBottomBar(
MiuixIconButton( MiuixIconButton(
onClick = primaryAction.onClick, onClick = primaryAction.onClick,
backgroundColor = MiuixTheme.colorScheme.primary, backgroundColor = MiuixTheme.colorScheme.secondaryContainer,
minWidth = 64.dp minWidth = 64.dp
) { ) {
MiuixIcon( MiuixIcon(
imageVector = primaryAction.icon, imageVector = primaryAction.icon,
contentDescription = null, contentDescription = null,
tint = MiuixTheme.colorScheme.onPrimary tint = MiuixTheme.colorScheme.onSecondaryContainer
) )
} }
@@ -155,14 +154,19 @@ fun SelectionBottomBar(
contentDescription = "More actions" contentDescription = "More actions"
) )
} }
DropdownMenu( RoundDropdownMenu(
expanded = showMenu, expanded = showMenu,
onDismissRequest = { showMenu = false } onDismissRequest = { showMenu = false }
) { ) {
secondaryActions.forEach { action -> secondaryActions.forEach { action ->
DropdownMenuItem( RoundDropdownMenuItem(
text = { AppText(action.text) }, text = action.text,
leadingIcon = action.icon?.let { { AppIcon(imageVector = it, contentDescription = null) } }, leadingIcon = {
AppIcon(
imageVector = action.icon,
contentDescription = null
)
},
onClick = { onClick = {
action.onClick() action.onClick()
showMenu = false showMenu = false
@@ -184,6 +188,10 @@ fun SelectionBottomBar(
FilledIconButton( FilledIconButton(
modifier = Modifier.width(64.dp), modifier = Modifier.width(64.dp),
onClick = primaryAction.onClick, onClick = primaryAction.onClick,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = LegadoTheme.colorScheme.secondaryContainer,
contentColor = LegadoTheme.colorScheme.onSecondaryContainer,
),
) { ) {
AppIcon(imageVector = primaryAction.icon, contentDescription = null) AppIcon(imageVector = primaryAction.icon, contentDescription = null)
} }
@@ -8,6 +8,11 @@ import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -43,6 +48,11 @@ fun SourceIcon(
} }
) { ) {
val context = LocalContext.current val context = LocalContext.current
var imageLoaded by remember(path) { mutableStateOf(false) }
LaunchedEffect(path) {
imageLoaded = false
}
Box( Box(
modifier = modifier.clip(MaterialTheme.shapes.medium), modifier = modifier.clip(MaterialTheme.shapes.medium),
@@ -51,6 +61,9 @@ fun SourceIcon(
if (path == null || (path is String && path.isEmpty())) { if (path == null || (path is String && path.isEmpty())) {
placeholderIcon() placeholderIcon()
} else { } else {
if (!imageLoaded) {
placeholderIcon()
}
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
.data(path) .data(path)
@@ -61,8 +74,10 @@ fun SourceIcon(
imageLoader = imageLoader, imageLoader = imageLoader,
contentDescription = null, contentDescription = null,
contentScale = contentScale, // 不裁切 contentScale = contentScale, // 不裁切
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
onSuccess = { imageLoaded = true },
onError = { imageLoaded = false }
) )
} }
} }
} }
@@ -19,16 +19,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.EmptyMessage 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.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.LogUtils import io.legado.app.utils.LogUtils
import splitties.init.appCtx
import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -80,93 +78,25 @@ fun AppLogSheet(
private data class LogEntry( private data class LogEntry(
val time: Long, val time: Long,
val message: String, val message: String,
val isCrash: Boolean = false,
) { ) {
val content: String get() = message val content: String get() = message
} }
/**
* 从本地日志文件加载所有日志(应用日志 + 崩溃日志)
*/
private fun loadAllLogs(): List<LogEntry> { private fun loadAllLogs(): List<LogEntry> {
val entries = mutableListOf<LogEntry>() return AppLog.logs.map { (time, message, throwable) ->
loadAppLogFiles(entries) LogEntry(
loadCrashLogFiles(entries) time = time,
return entries.sortedByDescending { it.time } message = if (throwable == null) {
} message
} else {
/** "$message\n${throwable.stackTraceToString()}"
* 从 externalCacheDir/logs/ 读取应用日志文件
* 文件格式: appLog-{date}.txt, 每行格式: yy-MM-dd HH:mm:ss.SSS: message
*/
private fun loadAppLogFiles(entries: MutableList<LogEntry>) {
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) }
} }
} )
} }
} }
/**
* 从 externalCacheDir/crash/ 读取崩溃日志文件
*/
private fun loadCrashLogFiles(entries: MutableList<LogEntry>) {
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() { private fun clearAllLogs() {
// 清除应用日志 AppLog.clear()
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() }
}
} }
@Composable @Composable
@@ -182,28 +112,15 @@ private fun LogItem(
) { ) {
AppText( AppText(
text = buildString { text = buildString {
if (item.isCrash) append("[崩溃] ")
if (item.time > 0) append(LogUtils.logTimeFormat.format(Date(item.time))) if (item.time > 0) append(LogUtils.logTimeFormat.format(Date(item.time)))
}, },
style = LegadoTheme.typography.labelMedium, style = LegadoTheme.typography.labelMedium,
color = if (item.isCrash) { color = LegadoTheme.colorScheme.outline
LegadoTheme.colorScheme.error
} else {
LegadoTheme.colorScheme.outline
}
) )
AppText( AppText(
text = if (item.isCrash) { text = item.message,
item.message.lineSequence().firstOrNull().orEmpty()
} else {
item.message
},
style = LegadoTheme.typography.bodyMedium, style = LegadoTheme.typography.bodyMedium,
color = if (item.isCrash) { color = LegadoTheme.colorScheme.onSurface,
LegadoTheme.colorScheme.error.copy(alpha = 0.8f)
} else {
LegadoTheme.colorScheme.onSurface
},
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
maxLines = 2, maxLines = 2,
) )