Merge pull request #1150 from HapeLee/feature/homepage-modules

Feature/homepage modules
This commit is contained in:
Kudomaga
2026-05-23 04:08:14 +08:00
committed by GitHub
62 changed files with 1925 additions and 1003 deletions
+2 -1
View File
@@ -101,7 +101,8 @@
"Read(//c/Users/**)", "Read(//c/Users/**)",
"Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)",
"Bash(Select-Object Name)", "Bash(Select-Object Name)",
"PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })" "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })",
"Bash(gh pr *)"
] ]
} }
} }
@@ -45,6 +45,7 @@ object PreferKey {
const val prevKeys = "prevKeyCodes" const val prevKeys = "prevKeyCodes"
const val nextKeys = "nextKeyCodes" const val nextKeys = "nextKeyCodes"
const val showDiscovery = "showDiscovery" const val showDiscovery = "showDiscovery"
const val showHome = "showHome"
const val enableReview = "enableReview" const val enableReview = "enableReview"
const val showRss = "showRss" const val showRss = "showRss"
const val showStatusBar = "showStatusBar" const val showStatusBar = "showStatusBar"
@@ -0,0 +1,13 @@
package io.legado.app.data.local.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name = "local_ui_status")
object LocalPreferencesKeys {
val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
}
@@ -0,0 +1,35 @@
package io.legado.app.data.local.preferences
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
class LocalPreferencesRepository(private val context: Context) {
private val dataStore = context.localDataStore
fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
return dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences[key] ?: defaultValue
}
}
suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
dataStore.edit { preferences ->
preferences[key] = value
}
}
}
@@ -6,6 +6,7 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import io.legado.app.data.AppDatabase import io.legado.app.data.AppDatabase
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.AppStartupRepository import io.legado.app.data.repository.AppStartupRepository
import io.legado.app.data.repository.BookCacheCleanupRepository import io.legado.app.data.repository.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl import io.legado.app.data.repository.BookDomainRepositoryImpl
@@ -137,6 +138,7 @@ val appModule = module {
singleOf(::SearchContentRepository) singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository) singleOf(::RemoteBookRepository)
singleOf(::SettingsRepository) singleOf(::SettingsRepository)
singleOf(::LocalPreferencesRepository)
singleOf(::ExploreBooksUseCase) singleOf(::ExploreBooksUseCase)
singleOf(::ExploreKindUiUseCase) singleOf(::ExploreKindUiUseCase)
singleOf(::SaveSearchBooksUseCase) singleOf(::SaveSearchBooksUseCase)
@@ -3,6 +3,8 @@ package io.legado.app.domain.usecase
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.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ExploreBooksUseCase( class ExploreBooksUseCase(
private val bookSourceRepository: BookSourceRepository, private val bookSourceRepository: BookSourceRepository,
@@ -20,7 +22,7 @@ class ExploreBooksUseCase(
moduleUrl: String?, moduleUrl: String?,
args: String?, args: String?,
page: Int = 1 page: Int = 1
): ExploreResult { ): ExploreResult = withContext(Dispatchers.IO) {
val base = bookSourceRepository.getBookSource(sourceUrl) val base = bookSourceRepository.getBookSource(sourceUrl)
?: throw SourceNotFound(sourceUrl) ?: throw SourceNotFound(sourceUrl)
val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
@@ -33,14 +35,14 @@ class ExploreBooksUseCase(
throw InvalidUrl(resolvedUrl) throw InvalidUrl(resolvedUrl)
} }
val books = WebBook.exploreBookSuspend(source, resolvedUrl, page) val books = WebBook.exploreBookSuspend(source, resolvedUrl, page)
return ExploreResult(resolvedUrl, books) ExploreResult(resolvedUrl, books)
} }
suspend fun executeForRanking( suspend fun executeForRanking(
sourceUrl: String, sourceUrl: String,
moduleUrl: String?, moduleUrl: String?,
args: String? args: String?
): List<SearchBook> { ): List<SearchBook> = withContext(Dispatchers.IO) {
val result = execute(sourceUrl, moduleUrl, args) val result = execute(sourceUrl, moduleUrl, args)
var books = result.books var books = result.books
var page = 1 var page = 1
@@ -50,7 +52,7 @@ class ExploreBooksUseCase(
WebBook.exploreBookSuspend( WebBook.exploreBookSuspend(
bookSourceRepository.getBookSource(sourceUrl) bookSourceRepository.getBookSource(sourceUrl)
?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s } ?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s }
?: return books.take(MAX_RANKING_BOOKS), ?: return@withContext books.take(MAX_RANKING_BOOKS),
result.resolvedUrl, result.resolvedUrl,
page, page,
) )
@@ -60,7 +62,7 @@ class ExploreBooksUseCase(
if (next.isEmpty()) break if (next.isEmpty()) break
books = (books + next) books = (books + next)
} }
return books.take(MAX_RANKING_BOOKS) books.take(MAX_RANKING_BOOKS)
} }
data class ExploreResult(val resolvedUrl: String, val books: List<SearchBook>) data class ExploreResult(val resolvedUrl: String, val books: List<SearchBook>)
@@ -99,9 +99,10 @@ class ExploreKindUiUseCase(
} }
} }
private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? { private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? =
val source = getOrLoadBookSource(sourceUrl) ?: return null withContext(Dispatchers.IO) {
return runScriptWithContext { val source = getOrLoadBookSource(sourceUrl) ?: return@withContext null
runScriptWithContext {
source.evalJS(jsStr) { source.evalJS(jsStr) {
put("infoMap", infoMap) put("infoMap", infoMap)
}?.toString() }?.toString()
@@ -306,6 +306,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val showDiscovery: Boolean val showDiscovery: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true) get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true)
val showHome: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showHome, true)
val showRSS: Boolean val showRSS: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showRss, true) get() = appCtx.getPrefBoolean(PreferKey.showRss, true)
@@ -3,12 +3,8 @@ package io.legado.app.help.config
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.inputStream
import io.legado.app.utils.outputStream
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
@@ -175,6 +171,7 @@ object ThemeImportExport {
customTagColorsJson = ThemeConfig.customTagColorsJson, customTagColorsJson = ThemeConfig.customTagColorsJson,
// 主界面设置 // 主界面设置
showHome = ThemeConfig.showHome,
showDiscovery = ThemeConfig.showDiscovery, showDiscovery = ThemeConfig.showDiscovery,
showRss = ThemeConfig.showRss, showRss = ThemeConfig.showRss,
showStatusBar = ThemeConfig.showStatusBar, showStatusBar = ThemeConfig.showStatusBar,
@@ -258,6 +255,7 @@ object ThemeImportExport {
ThemeConfig.customTagColorsJson = data.customTagColorsJson ThemeConfig.customTagColorsJson = data.customTagColorsJson
// 主界面设置 // 主界面设置
ThemeConfig.showHome = data.showHome
ThemeConfig.showDiscovery = data.showDiscovery ThemeConfig.showDiscovery = data.showDiscovery
ThemeConfig.showRss = data.showRss ThemeConfig.showRss = data.showRss
ThemeConfig.showStatusBar = data.showStatusBar ThemeConfig.showStatusBar = data.showStatusBar
@@ -394,6 +392,7 @@ data class ThemeExportData(
val customTagColorsJson: String? = null, val customTagColorsJson: String? = null,
// 主界面设置 // 主界面设置
val showHome: Boolean = true,
val showDiscovery: Boolean = true, val showDiscovery: Boolean = true,
val showRss: Boolean = true, val showRss: Boolean = true,
val showStatusBar: Boolean = true, val showStatusBar: Boolean = true,
@@ -86,7 +86,9 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
context = requireContext(), context = requireContext(),
name = it.name, name = it.name,
author = it.author, author = it.author,
bookUrl = it.bookUrl bookUrl = it.bookUrl,
origin = it.origin,
coverPath = it.coverUrl
) )
) )
dismiss() dismiss()
@@ -1,8 +1,6 @@
package io.legado.app.ui.book.explore package io.legado.app.ui.book.explore
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
@@ -13,8 +11,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -27,9 +23,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
@@ -52,9 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
@@ -69,13 +63,11 @@ import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.book.SearchBookGridItem import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.book.SearchBookListItem import io.legado.app.ui.widget.components.book.SearchBookListItem
import io.legado.app.ui.widget.components.button.AnimatedTextButton
import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
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.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -98,7 +90,7 @@ fun ExploreShowScreen(
sourceUrl: String?, sourceUrl: String?,
exploreUrl: String?, exploreUrl: String?,
onBack: () -> Unit, onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit, onBookClick: (SearchBook, String?) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel(), viewModel: ExploreShowViewModel = koinViewModel(),
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -111,7 +103,6 @@ fun ExploreShowScreen(
val books by viewModel.uiBooks.collectAsState() val books by viewModel.uiBooks.collectAsState()
val isBookEnd by viewModel.isEnd.collectAsState() val isBookEnd by viewModel.isEnd.collectAsState()
val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState() val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
val kinds by viewModel.kinds.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState()
val filterState by viewModel.filterState.collectAsState() val filterState by viewModel.filterState.collectAsState()
@@ -125,8 +116,6 @@ fun ExploreShowScreen(
var showGridCountSheet by remember { mutableStateOf(false) } var showGridCountSheet by remember { mutableStateOf(false) }
val gridColumnCount by viewModel.gridCount.collectAsState() val gridColumnCount by viewModel.gridCount.collectAsState()
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val context = LocalContext.current
val activity = context as? AppCompatActivity
val exploreKindUseCase: ExploreKindUiUseCase = koinInject() val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) { LaunchedEffect(sourceUrl) {
@@ -233,73 +222,16 @@ fun ExploreShowScreen(
} }
AppModalBottomSheet( ExploreKindSelectSheet(
show = showKindSheet, show = showKindSheet,
onDismissRequest = { showKindSheet = false } onDismissRequest = { showKindSheet = false },
) { sourceUrl = sourceUrl,
onSelected = { selectedKinds ->
var kindQuery by remember { mutableStateOf("") } selectedKinds.firstOrNull()?.let { kind ->
viewModel.switchExploreUrl(kind)
SearchBar(
query = kindQuery,
backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
onQueryChange = { kindQuery = it },
placeholder = "选择或搜索分类",
)
val filteredKinds = remember(kindQuery, kinds) {
if (kindQuery.isBlank()) kinds
else kinds.filter { kind ->
kind.title.contains(kindQuery, ignoreCase = true) ||
(kind.url?.contains(kindQuery, ignoreCase = true) == true)
} }
} }
val kindRows = remember(filteredKinds) { )
calculateExploreKindRows(filteredKinds, 6)
}
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier.weight(1f, fill = false)
) {
items(kindRows) { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.animateItem()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
ExploreKindMultiTypeItem(
modifier = Modifier
.weight(span.toFloat())
.animateItem(),
kind = kind,
sourceUrl = sourceUrl,
activity = activity,
onOpenUrl = { url ->
showKindSheet = false
viewModel.switchExploreUrl(kind.copy(url = url))
},
onRefreshKinds = viewModel::refreshKinds,
backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
isMiuix = isMiuix,
useCase = exploreKindUseCase
)
}
val totalSpan = rowItems.sumOf { it.second }
if (totalSpan < 6) {
Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat())
)
}
}
}
}
}
AppScaffold( AppScaffold(
modifier = Modifier modifier = Modifier
@@ -433,17 +365,22 @@ fun ExploreShowScreen(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items( itemsIndexed(
items = books, items = books,
key = { it.book.bookUrl } key = { index, item -> "${item.book.bookUrl}:$index" }
) { item -> ) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"explore:grid:$index"
)
ExploreBookGridItem( ExploreBookGridItem(
book = item.book, book = item.book,
shelfState = item.shelfState, shelfState = item.shelfState,
onClick = { onBookClick(item.book) }, onClick = { onBookClick(item.book, sharedCoverKey) },
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
) )
} }
@@ -467,17 +404,22 @@ fun ExploreShowScreen(
bottom = paddingValues.calculateBottomPadding() + 16.dp bottom = paddingValues.calculateBottomPadding() + 16.dp
) )
) { ) {
items( itemsIndexed(
items = books, items = books,
key = { it.book.bookUrl } key = { index, item -> "${item.book.bookUrl}:$index" }
) { item -> ) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"explore:list:$index"
)
ExploreBookItem( ExploreBookItem(
book = item.book, book = item.book,
shelfState = item.shelfState, shelfState = item.shelfState,
onClick = { onBookClick(item.book) }, onClick = { onBookClick(item.book, sharedCoverKey) },
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
) )
} }
@@ -506,6 +448,7 @@ fun ExploreBookItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) { ) {
SearchBookListItem( SearchBookListItem(
book = book, book = book,
@@ -514,7 +457,7 @@ fun ExploreBookItem(
modifier = modifier, modifier = modifier,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
@@ -527,6 +470,7 @@ fun ExploreBookGridItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) { ) {
SearchBookGridItem( SearchBookGridItem(
book = book, book = book,
@@ -535,60 +479,7 @@ fun ExploreBookGridItem(
modifier = modifier, modifier = modifier,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LoadMoreFooter(
isLoading: Boolean,
errorMsg: String?,
isEnd: Boolean,
onRetry: () -> Unit
) {
LaunchedEffect(isLoading, errorMsg, isEnd) {
if (!isLoading && errorMsg == null && !isEnd) {
onRetry()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AnimatedContent(
targetState = when {
isLoading -> "加载中…"
errorMsg != null -> "加载失败: $errorMsg"
isEnd -> "已经到底了~"
else -> "我爱你"
},
label = "FooterTextChange"
) { text ->
AppText(
text = text,
color = when {
errorMsg != null -> Color.Red
else -> Color.Gray
},
style = LegadoTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedTextButton(
isLoading = isLoading,
onClick = onRetry,
text = if (errorMsg != null) "重试" else "再试一次",
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
@@ -13,6 +13,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.domain.usecase.SaveSearchBooksUseCase
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.utils.exploreLayoutGrid import io.legado.app.utils.exploreLayoutGrid
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -222,7 +223,7 @@ class ExploreShowViewModel(
} }
} }
.onFailure { .onFailure {
_errorMsg.value = it.localizedMessage _errorMsg.value = it.stackTraceStr
} }
_isLoading.value = false _isLoading.value = false
@@ -40,6 +40,10 @@ import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun BookInfoRouteScreen( fun BookInfoRouteScreen(
bookUrl: String, bookUrl: String,
name: String? = null,
author: String? = null,
origin: String? = null,
coverPath: String? = null,
viewModel: BookInfoViewModel, viewModel: BookInfoViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
@@ -80,8 +84,14 @@ fun BookInfoRouteScreen(
viewModel.onReaderResult(it.resultCode) viewModel.onReaderResult(it.resultCode)
} }
LaunchedEffect(bookUrl, viewModel) { LaunchedEffect(bookUrl, name, author, origin, coverPath, viewModel) {
viewModel.initData(bookUrl) viewModel.initData(
bookUrl = bookUrl,
name = name,
author = author,
origin = origin,
coverPath = coverPath
)
} }
DisposableEffect(viewModel) { DisposableEffect(viewModel) {
@@ -50,6 +50,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.main.MainIntent
import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
@@ -113,12 +114,37 @@ class BookInfoViewModel(
private var readRecordObserveJob: Job? = null private var readRecordObserveJob: Job? = null
fun initData(intent: Intent) { fun initData(intent: Intent) {
initData(intent.getStringExtra("bookUrl") ?: "") initData(
bookUrl = intent.getStringExtra(MainIntent.EXTRA_BOOK_URL) ?: "",
name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME),
author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR),
origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN),
coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER)
)
} }
fun initData(bookUrl: String) { fun initData(
bookUrl: String,
name: String? = null,
author: String? = null,
origin: String? = null,
coverPath: String? = null
) {
if (currentBook?.bookUrl == bookUrl) return if (currentBook?.bookUrl == bookUrl) return
currentBook = null _uiState.value = BookInfoUiState() // 立即重置 UI 状态
currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
Book(
bookUrl = bookUrl,
name = name,
author = author,
origin = origin ?: BookType.localTag,
coverUrl = coverPath
).apply {
addType(BookType.notShelf)
}
} else {
null
}
currentChapterList = emptyList() currentChapterList = emptyList()
currentWebFiles = emptyList() currentWebFiles = emptyList()
currentKindLabels = emptyList() currentKindLabels = emptyList()
@@ -128,24 +154,32 @@ class BookInfoViewModel(
bookSource = null bookSource = null
chapterChanged = false chapterChanged = false
clearReadRecordObserve() clearReadRecordObserve()
_uiState.value = BookInfoUiState() syncUiState()
execute { execute {
val book = appDb.bookDao.getBook(bookUrl)?.let { val dbBook = appDb.bookDao.getBook(bookUrl)
inBookshelf = !it.isNotShelf if (dbBook != null) {
it inBookshelf = !dbBook.isNotShelf
} ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { dbBook
inBookshelf = false } else {
it val searchBook = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()
} ?: throw NoStackTraceException("未找到书籍") if (searchBook != null) {
inBookshelf = false
searchBook
} else {
currentBook ?: throw NoStackTraceException("未找到书籍")
}
}
}.onSuccess { book ->
// 如果从数据库/搜索中拿到的书没有封面,但我们有传入的封面,则保留传入的封面
if (book.coverUrl.isNullOrBlank() && !coverPath.isNullOrBlank()) {
book.coverUrl = coverPath
}
val source = if (book.isLocal) { val source = if (book.isLocal) {
null null
} else { } else {
appDb.bookSourceDao.getBookSource(book.origin) appDb.bookSourceDao.getBookSource(book.origin)
} }
book to source upBook(book, source)
}.onSuccess {
upBook(it.first, it.second)
}.onError { }.onError {
context.toastOnUi(it.localizedMessage ?: "未找到书籍") context.toastOnUi(it.localizedMessage ?: "未找到书籍")
emitEffect(BookInfoEffect.Finish(afterTransition = true)) emitEffect(BookInfoEffect.Finish(afterTransition = true))
@@ -40,13 +40,15 @@ class SearchActivity : BaseComposeActivity() {
SearchScreen( SearchScreen(
viewModel = viewModel, viewModel = viewModel,
onBack = { finish() }, onBack = { finish() },
onOpenBookInfo = { name, author, bookUrl -> onOpenBookInfo = { name, author, bookUrl, origin, coverPath, _ ->
startActivity( startActivity(
MainActivity.createBookInfoIntent( MainActivity.createBookInfoIntent(
context = this, context = this,
name = name, name = name,
author = author, author = author,
bookUrl = bookUrl bookUrl = bookUrl,
origin = origin,
coverPath = coverPath
) )
) )
}, },
@@ -58,7 +58,7 @@ sealed interface SearchIntent {
data object PauseEngine : SearchIntent data object PauseEngine : SearchIntent
data object ResumeEngine : SearchIntent data object ResumeEngine : SearchIntent
data class UseHistoryKeyword(val keyword: String) : SearchIntent data class UseHistoryKeyword(val keyword: String) : SearchIntent
data class OpenSearchBook(val book: SearchBook) : SearchIntent data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
data class DeleteHistory(val item: SearchKeyword) : SearchIntent data class DeleteHistory(val item: SearchKeyword) : SearchIntent
data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
@@ -82,6 +82,9 @@ sealed interface SearchEffect {
val name: String, val name: String,
val author: String, val author: String,
val bookUrl: String, val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String?,
) : SearchEffect ) : SearchEffect
data object OpenSourceManage : SearchEffect data object OpenSourceManage : SearchEffect
@@ -90,7 +90,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
fun SearchScreen( fun SearchScreen(
viewModel: SearchViewModel, viewModel: SearchViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit, onOpenBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
onOpenSourceManage: () -> Unit, onOpenSourceManage: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -160,7 +160,14 @@ fun SearchScreen(
viewModel.effects.collect { effect -> viewModel.effects.collect { effect ->
when (effect) { when (effect) {
is SearchEffect.OpenBookInfo -> { is SearchEffect.OpenBookInfo -> {
onOpenBookInfo(effect.name, effect.author, effect.bookUrl) onOpenBookInfo(
effect.name,
effect.author,
effect.bookUrl,
effect.origin,
effect.coverPath,
effect.sharedCoverKey
)
} }
SearchEffect.OpenSourceManage -> onOpenSourceManage() SearchEffect.OpenSourceManage -> onOpenSourceManage()
@@ -394,16 +401,25 @@ fun SearchScreen(
itemsIndexed( itemsIndexed(
items = state.results, items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { _, item -> ) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
)
SearchBookListItem( SearchBookListItem(
book = item.book, book = item.book,
shelfState = item.shelfState, shelfState = item.shelfState,
onClick = { onClick = {
viewModel.onIntent(SearchIntent.OpenSearchBook(item.book)) viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
}, },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(item.book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
@@ -15,7 +15,6 @@ import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefBoolean
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -107,6 +106,9 @@ class SearchViewModel(
name = intent.book.name, name = intent.book.name,
author = intent.book.author, author = intent.book.author,
bookUrl = intent.book.bookUrl, bookUrl = intent.book.bookUrl,
origin = intent.book.origin,
coverPath = intent.book.coverUrl,
sharedCoverKey = intent.sharedCoverKey,
) )
) )
} }
@@ -117,6 +119,9 @@ class SearchViewModel(
name = intent.book.name, name = intent.book.name,
author = intent.book.author, author = intent.book.author,
bookUrl = intent.book.bookUrl, bookUrl = intent.book.bookUrl,
origin = intent.book.origin,
coverPath = intent.book.getDisplayCover(),
sharedCoverKey = null,
) )
) )
} }
@@ -141,6 +141,8 @@ object ThemeConfig {
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showHome by prefDelegate(PreferKey.showHome, true)
var showRss by prefDelegate(PreferKey.showRss, true) var showRss by prefDelegate(PreferKey.showRss, true)
var showStatusBar by prefDelegate(PreferKey.showStatusBar, true) var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
@@ -20,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -47,7 +46,6 @@ import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -57,7 +55,6 @@ import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -73,31 +70,29 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import com.google.android.material.color.DynamicColors import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.material.color.DynamicColorsOptions
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.AppContextWrapper import io.legado.app.base.AppContextWrapper
import io.legado.app.constant.PreferKey
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.help.LauncherIconHelp import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.loadFontFiles
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig import io.legado.app.help.config.OldThemeConfig
import io.legado.app.lib.theme.ThemeStore import io.legado.app.help.loadFontFiles
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeEngine import io.legado.app.ui.theme.ThemeEngine
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.theme.adaptiveContentPadding
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.SplicedColumnGroup import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.button.SmallIconButton 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.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.SliderSettingItem import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
@@ -105,7 +100,7 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.utils.FileDoc import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.utils.getPrefString import io.legado.app.utils.getPrefString
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.putPrefString import io.legado.app.utils.putPrefString
@@ -113,10 +108,6 @@ import io.legado.app.utils.restart
import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.takePersistablePermissionSafely
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Card as MiuixCard
import top.yukonga.miuix.kmp.basic.CardDefaults as MiuixCardDefaults
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@@ -138,16 +129,16 @@ fun ThemeConfigScreen(
var showBorderColorPicker by remember { mutableStateOf(false) } var showBorderColorPicker by remember { mutableStateOf(false) }
var showNavIconSheet by remember { mutableStateOf(false) } var showNavIconSheet by remember { mutableStateOf(false) }
var showFontSheet by remember { mutableStateOf(false) } var showFontSheet by remember { mutableStateOf(false) }
var fontItems by remember { mutableStateOf<List<FileDoc>>(emptyList()) } val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
var fontFolderUri by remember { mutableStateOf<Uri?>(null) }
fun loadFonts() { var fontFolderUri by remember {
fontItems = loadFontFiles(context, fontFolderUri) mutableStateOf(
context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) }
)
} }
remember {
val saved = context.getPrefString(PreferKey.fontFolder) val fontItems = remember(fontFolderUri) {
if (!saved.isNullOrEmpty()) fontFolderUri = Uri.parse(saved) loadFontFiles(context, fontFolderUri)
loadFonts()
} }
val fontFolderLauncher = rememberLauncherForActivityResult( val fontFolderLauncher = rememberLauncherForActivityResult(
@@ -157,7 +148,6 @@ fun ThemeConfigScreen(
fontFolderUri = uri fontFolderUri = uri
uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION) uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.putPrefString(PreferKey.fontFolder, uri.toString()) context.putPrefString(PreferKey.fontFolder, uri.toString())
loadFonts()
} }
} }
@@ -218,16 +208,28 @@ fun ThemeConfigScreen(
themeItems.zip(themeValues).toList() themeItems.zip(themeValues).toList()
} }
if (isMiuixEngine) { AnimatedVisibility(visible = showThemeRefactorTip) {
MiuixCard( GlassCard(
cornerRadius = 16.dp, cornerRadius = 16.dp,
insideMargin = PaddingValues(16.dp), modifier = Modifier.padding(bottom = 16.dp)
colors = MiuixCardDefaults.defaultColors(
color = MiuixTheme.colorScheme.primaryVariant,
contentColor = MiuixTheme.colorScheme.onPrimary
)
) { ) {
MiuixText(stringResource(R.string.theme_config_miuix_experimental_warning)) Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
AppText(
text = "仍有部分界面未用Compose重构,这些界面会与大部分界面有较大差异。",
style = LegadoTheme.typography.labelLargeEmphasized,
modifier = Modifier.weight(1f)
)
SmallIconButton(
imageVector = AppIcons.Close,
contentDescription = "关闭",
onClick = {
viewModel.setShowThemeRefactorTip(false)
}
)
}
} }
} }
@@ -376,6 +378,11 @@ fun ThemeConfigScreen(
} }
SplicedColumnGroup(title = stringResource(R.string.main_activity)) { SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
SwitchSettingItem(
title = stringResource(R.string.show_home),
checked = ThemeConfig.showHome,
onCheckedChange = { ThemeConfig.showHome = it }
)
SwitchSettingItem( SwitchSettingItem(
title = stringResource(R.string.show_discovery), title = stringResource(R.string.show_discovery),
checked = ThemeConfig.showDiscovery, checked = ThemeConfig.showDiscovery,
@@ -648,7 +655,11 @@ fun ThemeConfigScreen(
.size(28.dp) .size(28.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(ThemeConfig.itemDividerColor)) .background(Color(ThemeConfig.itemDividerColor))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) .border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape
)
) )
} }
} }
@@ -771,7 +782,9 @@ fun ThemeConfigScreen(
content = { content = {
if (fontItems.isEmpty()) { if (fontItems.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxWidth().height(120.dp), modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@@ -789,7 +802,9 @@ fun ThemeConfigScreen(
fontItems.forEach { fontDoc -> fontItems.forEach { fontDoc ->
item { item {
Card( Card(
modifier = Modifier.fillMaxWidth().height(100.dp), modifier = Modifier
.fillMaxWidth()
.height(100.dp),
onClick = { onClick = {
ThemeConfig.appFontPath = fontDoc.uri.toString() ThemeConfig.appFontPath = fontDoc.uri.toString()
showFontSheet = false showFontSheet = false
@@ -2,19 +2,40 @@ package io.legado.app.ui.config.themeConfig
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.utils.FileDoc import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils import io.legado.app.utils.MD5Utils
import io.legado.app.utils.externalFiles import io.legado.app.utils.externalFiles
import io.legado.app.utils.inputStream import io.legado.app.utils.inputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
class ThemeConfigViewModel : ViewModel() { class ThemeConfigViewModel(
private val localPreferencesRepository: LocalPreferencesRepository
) : ViewModel() {
val showThemeRefactorTip = localPreferencesRepository
.getPreference(LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP, true)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
fun setShowThemeRefactorTip(show: Boolean) {
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP,
show
)
}
}
/** /**
* 设置背景图片 * 设置背景图片
@@ -31,7 +31,6 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.help.config.ThemeExportData import io.legado.app.help.config.ThemeExportData
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -164,6 +163,11 @@ fun EditThemeSheet(
// Interface layout // Interface layout
SectionTitle(stringResource(R.string.theme_manage_section_layout)) SectionTitle(stringResource(R.string.theme_manage_section_layout))
CompactSwitchSettingItem(
title = "首页",
checked = data.showHome,
onCheckedChange = { data = data.copy(showHome = it) }
)
CompactSwitchSettingItem( CompactSwitchSettingItem(
title = stringResource(R.string.theme_manage_show_discovery), title = stringResource(R.string.theme_manage_show_discovery),
checked = data.showDiscovery, checked = data.showDiscovery,
@@ -1,3 +1,6 @@
package io.legado.app.ui.main package io.legado.app.ui.main
fun bookCoverSharedElementKey(bookUrl: String): String = "book-cover:$bookUrl" fun bookCoverSharedElementKey(bookUrl: String, sourceId: String? = null): String {
val source = sourceId?.takeIf { it.isNotBlank() } ?: return "book-cover:$bookUrl"
return "book-cover:$source:$bookUrl"
}
@@ -100,8 +100,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
context: Context, context: Context,
name: String? = null, name: String? = null,
author: String? = null, author: String? = null,
bookUrl: String bookUrl: String,
): Intent = MainIntent.createBookInfoIntent(context, name, author, bookUrl) origin: String? = null,
coverPath: String? = null
): Intent =
MainIntent.createBookInfoIntent(context, name, author, bookUrl, origin, coverPath)
fun createExploreShowIntent( fun createExploreShowIntent(
context: Context, context: Context,
@@ -12,6 +12,8 @@ object MainIntent {
const val EXTRA_BOOK_NAME = "name" const val EXTRA_BOOK_NAME = "name"
const val EXTRA_BOOK_AUTHOR = "author" const val EXTRA_BOOK_AUTHOR = "author"
const val EXTRA_BOOK_URL = "bookUrl" const val EXTRA_BOOK_URL = "bookUrl"
const val EXTRA_BOOK_ORIGIN = "origin"
const val EXTRA_BOOK_COVER = "coverPath"
const val EXTRA_EXPLORE_NAME = "exploreName" const val EXTRA_EXPLORE_NAME = "exploreName"
const val EXTRA_SOURCE_URL = "sourceUrl" const val EXTRA_SOURCE_URL = "sourceUrl"
const val EXTRA_EXPLORE_URL = "exploreUrl" const val EXTRA_EXPLORE_URL = "exploreUrl"
@@ -114,13 +116,17 @@ object MainIntent {
context: Context, context: Context,
name: String? = null, name: String? = null,
author: String? = null, author: String? = null,
bookUrl: String bookUrl: String,
origin: String? = null,
coverPath: String? = null
): Intent { ): Intent {
return createLauncherIntent(context).apply { return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_INFO) putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_INFO)
putExtra(EXTRA_BOOK_NAME, name) putExtra(EXTRA_BOOK_NAME, name)
putExtra(EXTRA_BOOK_AUTHOR, author) putExtra(EXTRA_BOOK_AUTHOR, author)
putExtra(EXTRA_BOOK_URL, bookUrl) putExtra(EXTRA_BOOK_URL, bookUrl)
putExtra(EXTRA_BOOK_ORIGIN, origin)
putExtra(EXTRA_BOOK_COVER, coverPath)
} }
} }
@@ -90,12 +90,15 @@ fun MainActivity.mainEntryProvider(
onNavigateToBookCacheManage = { onNavigateToBookCacheManage = {
onNavigateToRoute(MainRouteBookCacheManage) onNavigateToRoute(MainRouteBookCacheManage)
}, },
onNavigateToBookInfo = { name, author, bookUrl -> onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToRoute( onNavigateToRoute(
MainRouteBookInfo( MainRouteBookInfo(
name = name, name = name,
author = author, author = author,
bookUrl = bookUrl bookUrl = bookUrl,
origin = origin,
coverPath = coverPath,
sharedCoverKey = sharedCoverKey
) )
) )
}, },
@@ -246,12 +249,15 @@ fun MainActivity.mainEntryProvider(
searchViewModel.onIntent(SearchIntent.ClearSearchResults) searchViewModel.onIntent(SearchIntent.ClearSearchResults)
onNavigateBack() onNavigateBack()
}, },
onOpenBookInfo = { name, author, bookUrl -> onOpenBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToRoute( onNavigateToRoute(
MainRouteBookInfo( MainRouteBookInfo(
name = name, name = name,
author = author, author = author,
bookUrl = bookUrl bookUrl = bookUrl,
origin = origin,
coverPath = coverPath,
sharedCoverKey = sharedCoverKey
) )
) )
}, },
@@ -395,9 +401,13 @@ fun MainActivity.mainEntryProvider(
} else null } else null
} }
) { route -> ) { route ->
val bookInfoViewModel = koinViewModel<BookInfoViewModel>() val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = route.bookUrl)
BookInfoRouteScreen( BookInfoRouteScreen(
bookUrl = route.bookUrl, bookUrl = route.bookUrl,
name = route.name,
author = route.author,
origin = route.origin,
coverPath = route.coverPath,
viewModel = bookInfoViewModel, viewModel = bookInfoViewModel,
onBack = { onNavigateBack() }, onBack = { onNavigateBack() },
onFinish = { _, _ -> onNavigateBack() }, onFinish = { _, _ -> onNavigateBack() },
@@ -406,7 +416,7 @@ fun MainActivity.mainEntryProvider(
}, },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = LocalNavAnimatedContentScope.current, animatedVisibilityScope = LocalNavAnimatedContentScope.current,
sharedCoverKey = bookCoverSharedElementKey(route.bookUrl), sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
onRegisterVariableSetter = { setter -> onRegisterVariableSetter = { setter ->
onRegisterVariableSetter(setter) onRegisterVariableSetter(setter)
} }
@@ -419,12 +429,15 @@ fun MainActivity.mainEntryProvider(
sourceUrl = route.sourceUrl, sourceUrl = route.sourceUrl,
exploreUrl = route.exploreUrl, exploreUrl = route.exploreUrl,
onBack = { onNavigateBack() }, onBack = { onNavigateBack() },
onBookClick = { book -> onBookClick = { book, sharedCoverKey ->
onNavigateToRoute( onNavigateToRoute(
MainRouteBookInfo( MainRouteBookInfo(
name = book.name, name = book.name,
author = book.author, author = book.author,
bookUrl = book.bookUrl bookUrl = book.bookUrl,
origin = book.origin,
coverPath = book.coverUrl,
sharedCoverKey = sharedCoverKey
) )
) )
}, },
@@ -65,6 +65,9 @@ data class MainRouteBookInfo(
val name: String?, val name: String?,
val author: String?, val author: String?,
val bookUrl: String, val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String? = null,
) : MainRoute ) : MainRoute
@Serializable @Serializable
@@ -254,7 +254,9 @@ object MainNavigator {
MainRouteBookInfo( MainRouteBookInfo(
name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME), name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME),
author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR), author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR),
bookUrl = bookUrl bookUrl = bookUrl,
origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN),
coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER)
) )
} ?: MainRouteHome } ?: MainRouteHome
@@ -110,7 +110,7 @@ fun MainScreen(
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit, onNavigateToCache: (Long) -> Unit,
onNavigateToBookCacheManage: () -> Unit, onNavigateToBookCacheManage: () -> Unit,
onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit, onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit, onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
@@ -368,8 +368,15 @@ fun MainScreen(
val destination = destinations.getOrNull(page) ?: return@HorizontalPager val destination = destinations.getOrNull(page) ?: return@HorizontalPager
when (destination) { when (destination) {
MainDestination.Home -> HomepageScreen( MainDestination.Home -> HomepageScreen(
onBookClick = { name, author, bookUrl -> onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToBookInfo(name ?: "", author ?: "", bookUrl) onNavigateToBookInfo(
name ?: "",
author ?: "",
bookUrl,
origin,
coverPath,
sharedCoverKey
)
}, },
onModuleHeaderClick = { title, sourceUrl, exploreUrl -> onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
onNavigateToExploreShow(title, sourceUrl, exploreUrl) onNavigateToExploreShow(title, sourceUrl, exploreUrl)
@@ -382,8 +389,15 @@ fun MainScreen(
onBookClick = { book -> onBookClick = { book ->
context.startActivityForBook(book) context.startActivityForBook(book)
}, },
onBookLongClick = { book -> onBookLongClick = { book, sharedCoverKey ->
onNavigateToBookInfo(book.name, book.author, book.bookUrl) onNavigateToBookInfo(
book.name,
book.author,
book.bookUrl,
book.origin,
book.getDisplayCover(),
sharedCoverKey
)
}, },
onNavigateToSearch = { query -> onNavigateToSearch(query) }, onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport, onNavigateToRemoteImport = onNavigateToRemoteImport,
@@ -31,6 +31,7 @@ class MainViewModel(
private val prefs = context.defaultSharedPreferences private val prefs = context.defaultSharedPreferences
private val mainPreferenceKeys = setOf( private val mainPreferenceKeys = setOf(
PreferKey.showDiscovery, PreferKey.showDiscovery,
PreferKey.showHome,
PreferKey.showRss, PreferKey.showRss,
PreferKey.showBottomView, PreferKey.showBottomView,
PreferKey.useFloatingBottomBar, PreferKey.useFloatingBottomBar,
@@ -152,10 +153,12 @@ private const val NAV_EXTENDED_KEY = "navExtended"
private fun MainViewModel.readMainUiState(): MainUiState { private fun MainViewModel.readMainUiState(): MainUiState {
val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true) val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true)
val showHome = context.getPrefBoolean(PreferKey.showHome, true)
val showRss = context.getPrefBoolean(PreferKey.showRss, true) val showRss = context.getPrefBoolean(PreferKey.showRss, true)
val destinations = MainDestination.mainDestinations.filter { val destinations = MainDestination.mainDestinations.filter {
when (it) { when (it) {
MainDestination.Explore -> showDiscovery MainDestination.Explore -> showDiscovery
MainDestination.Home -> showHome
MainDestination.Rss -> showRss MainDestination.Rss -> showRss
else -> true else -> true
} }
@@ -153,7 +153,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState
fun BookshelfScreen( fun BookshelfScreen(
viewModel: BookshelfViewModel = koinViewModel(), viewModel: BookshelfViewModel = koinViewModel(),
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit, onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit,
onNavigateToSearch: (String) -> Unit, onNavigateToSearch: (String) -> Unit,
onNavigateToRemoteImport: () -> Unit, onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
@@ -1211,7 +1211,7 @@ fun BookshelfPage(
onDragFinished: () -> Unit, onDragFinished: () -> Unit,
onGlobalSearch: () -> Unit, onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit, onBookLongClick: (BookShelfItem, String?) -> Unit,
isCurrentPage: Boolean = true, isCurrentPage: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -1327,6 +1327,14 @@ fun BookshelfPage(
) { ) {
items(displayBooks, key = { it.book.bookUrl }) { bookUi -> items(displayBooks, key = { it.book.bookUrl }) { bookUi ->
val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl) val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl)
val sharedCoverKey = if (isCurrentPage) {
bookCoverSharedElementKey(
bookUi.book.bookUrl,
"bookshelf:${uiState.selectedGroupId}"
)
} else {
null
}
ReorderableItem( ReorderableItem(
state = reorderableState, state = reorderableState,
key = bookUi.book.bookUrl, key = bookUi.book.bookUrl,
@@ -1366,7 +1374,7 @@ fun BookshelfPage(
searchKey = uiState.searchKey, searchKey = uiState.searchKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = if (isCurrentPage) bookCoverSharedElementKey(bookUi.book.bookUrl) else null, sharedCoverKey = sharedCoverKey,
onClick = { onClick = {
if (uiState.isEditMode) { if (uiState.isEditMode) {
onToggleBookSelection(bookUi) onToggleBookSelection(bookUi)
@@ -1381,7 +1389,7 @@ fun BookshelfPage(
if (uiState.isEditMode) { if (uiState.isEditMode) {
onToggleBookSelection(bookUi) onToggleBookSelection(bookUi)
} else { } else {
onBookLongClick(bookUi.book) onBookLongClick(bookUi.book, sharedCoverKey)
} }
} }
} }
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -151,7 +152,8 @@ fun ExploreScreen(
onClick = { viewModel.setGroup(group); dismiss() } onClick = { viewModel.setGroup(group); dismiss() }
) )
} }
} },
contentWindowInsets = WindowInsets(0)
) { paddingValues -> ) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (uiState.items.isEmpty()) { if (uiState.items.isEmpty()) {
@@ -5,6 +5,9 @@ sealed interface HomepageEffect {
val name: String?, val name: String?,
val author: String?, val author: String?,
val bookUrl: String, val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String?,
) : HomepageEffect ) : HomepageEffect
data class NavigateToExploreShow( data class NavigateToExploreShow(
@@ -43,9 +43,12 @@ import io.legado.app.ui.widget.components.JsonRawEditor
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SecondaryButton import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.button.SmallIconButton 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.ReorderableSelectionItem import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
import io.legado.app.ui.widget.components.icon.AppIcon 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
@@ -84,6 +87,7 @@ fun <T> HomepageModuleManageSheet(
onGetAllModulesGroupedBySource: () -> Map<String, List<HomepageModuleManageUi>> = { emptyMap() }, onGetAllModulesGroupedBySource: () -> Map<String, List<HomepageModuleManageUi>> = { emptyMap() },
onGetSourceName: (String) -> String = { it }, onGetSourceName: (String) -> String = { it },
onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> },
onSyncSourceModules: (String) -> Unit = {},
) { ) {
var selectingSetUrl by remember(data != null) { mutableStateOf<String?>(null) } var selectingSetUrl by remember(data != null) { mutableStateOf<String?>(null) }
var browsingSourceUrl by remember(data != null) { mutableStateOf<String?>(null) } var browsingSourceUrl by remember(data != null) { mutableStateOf<String?>(null) }
@@ -102,6 +106,7 @@ fun <T> HomepageModuleManageSheet(
var browseModuleType by remember(data != null) { mutableStateOf("card") } var browseModuleType by remember(data != null) { mutableStateOf("card") }
var selectedKindTitles by remember(data != null) { mutableStateOf<Set<String>>(emptySet()) } var selectedKindTitles by remember(data != null) { mutableStateOf<Set<String>>(emptySet()) }
var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) } var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) }
var showKindSelect by remember(data != null) { mutableStateOf(false) }
var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) } var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) }
val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions) val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions)
var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) } var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) }
@@ -204,18 +209,27 @@ fun <T> HomepageModuleManageSheet(
val setUrl = selectingSetUrl val setUrl = selectingSetUrl
val browseUrl = browsingSourceUrl val browseUrl = browsingSourceUrl
val isBrowsing = showSourceBrowser || browseUrl != null val isBrowsing = showSourceBrowser || browseUrl != null
LaunchedEffect(browseUrl) {
browseUrl?.let { onSyncSourceModules(it) }
}
when { when {
browseUrl != null && browsingDetail -> { browseUrl != null && browsingDetail -> {
// 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现) // 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现)
val displaySetUrl = val displaySetUrl =
selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl") selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl")
val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl) val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl)
val joinedModules = onGetModulesInSet(displaySetUrl) val joinedModules = remember(displaySetUrl, sets, browseSources) {
onGetModulesInSet(displaySetUrl).distinctBy { it.id }
}
val standardModules = val standardModules = remember(joinedModules) {
joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
val infiniteModules = }
val infiniteModules = remember(joinedModules) {
joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
}
val joinedKeys = joinedModules.map { it.moduleKey }.toSet() val joinedKeys = joinedModules.map { it.moduleKey }.toSet()
val sourceModules = onGetSourceModules(browseUrl, currentSetId) val sourceModules = onGetSourceModules(browseUrl, currentSetId)
@@ -243,9 +257,9 @@ fun <T> HomepageModuleManageSheet(
AppText(stringResource(R.string.homepage_no_joined_modules)) AppText(stringResource(R.string.homepage_no_joined_modules))
} }
} else { } else {
var listData by remember(displaySetUrl) { var listData by remember(displaySetUrl, standardModules) {
mutableStateOf( mutableStateOf(
standardModules standardModules.distinctBy { it.id }
) )
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -264,7 +278,7 @@ fun <T> HomepageModuleManageSheet(
LaunchedEffect(reorderableState.isAnyItemDragging) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
val orderedIds = val orderedIds =
listData.map { it.id } + infiniteModules.map { it.id } (listData.map { it.id } + infiniteModules.map { it.id }).distinct()
if (orderedIds != joinedModules.map { it.id }) { if (orderedIds != joinedModules.map { it.id }) {
onReorderModules(orderedIds) onReorderModules(orderedIds)
} }
@@ -310,8 +324,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id }, onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete imageVector = Icons.Default.Delete
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
@@ -353,8 +366,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id }, onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete imageVector = Icons.Default.Delete
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -398,8 +410,7 @@ fun <T> HomepageModuleManageSheet(
sourceUrl = browseUrl, sourceUrl = browseUrl,
) )
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -408,90 +419,76 @@ fun <T> HomepageModuleManageSheet(
2 -> { 2 -> {
val isButtonGroup = browseModuleType == "buttonGroup" val isButtonGroup = browseModuleType == "buttonGroup"
val selectableKinds = exploreKinds
Column { Column {
val typeList = remember { val typeList = remember {
HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
} }
CompactDropdownSettingItem(
title = stringResource(R.string.homepage_module_type), GlassCard(
selectedValue = browseModuleType, containerColor = LegadoTheme.colorScheme.onSheetContent,
displayEntries = typeList.map { it.title }.toTypedArray(), cornerRadius = 12.dp
entryValues = typeList.map { it.key }.toTypedArray(), ) {
onValueChange = { CompactDropdownSettingItem(
browseModuleType = it; selectedKindTitles = emptySet() title = stringResource(R.string.homepage_module_type),
selectedValue = browseModuleType,
displayEntries = typeList.map { it.title }.toTypedArray(),
entryValues = typeList.map { it.key }.toTypedArray(),
onValueChange = {
browseModuleType = it; selectedKindTitles = emptySet()
}
)
}
Spacer(modifier = Modifier.height(16.dp))
SelectionItemCard(
title = stringResource(R.string.homepage_select_from_kinds),
subtitle = if (isButtonGroup) {
if (selectedKindTitles.isEmpty()) stringResource(R.string.homepage_select_multiple_kinds)
else stringResource(
R.string.homepage_n_selected,
selectedKindTitles.size
)
} else {
stringResource(R.string.homepage_select_one_kind)
},
containerColor = LegadoTheme.colorScheme.onSheetContent,
onToggleSelection = { showKindSelect = true },
trailingAction = {
if (isButtonGroup && selectedKindTitles.isNotEmpty()) {
SmallIconButton(
onClick = { showAddButtonGroupDialog = true },
imageVector = Icons.Default.Check
)
}
} }
) )
if (selectableKinds.isEmpty()) {
Box( ExploreKindSelectSheet(
modifier = Modifier show = showKindSelect,
.fillMaxWidth() onDismissRequest = { showKindSelect = false },
.padding(24.dp), sourceUrl = browseUrl,
contentAlignment = Alignment.Center multiple = isButtonGroup,
) { initialSelectedTitles = selectedKindTitles.toList(),
AppText( onSelected = { kinds ->
stringResource(R.string.homepage_source_no_discover), if (isButtonGroup) {
color = LegadoTheme.colorScheme.onSurfaceVariant selectedKindTitles = kinds.map { it.title }.toSet()
) } else {
} kinds.firstOrNull()?.let { kind ->
} else { addDialogPrefill = AddDialogPrefill(
AppText( title = kind.title,
stringResource(R.string.homepage_select_items), url = kind.url ?: "",
style = LegadoTheme.typography.labelMedium, type = browseModuleType
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 4.dp
)
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
selectableKinds.distinctBy { it.first + it.second },
key = { it.first + it.second }) { (kindTitle, kindUrl) ->
if (isButtonGroup) {
val isSelected = kindTitle in selectedKindTitles
SelectionItemCard(
title = kindTitle,
subtitle = kindUrl.take(60),
containerColor = LegadoTheme.colorScheme.onSheetContent,
isSelected = isSelected,
inSelectionMode = true,
onToggleSelection = {
selectedKindTitles =
if (isSelected) selectedKindTitles - kindTitle
else selectedKindTitles + kindTitle
},
modifier = Modifier.padding(horizontal = 4.dp)
)
} else {
val isJoined = joinedKeys.contains(kindTitle)
SelectionItemCard(
title = kindTitle,
subtitle = kindUrl.take(60) + if (isJoined) stringResource(
R.string.homepage_status_joined
) else "",
containerColor = LegadoTheme.colorScheme.onSheetContent,
isSelected = isJoined,
inSelectionMode = true,
onToggleSelection = {
if (!isJoined) addDialogPrefill =
AddDialogPrefill(
kindTitle,
kindUrl,
browseModuleType
)
},
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
} }
} )
Spacer(modifier = Modifier.height(12.dp))
PillDivider(
modifier = Modifier.padding(vertical = 12.dp)
)
SecondaryButton( SecondaryButton(
text = stringResource(R.string.homepage_manual_add), text = stringResource(R.string.homepage_manual_add),
onClick = { onClick = {
@@ -526,7 +523,7 @@ fun <T> HomepageModuleManageSheet(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) )
} }
items(modules, key = { it.sourceUrl + it.moduleKey }) { module -> items(modules, key = { it.id }) { module ->
val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey] val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey]
val inCurrentSet = instanceIdInCurrentSet != null val inCurrentSet = instanceIdInCurrentSet != null
SelectionItemCard( SelectionItemCard(
@@ -544,8 +541,7 @@ fun <T> HomepageModuleManageSheet(
joinedInCurrent = joinedInCurrent =
joinedInCurrent + (module.moduleKey to "temp_${module.id}") joinedInCurrent + (module.moduleKey to "temp_${module.id}")
} }
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -554,11 +550,14 @@ fun <T> HomepageModuleManageSheet(
isBrowsing -> { isBrowsing -> {
// 二级:浏览书源列表 // 二级:浏览书源列表
val sources = remember(filteredBrowseSources) {
filteredBrowseSources.distinctBy { it.sourceUrl }
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(filteredBrowseSources, key = { it.sourceUrl }) { source -> items(sources, key = { it.sourceUrl }) { source ->
val moduleCount = onGetSourceModules(source.sourceUrl, null).size val moduleCount = onGetSourceModules(source.sourceUrl, null).size
SelectionItemCard( SelectionItemCard(
title = source.sourceName, title = source.sourceName,
@@ -567,8 +566,7 @@ fun <T> HomepageModuleManageSheet(
onToggleSelection = { onToggleSelection = {
browsingSourceUrl = source.sourceUrl browsingSourceUrl = source.sourceUrl
browsingDetail = true browsingDetail = true
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -577,12 +575,16 @@ fun <T> HomepageModuleManageSheet(
setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> { setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> {
// 二级:集详情 // 二级:集详情
val setId = HomepageViewModel.customSetIdFromUrl(setUrl) val setId = HomepageViewModel.customSetIdFromUrl(setUrl)
val modules = onGetModulesInSet(setUrl) val modules = remember(setUrl, sets) {
onGetModulesInSet(setUrl).distinctBy { it.id }
}
val standardModules = val standardModules = remember(modules) {
modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
val infiniteModules = }
val infiniteModules = remember(modules) {
modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
}
if (modules.isEmpty()) { if (modules.isEmpty()) {
Column( Column(
@@ -605,7 +607,9 @@ fun <T> HomepageModuleManageSheet(
) )
} }
} else { } else {
var listData by remember(setUrl) { mutableStateOf(standardModules) } var listData by remember(setUrl, standardModules) {
mutableStateOf(standardModules)
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(listState) { from, to -> val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
listData = listData.toMutableList().apply { listData = listData.toMutableList().apply {
@@ -617,11 +621,12 @@ fun <T> HomepageModuleManageSheet(
LaunchedEffect(standardModules) { LaunchedEffect(standardModules) {
if (!reorderableState.isAnyItemDragging) listData = if (!reorderableState.isAnyItemDragging) listData =
standardModules.distinctBy { it.id } standardModules
} }
LaunchedEffect(reorderableState.isAnyItemDragging) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
val orderedIds = listData.map { it.id } + infiniteModules.map { it.id } val orderedIds =
(listData.map { it.id } + infiniteModules.map { it.id }).distinct()
if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds) if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds)
} }
} }
@@ -662,8 +667,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id }, onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete imageVector = Icons.Default.Delete
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -694,8 +698,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id }, onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete imageVector = Icons.Default.Delete
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} }
@@ -720,7 +723,9 @@ fun <T> HomepageModuleManageSheet(
else -> { else -> {
// 一级:集列表 // 一级:集列表
var localSets by remember(data != null) { mutableStateOf(sets) } var localSets by remember(data != null, sets) {
mutableStateOf(sets.distinctBy { it.sourceUrl })
}
val setsListState = rememberLazyListState() val setsListState = rememberLazyListState()
val setsReorderableState = val setsReorderableState =
rememberReorderableLazyListState(setsListState) { from, to -> rememberReorderableLazyListState(setsListState) { from, to ->
@@ -770,8 +775,12 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteSetConfirmId = set.sourceUrl }, onClick = { deleteSetConfirmId = set.sourceUrl },
imageVector = Icons.Default.Delete imageVector = Icons.Default.Delete
) )
}, }
modifier = Modifier.padding(horizontal = 4.dp) )
}
item {
PillDivider(
modifier = Modifier.padding(vertical = 12.dp)
) )
} }
item(key = "create_set") { item(key = "create_set") {
@@ -1001,41 +1010,51 @@ fun <T> AddCustomModuleDialog(
.fillMaxWidth() .fillMaxWidth()
.height(400.dp) .height(400.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
AppTextField( AppTextField(
value = title, value = title,
onValueChange = { title = it }, onValueChange = { title = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.homepage_title_label), label = stringResource(R.string.homepage_title_label),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
AppTextField( AppTextField(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "URL", label = "URL",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
val typeList = remember { val typeList = remember {
HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
} }
DropdownListSettingItem(
title = stringResource(R.string.homepage_type_label), GlassCard(
selectedValue = type, containerColor = LegadoTheme.colorScheme.onSheetContent
displayEntries = typeList.map { it.title }.toTypedArray(), ) {
entryValues = typeList.map { it.key }.toTypedArray(), DropdownListSettingItem(
onValueChange = { type = it } title = stringResource(R.string.homepage_type_label),
) selectedValue = type,
displayEntries = typeList.map { it.title }.toTypedArray(),
entryValues = typeList.map { it.key }.toTypedArray(),
onValueChange = { type = it }
)
}
AppTextField( AppTextField(
value = args, value = args,
onValueChange = { args = it }, onValueChange = { args = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "Args (JSON)", label = "Args (JSON)",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
AppText(
text = stringResource(R.string.homepage_layout_config_label), PillHeaderDivider(
style = LegadoTheme.typography.labelMedium, title = stringResource(R.string.homepage_layout_config_label)
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
) )
if (hasVisualizableKeys) { if (hasVisualizableKeys) {
JsonConfigEditor( JsonConfigEditor(
jsonString = layoutConfig, jsonString = layoutConfig,
@@ -10,27 +10,28 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
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.LaunchedEffect
@@ -46,7 +47,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -60,12 +60,15 @@ import io.legado.app.ui.main.homepage.modules.GridModule
import io.legado.app.ui.main.homepage.modules.GridRankingModule import io.legado.app.ui.main.homepage.modules.GridRankingModule
import io.legado.app.ui.main.homepage.modules.RankingModule import io.legado.app.ui.main.homepage.modules.RankingModule
import io.legado.app.ui.main.homepage.modules.WaterfallItem import io.legado.app.ui.main.homepage.modules.WaterfallItem
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookGridItem import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.button.SmallTonalIconButton import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@@ -84,7 +87,7 @@ import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun HomepageScreen( fun HomepageScreen(
viewModel: HomepageViewModel = koinViewModel(), viewModel: HomepageViewModel = koinViewModel(),
onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit, onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -137,7 +140,14 @@ fun HomepageScreen(
viewModel.effects.collect { effect -> viewModel.effects.collect { effect ->
when (effect) { when (effect) {
is HomepageEffect.NavigateToBookInfo -> is HomepageEffect.NavigateToBookInfo ->
onBookClick(effect.name, effect.author, effect.bookUrl) onBookClick(
effect.name,
effect.author,
effect.bookUrl,
effect.origin,
effect.coverPath,
effect.sharedCoverKey
)
is HomepageEffect.NavigateToExploreShow -> is HomepageEffect.NavigateToExploreShow ->
onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl) onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
@@ -238,6 +248,7 @@ fun HomepageScreen(
data = errorMsg, data = errorMsg,
onDismissRequest = { errorMsg = null }, onDismissRequest = { errorMsg = null },
title = stringResource(R.string.homepage_module_error), title = stringResource(R.string.homepage_module_error),
text = errorMsg,
confirmText = stringResource(R.string.copy_text), confirmText = stringResource(R.string.copy_text),
onConfirm = { onConfirm = {
context.sendToClip(it) context.sendToClip(it)
@@ -255,6 +266,7 @@ fun HomepageScreen(
onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) }, onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) },
onGetModulesInSet = { viewModel.getJoinedModules(it) }, onGetModulesInSet = { viewModel.getJoinedModules(it) },
onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
onSyncSourceModules = { viewModel.syncSourceModules(it) },
onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) },
onJoinModule = { sourceUrl, targetSetId, def -> onJoinModule = { sourceUrl, targetSetId, def ->
viewModel.joinModule( viewModel.joinModule(
@@ -355,12 +367,14 @@ private fun ModuleList(
item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) { item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
ModuleHeader( ModuleHeader(
title = moduleUi.title, title = moduleUi.title,
onNavigate = { onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else {
viewModel.onModuleHeaderClick( {
moduleUi.sourceUrl, viewModel.onModuleHeaderClick(
moduleUi.exploreUrl, moduleUi.sourceUrl,
moduleUi.title, moduleUi.exploreUrl,
) moduleUi.title,
)
}
}, },
) )
} }
@@ -390,27 +404,78 @@ private fun ModuleList(
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.height(80.dp) horizontalAlignment = Alignment.CenterHorizontally
.clickable { onErrorClick(state.message) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { ) {
AppText( GlassCard(
text = state.message, onClick = { onErrorClick(state.message) },
color = MaterialTheme.colorScheme.error, containerColor = LegadoTheme.colorScheme.errorContainer.copy(
maxLines = 2, alpha = 0.6f
overflow = TextOverflow.Ellipsis, ),
textAlign = TextAlign.Center, ) {
modifier = Modifier.padding(horizontal = 16.dp) Column(
) modifier = Modifier.fillMaxWidth()
Spacer(modifier = Modifier.height(4.dp)) ) {
SecondaryButton(
text = stringResource(R.string.retry), Row(
onClick = { modifier = Modifier
viewModel.retryModule(moduleUi.globalId) .fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = state.message,
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(
color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.retryModule(moduleUi.globalId)
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = "重试",
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.labelMedium
)
}
}
} }
) }
} }
} }
} }
@@ -435,68 +500,63 @@ private fun ModuleList(
val config = moduleUi.config val config = moduleUi.config
when (moduleUi.type) { when (moduleUi.type) {
HomepageModuleType.Waterfall -> { HomepageModuleType.Waterfall -> {
items( itemsIndexed(
state.books, state.books,
key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book -> key = { index, book -> "wf_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
"home:${moduleUi.globalId}:waterfall:$index"
)
WaterfallItem( WaterfallItem(
book = book, book = book,
onClick = { viewModel.onBookClick(book) }, onClick = { viewModel.onBookClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
) )
} }
if (state.hasMore) { item(
item( key = "wf_more_${moduleUi.globalId}",
key = "wf_more_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine
span = StaggeredGridItemSpan.FullLine ) {
) { LoadMoreFooter(
LaunchedEffect(state.books.size) { isLoading = state.isLoadingMore,
viewModel.loadMoreModule(moduleUi.globalId) errorMsg = null,
} isEnd = !state.hasMore,
Box( onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
modifier = Modifier )
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
} }
} }
HomepageModuleType.InfiniteGrid -> { HomepageModuleType.InfiniteGrid -> {
items( itemsIndexed(
state.books, state.books,
key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book -> key = { index, book -> "inf_grid_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
"home:${moduleUi.globalId}:infinite:$index"
)
SearchBookGridItem( SearchBookGridItem(
book = book, book = book,
shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF, shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
onClick = { viewModel.onBookClick(book) }, onClick = { viewModel.onBookClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
if (state.hasMore) { item(
item( key = "inf_grid_more_${moduleUi.globalId}",
key = "inf_grid_more_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine
span = StaggeredGridItemSpan.FullLine ) {
) { LoadMoreFooter(
LaunchedEffect(state.books.size) { isLoading = state.isLoadingMore,
viewModel.loadMoreModule(moduleUi.globalId) errorMsg = null,
} isEnd = !state.hasMore,
Box( onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
modifier = Modifier )
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
} }
} }
@@ -509,12 +569,15 @@ private fun ModuleList(
) { ) {
GridModule( GridModule(
books = state.books, books = state.books,
onClick = { viewModel.onBookClick(it) }, onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
columns = columns, columns = columns,
maxRows = rows, maxRows = rows,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
) )
} }
} }
@@ -528,10 +591,13 @@ private fun ModuleList(
) { ) {
BannerModule( BannerModule(
books = state.books, books = state.books,
onClick = { viewModel.onBookClick(it) }, onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
) )
} }
} }
@@ -543,10 +609,13 @@ private fun ModuleList(
) { ) {
RankingModule( RankingModule(
books = state.books, books = state.books,
onClick = { viewModel.onBookClick(it) }, onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
) )
} }
} }
@@ -558,11 +627,14 @@ private fun ModuleList(
) { ) {
GridRankingModule( GridRankingModule(
books = state.books, books = state.books,
onClick = { viewModel.onBookClick(it) }, onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4, rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
) )
} }
} }
@@ -574,10 +646,13 @@ private fun ModuleList(
) { ) {
CardModule( CardModule(
books = state.books, books = state.books,
onClick = { viewModel.onBookClick(it) }, onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
) )
} }
} }
@@ -596,7 +671,7 @@ private fun ModuleList(
@Composable @Composable
private fun ModuleHeader( private fun ModuleHeader(
title: String, title: String,
onNavigate: () -> Unit, onNavigate: (() -> Unit)? = null,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -612,9 +687,11 @@ private fun ModuleHeader(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
SmallTonalIconButton( if (onNavigate != null) {
onClick = onNavigate, SmallTonalIconButton(
imageVector = Icons.AutoMirrored.Filled.ArrowForward onClick = onNavigate,
) imageVector = Icons.AutoMirrored.Filled.ArrowForward
)
}
} }
} }
@@ -17,6 +17,7 @@ import io.legado.app.domain.usecase.SaveSearchBooksUseCase
import io.legado.app.help.source.exploreKinds import io.legado.app.help.source.exploreKinds
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.stackTraceStr
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
class HomepageViewModel( class HomepageViewModel(
application: Application, application: Application,
@@ -79,7 +81,7 @@ class HomepageViewModel(
private val _effects = MutableSharedFlow<HomepageEffect>(extraBufferCapacity = 8) private val _effects = MutableSharedFlow<HomepageEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private val loadJobs = mutableMapOf<String, Job>() private val loadJobs = ConcurrentHashMap<String, Job>()
private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules() private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules()
private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() private val exploreSourcesFlow = bookSourceRepository.flowExploreSources()
@@ -215,28 +217,10 @@ class HomepageViewModel(
} }
} }
// sync: 只处理有 homepageModules 的书源 // 清理 _pendingUserModules 中已入库的条目
viewModelScope.launch {
initModulesSyncFlow.collect { sources ->
sources.forEach { source -> syncModulesFromSource(source) }
}
}
// cache: 所有启用发现的书源(包括无 homepageModules 的)
viewModelScope.launch { viewModelScope.launch {
exploreSourcesFlow.collect { sources -> exploreSourcesFlow.collect { sources ->
_bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
val kindsCache = mutableMapOf<String, List<Pair<String, String>>>()
for (source in sources) {
kindsCache[source.bookSourceUrl] = try {
withContext(Dispatchers.IO) {
source.exploreKinds().map { it.title to (it.url ?: "") }
}
} catch (_: Exception) {
emptyList()
}
}
_exploreKindsCache.value = kindsCache
} }
} }
@@ -367,7 +351,7 @@ class HomepageViewModel(
}.onFailure { e -> }.onFailure { e ->
_moduleContentStates.update { _moduleContentStates.update {
it + (module.id to ModuleLoadState.Error( it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error" e.stackTraceStr
)) ))
} }
} }
@@ -404,7 +388,7 @@ class HomepageViewModel(
}.onFailure { e -> }.onFailure { e ->
_moduleContentStates.update { _moduleContentStates.update {
it + (module.id to ModuleLoadState.Error( it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error" e.stackTraceStr
)) ))
} }
} }
@@ -454,7 +438,7 @@ class HomepageViewModel(
HomepageEffect.ShowSnackbar( HomepageEffect.ShowSnackbar(
getApplication<Application>().getString( getApplication<Application>().getString(
R.string.homepage_load_more_failed, R.string.homepage_load_more_failed,
e.message ?: "" e.stackTraceStr
) )
) )
) )
@@ -477,6 +461,13 @@ class HomepageViewModel(
_isRefreshing.value = true _isRefreshing.value = true
loadJobs.values.forEach { it.cancel() } loadJobs.values.forEach { it.cancel() }
loadJobs.clear() loadJobs.clear()
// 刷新时同步当前已启用模块所属书源的定义
val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct()
activeSourceUrls.forEach { url ->
resolveBookSource(url)?.let { syncModulesFromSource(it) }
}
_moduleContentStates.value = emptyMap() _moduleContentStates.value = emptyMap()
uiState.map { it.modules }.first { modules -> uiState.map { it.modules }.first { modules ->
modules.all { it.state !is ModuleLoadState.Loading } modules.all { it.state !is ModuleLoadState.Loading }
@@ -698,12 +689,19 @@ class HomepageViewModel(
} }
} }
fun syncSourceModules(sourceUrl: String) {
viewModelScope.launch {
resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) }
}
}
/** 「书源模块」tab:仅 JSON,纯参考 */ /** 「书源模块」tab:仅 JSON,纯参考 */
fun getSourceModules( fun getSourceModules(
sourceUrl: String, sourceUrl: String,
targetSetId: String? = null targetSetId: String? = null
): List<HomepageModuleManageUi> { ): List<HomepageModuleManageUi> {
val source = resolveBookSource(sourceUrl) ?: return emptyList() val source = resolveBookSource(sourceUrl) ?: return emptyList()
val json = source.homepageModules ?: return emptyList() val json = source.homepageModules ?: return emptyList()
val jsonDefs = parseBookSourceModules(source, json) val jsonDefs = parseBookSourceModules(source, json)
@@ -850,10 +848,19 @@ class HomepageViewModel(
} }
} }
fun onBookClick(book: SearchBook) { fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
viewModelScope.launch { viewModelScope.launch {
saveSearchBooksUseCase.save(book) saveSearchBooksUseCase.save(book)
_effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl)) _effects.emit(
HomepageEffect.NavigateToBookInfo(
name = book.name,
author = book.author,
bookUrl = book.bookUrl,
origin = book.origin,
coverPath = book.coverUrl,
sharedCoverKey = sharedCoverKey
)
)
} }
} }
@@ -909,4 +916,4 @@ private data class HomepageUiFlags(
val isRefreshing: Boolean, val isRefreshing: Boolean,
val isManageMode: Boolean, val isManageMode: Boolean,
val isConfigMode: Boolean val isConfigMode: Boolean
) )
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -23,10 +23,11 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun BannerModule( fun BannerModule(
books: ImmutableList<SearchBook>, books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) { ) {
if (books.isEmpty()) return if (books.isEmpty()) return
@@ -38,7 +39,11 @@ fun BannerModule(
.fadingEdge(lazyListState, gradientWidth = 16.dp), .fadingEdge(lazyListState, gradientWidth = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(books) { book -> itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" }
)
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
author = book.author, author = book.author,
@@ -47,10 +52,10 @@ fun BannerModule(
sourceOrigin = book.origin, sourceOrigin = book.origin,
modifier = Modifier modifier = Modifier
.width(96.dp) .width(96.dp)
.clickable { onClick(book) }, .clickable { onClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
} }
@@ -3,6 +3,7 @@ package io.legado.app.ui.main.homepage.modules
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -12,12 +13,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -26,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.ui.main.homepage.HomepageViewModel import io.legado.app.ui.main.homepage.HomepageViewModel
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
@@ -51,10 +47,7 @@ fun ButtonGroupModule(
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity val activity = context as? AppCompatActivity
val useCase: ExploreKindUiUseCase = koinInject() val useCase: ExploreKindUiUseCase = koinInject()
val scope = rememberCoroutineScope() val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val infoMap = remember(sourceUrl) {
sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) }
}
// 解析图标映射表和默认图标 // 解析图标映射表和默认图标
val (iconMap, defaultIcon) = remember(layoutConfig) { val (iconMap, defaultIcon) = remember(layoutConfig) {
@@ -90,74 +83,76 @@ fun ButtonGroupModule(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
rowKinds.forEach { kind -> rowKinds.forEach { kind ->
var displayName by remember(kind.title) { mutableStateOf(kind.title) }
LaunchedEffect(kind, sourceUrl, infoMap) {
displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
}
val buttonIcon = iconMap[kind.title] ?: defaultIcon val buttonIcon = iconMap[kind.title] ?: defaultIcon
val hasIcon = !buttonIcon.isNullOrBlank() val hasIcon = !buttonIcon.isNullOrBlank()
GlassCard( ExploreKindMultiTypeItem(
onClick = { kind = kind,
when (kind.type) { sourceUrl = sourceUrl,
ExploreKind.Type.url -> { activity = activity,
kind.url?.takeIf { it.isNotBlank() }?.let { onOpenUrl = { url ->
viewModel.onKindUrlClick(sourceUrl, it, kind.title) viewModel.onKindUrlClick(sourceUrl, url, kind.title)
} },
} onRefreshKinds = {
viewModel.refreshButtonGroup(globalId)
},
useCase = useCase,
isMiuix = isMiuix,
modifier = Modifier.weight(1f),
content = { displayName, isSelected, onClick, trailingIcon ->
GlassCard(
onClick = onClick,
cornerRadius = 8.dp,
containerColor = if (isSelected) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp, horizontal = 4.dp)
) {
if (hasIcon) {
SourceIcon(
path = buttonIcon!!,
modifier = Modifier.size(20.dp),
placeholderIcon = {
ExploreKind.Type.button -> {
scope.launch {
useCase.executeAction(
action = kind.action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = {
viewModel.refreshButtonGroup(globalId)
} }
) )
Spacer(modifier = Modifier.height(6.dp))
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
AppText(
text = displayName,
style = LegadoTheme.typography.labelSmallEmphasized,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.padding(horizontal = 4.dp)
.basicMarquee()
)
if (trailingIcon != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 2.dp)
) {
trailingIcon()
}
}
} }
} }
} }
},
cornerRadius = 8.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.weight(1f)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp, horizontal = 4.dp)
) {
if (hasIcon) {
SourceIcon(
path = buttonIcon,
modifier = Modifier.size(20.dp),
placeholderIcon = {
}
)
Spacer(modifier = Modifier.height(6.dp))
}
AppText(
text = displayName,
style = LegadoTheme.typography.labelSmallEmphasized,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.padding(horizontal = 4.dp)
.basicMarquee()
)
} }
} )
} }
if (rowKinds.size < actualColumns) { if (rowKinds.size < actualColumns) {
@@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -35,10 +35,11 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun CardModule( fun CardModule(
books: ImmutableList<SearchBook>, books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) { ) {
if (books.isEmpty()) return if (books.isEmpty()) return
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@@ -49,13 +50,17 @@ fun CardModule(
.fadingEdge(lazyListState, gradientWidth = 8.dp), .fadingEdge(lazyListState, gradientWidth = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(books, key = { it.bookUrl }) { book -> itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" }
)
Column( Column(
modifier = Modifier modifier = Modifier
.width(120.dp) .width(120.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(LegadoTheme.colorScheme.surfaceContainerLow) .background(LegadoTheme.colorScheme.surfaceContainerLow)
.clickable { onClick(book) } .clickable { onClick(book, sharedCoverKey) }
) { ) {
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
@@ -67,19 +72,20 @@ fun CardModule(
.wrapContentWidth(), .wrapContentWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
AppText( AppText(
text = book.name, text = book.name,
style = LegadoTheme.typography.labelLargeEmphasized, style = LegadoTheme.typography.labelLargeEmphasized,
maxLines = 2, maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding( modifier = Modifier.padding(
start = 8.dp, start = 8.dp,
end = 8.dp, end = 8.dp,
top = 8.dp, top = 8.dp,
bottom = 2.dp bottom = 8.dp
), ),
) )
@@ -88,7 +94,7 @@ fun CardModule(
if (intro != null) { if (intro != null) {
AppText( AppText(
text = intro, text = intro,
style = LegadoTheme.typography.bodySmall, style = LegadoTheme.typography.labelSmallEmphasized,
color = LegadoTheme.colorScheme.onSurfaceVariant, color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -21,12 +21,13 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun GridModule( fun GridModule(
books: ImmutableList<SearchBook>, books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
columns: Int = 3, columns: Int = 3,
maxRows: Int? = null, maxRows: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) { ) {
if (books.isEmpty()) return if (books.isEmpty()) return
var rows = books.toList().chunked(columns) var rows = books.toList().chunked(columns)
@@ -37,20 +38,25 @@ fun GridModule(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
for (row in rows) { for ((rowIndex, row) in rows.withIndex()) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
for (book in row) { for ((columnIndex, book) in row.withIndex()) {
val itemIndex = rowIndex * columns + columnIndex
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$itemIndex" }
)
SearchBookGridItem( SearchBookGridItem(
book = book, book = book,
shelfState = BookShelfState.NOT_IN_SHELF, shelfState = BookShelfState.NOT_IN_SHELF,
onClick = { onClick(book) }, onClick = { onClick(book, sharedCoverKey) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
@@ -37,11 +37,12 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun GridRankingModule( fun GridRankingModule(
books: ImmutableList<SearchBook>, books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
rows: Int = 4, rows: Int = 4,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) { ) {
if (books.isEmpty()) return if (books.isEmpty()) return
// 限制最多显示 20 项 // 限制最多显示 20 项
@@ -70,13 +71,19 @@ fun GridRankingModule(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 12.dp) .padding(vertical = 12.dp, horizontal = 12.dp)
) { ) {
for (book in page) { for ((rowIndex, book) in page.withIndex()) {
val itemIndex = pageIndex * rows + rowIndex
val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$itemIndex" }
)
GridRankingItem( GridRankingItem(
rank = pages.flatten().indexOf(book) + 1, rank = pages.flatten().indexOf(book) + 1,
book = book, book = book,
onClick = { onClick(book) }, onClick = { onClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
) )
} }
// 占位逻辑 // 占位逻辑
@@ -96,6 +103,7 @@ private fun GridRankingItem(
onClick: () -> Unit, onClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -114,7 +122,7 @@ private fun GridRankingItem(
modifier = Modifier.width(48.dp), modifier = Modifier.width(48.dp),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
// 2. 排名 // 2. 排名
@@ -45,10 +45,11 @@ private const val MAX_COUNT = 20
@Composable @Composable
fun RankingModule( fun RankingModule(
books: ImmutableList<SearchBook>, books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) { ) {
var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) } var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) }
val displayBooks = books.take(visibleCount) val displayBooks = books.take(visibleCount)
@@ -72,6 +73,10 @@ fun RankingModule(
onClick = onClick, onClick = onClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" }
)
) )
} }
@@ -114,14 +119,15 @@ fun RankingModule(
private fun RankingItem( private fun RankingItem(
rank: Int, rank: Int,
book: SearchBook, book: SearchBook,
onClick: (SearchBook) -> Unit, onClick: (SearchBook, String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick(book) } .clickable { onClick(book, sharedCoverKey) }
.padding(vertical = 4.dp, horizontal = 4.dp), .padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -144,7 +150,7 @@ private fun RankingItem(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
} }
} }
@@ -17,7 +17,6 @@ import androidx.compose.ui.text.font.FontWeight
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 io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.book.SearchBookTagChip import io.legado.app.ui.widget.components.book.SearchBookTagChip
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
@@ -36,6 +35,7 @@ fun WaterfallItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) { ) {
GlassCard( GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -55,7 +55,7 @@ fun WaterfallItem(
.fillMaxWidth(), .fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) sharedCoverKey = sharedCoverKey
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -165,7 +166,8 @@ fun RssScreen(
} }
) )
} }
} },
contentWindowInsets = WindowInsets(0)
) { paddingValues -> ) { paddingValues ->
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 72.dp), columns = GridCells.Adaptive(minSize = 72.dp),
@@ -1,7 +1,6 @@
package io.legado.app.ui.rss.article package io.legado.app.ui.rss.article
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -59,7 +58,7 @@ import io.legado.app.data.entities.RssSource
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
@@ -163,7 +162,9 @@ fun RssArticlesPage(
} }
item { item {
LoadMoreFooter( LoadMoreFooter(
state = loadState, isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) } onRetry = { rssSource?.let(viewModel::loadMore) }
) )
} }
@@ -198,7 +199,9 @@ fun RssArticlesPage(
} }
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter( LoadMoreFooter(
state = loadState, isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) } onRetry = { rssSource?.let(viewModel::loadMore) }
) )
} }
@@ -233,7 +236,9 @@ fun RssArticlesPage(
} }
item(span = StaggeredGridItemSpan.FullLine) { item(span = StaggeredGridItemSpan.FullLine) {
LoadMoreFooter( LoadMoreFooter(
state = loadState, isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) } onRetry = { rssSource?.let(viewModel::loadMore) }
) )
} }
@@ -297,35 +302,6 @@ private fun StaggeredLoadMoreDetector(
} }
} }
@Composable
private fun LoadMoreFooter(
state: RssArticlesLoadState,
onRetry: () -> Unit
) {
val text = when {
state.isRefreshing || state.isLoadingMore -> "加载中..."
!state.hasMore -> "没有更多了"
state.errorMessage != null -> "加载失败,点击重试"
else -> "上拉加载更多"
}
val contentModifier = if (state.errorMessage != null) {
Modifier.clickable(onClick = onRetry)
} else {
Modifier
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
EmptyMessage(
message = text,
isLoading = state.isRefreshing || state.isLoadingMore,
modifier = contentModifier
)
}
}
@Composable @Composable
private fun RssArticleItem( private fun RssArticleItem(
@@ -2,6 +2,7 @@ package io.legado.app.ui.theme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import top.yukonga.miuix.kmp.theme.TextStyles import top.yukonga.miuix.kmp.theme.TextStyles
@@ -66,7 +67,7 @@ fun Typography.toLegadoTypography(): LegadoTypography {
) )
} }
fun LegadoTypography.withFont(fontFamily: androidx.compose.ui.text.font.FontFamily?): LegadoTypography { fun LegadoTypography.withFont(fontFamily: FontFamily?): LegadoTypography {
if (fontFamily == null) return this if (fontFamily == null) return this
return copy( return copy(
headlineLarge = headlineLarge.copy(fontFamily = fontFamily), headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
@@ -71,7 +71,7 @@ fun AppFloatingActionButton(
MiuixIcon( MiuixIcon(
imageVector = icon, imageVector = icon,
contentDescription = tooltipText, contentDescription = tooltipText,
tint = Color.White tint = containerColor
) )
} else { } else {
Icon( Icon(
@@ -89,7 +89,8 @@ fun AppFloatingActionButton(
MiuixFloatingActionButton( MiuixFloatingActionButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
content = fabContent content = fabContent,
containerColor = LegadoTheme.colorScheme.surfaceContainer
) )
} else { } else {
if (tooltipText != null) { if (tooltipText != null) {
@@ -8,18 +8,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material.icons.filled.Compress import androidx.compose.material.icons.filled.Compress
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonParser import com.google.gson.JsonParser
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
@@ -39,8 +34,7 @@ fun JsonRawEditor(
) { ) {
AppText( AppText(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, style = LegadoTheme.typography.labelMediumEmphasized
color = MaterialTheme.colorScheme.primary
) )
Row { Row {
SmallIconButton( SmallIconButton(
@@ -67,21 +61,13 @@ fun JsonRawEditor(
} }
} }
TextField( AppTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 150.dp, max = 400.dp), .heightIn(min = 150.dp, max = 400.dp),
textStyle = TextStyle( backgroundColor = LegadoTheme.colorScheme.onSheetContent,
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.bodySmall.fontSize
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
maxLines = 1000 maxLines = 1000
) )
} }
@@ -0,0 +1,245 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.HorizontalDivider
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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.sendToClip
@Composable
fun LoadMoreFooter(
isLoading: Boolean,
errorMsg: String?,
isEnd: Boolean,
onRetry: () -> Unit
) {
val context = LocalContext.current
var showFullError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isLoading, errorMsg, isEnd) {
if (!isLoading && errorMsg == null && !isEnd) {
onRetry()
}
}
AppAlertDialog(
data = showFullError,
onDismissRequest = { showFullError = null },
title = "错误详情",
textProvider = { this },
confirmText = "复制",
onConfirm = { error ->
context.sendToClip(error)
showFullError = null
},
dismissText = "关闭",
onDismiss = { showFullError = null }
)
Box(
modifier = Modifier
.fillMaxWidth()
.adaptiveHorizontalPadding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = Triple(isLoading, errorMsg, isEnd),
label = "LoadMoreFooter"
) { (loading, error, end) ->
when {
error != null -> {
GlassCard(
onClick = { showFullError = error },
containerColor = LegadoTheme.colorScheme.errorContainer.copy(alpha = 0.6f),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// 信息区域
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
all = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = error,
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(
color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
)
// 操作区域
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onRetry)
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = "重新加载",
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.labelMedium
)
}
}
}
}
}
loading -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
AppContainedLoadingIndicator()
AppText(
text = "正在加载…",
color = LegadoTheme.colorScheme.outline,
style = LegadoTheme.typography.bodySmall
)
}
}
end -> {
GlassCard(
modifier = Modifier
.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
all = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurface
)
AppText(
text = "已经到底了~",
color = LegadoTheme.colorScheme.onSurface,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
else -> {
GlassCard(
modifier = Modifier
.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainer,
onClick = onRetry
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
all = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurface
)
AppText(
text = "加载更多",
color = LegadoTheme.colorScheme.onSurface,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}
}
@@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -98,12 +101,16 @@ fun AppAlertDialog(
tonalElevation = AlertDialogDefaults.TonalElevation, tonalElevation = AlertDialogDefaults.TonalElevation,
title = title?.let { { Text(text = it) } }, title = title?.let { { Text(text = it) } },
text = { text = {
Column { Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
if (text != null) { if (text != null) {
Text( SelectionContainer {
text = text, Text(
modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp) text = text,
) modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp)
)
}
} }
if (content != null) { if (content != null) {
content() content()
@@ -161,16 +168,17 @@ fun <T> AppAlertDialog(
val currentData = cachedData val currentData = cachedData
if (currentData != null) { if (currentData != null) {
val currentText = text ?: textProvider?.invoke(currentData) val currentText = text ?: textProvider?.invoke(currentData)
var cachedText by remember { mutableStateOf(currentText) } var lastValidText by remember { mutableStateOf(currentText) }
if (currentText != null) { if (currentText != null) {
cachedText = currentText lastValidText = currentText
} }
AppAlertDialog( AppAlertDialog(
show = data != null, show = data != null,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = title, title = title,
text = currentText ?: cachedText, text = currentText ?: lastValidText,
modifier = modifier, modifier = modifier,
confirmText = confirmText, confirmText = confirmText,
onConfirm = onConfirm?.let { { it(currentData) } }, onConfirm = onConfirm?.let { { it(currentData) } },
@@ -82,7 +82,7 @@ fun SmallTextButton(
@Composable @Composable
fun SmallTonalTextButton( fun SmallTonalTextButton(
text: String? = null, text: String? = null,
imageVector: ImageVector, imageVector: ImageVector? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit onClick: () -> Unit
) { ) {
@@ -102,11 +102,13 @@ fun SmallTonalTextButton(
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
MiuixIcon( if (imageVector != null) {
imageVector = imageVector, MiuixIcon(
contentDescription = null, imageVector = imageVector,
modifier = Modifier.size(16.dp) contentDescription = null,
) modifier = Modifier.size(16.dp)
)
}
if (text != null) { if (text != null) {
AppText( AppText(
text = text, text = text,
@@ -121,11 +123,13 @@ fun SmallTonalTextButton(
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) { ) {
Icon( if (imageVector != null) {
imageVector = imageVector, Icon(
contentDescription = null, imageVector = imageVector,
modifier = Modifier.size(16.dp) contentDescription = null,
) modifier = Modifier.size(16.dp)
)
}
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
if (text != null) { if (text != null) {
AppText( AppText(
@@ -3,10 +3,8 @@ package io.legado.app.ui.widget.components.explore
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -16,11 +14,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
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
@Composable @Composable
fun ExploreKindItem( fun ExploreKindItem(
@@ -31,6 +27,7 @@ fun ExploreKindItem(
isMiuix: Boolean, isMiuix: Boolean,
backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer, backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer,
displayText: String = kind.title, displayText: String = kind.title,
isSelected: Boolean = false,
trailingIcon: (@Composable () -> Unit)? = null trailingIcon: (@Composable () -> Unit)? = null
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
@@ -38,31 +35,45 @@ fun ExploreKindItem(
) { ) {
val cornerRadius = 12.dp val cornerRadius = 12.dp
val containerColor = if (isSelected) {
LegadoTheme.colorScheme.primaryContainer
} else {
backgroundColor
}
val contentColor = if (isSelected) {
LegadoTheme.colorScheme.onPrimaryContainer
} else if (isClickable) {
LegadoTheme.colorScheme.onSurface
} else {
LegadoTheme.colorScheme.primary
}
if (isClickable) { if (isClickable) {
GlassCard( GlassCard(
onClick = onClick, onClick = onClick,
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
containerColor = backgroundColor, containerColor = containerColor,
contentColor = LegadoTheme.colorScheme.onSurface, contentColor = contentColor,
modifier = modifier, modifier = modifier,
) { ) {
KindText( KindText(
text = displayText, text = displayText,
isClickable = true, isClickable = true,
contentColor = contentColor,
trailingIcon = trailingIcon trailingIcon = trailingIcon
) )
} }
} else { } else {
GlassCard( GlassCard(
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
containerColor = backgroundColor, containerColor = containerColor,
contentColor = LegadoTheme.colorScheme.primary, contentColor = contentColor,
modifier = modifier, modifier = modifier,
) { ) {
KindText( KindText(
text = displayText, text = displayText,
isClickable = false, isClickable = false,
contentColor = contentColor,
trailingIcon = trailingIcon trailingIcon = trailingIcon
) )
} }
@@ -75,6 +86,7 @@ fun ExploreKindItem(
private fun KindText( private fun KindText(
text: String, text: String,
isClickable: Boolean, isClickable: Boolean,
contentColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface,
trailingIcon: (@Composable () -> Unit)? = null trailingIcon: (@Composable () -> Unit)? = null
) { ) {
Box( Box(
@@ -84,7 +96,7 @@ private fun KindText(
) { ) {
AppText( AppText(
text = text, text = text,
color = if (isClickable) LegadoTheme.colorScheme.onSurface else LegadoTheme.colorScheme.primary, color = contentColor,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(end = if (trailingIcon == null) 0.dp else 18.dp), .padding(end = if (trailingIcon == null) 0.dp else 18.dp),
@@ -0,0 +1,85 @@
package io.legado.app.ui.widget.components.explore
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
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 io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.utils.InfoMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
/**
* 封装 ExploreKind 的业务状态与交互逻辑
*/
@Stable
class ExploreKindItemState(
val kind: ExploreKind,
val sourceUrl: String?,
private val useCase: ExploreKindUiUseCase?,
private val scope: CoroutineScope,
private val activity: AppCompatActivity?,
private val onRefreshKinds: () -> Unit
) {
val infoMap: InfoMap? = if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }
?.let(::getExploreInfoMap)
var displayName by mutableStateOf(kind.title)
internal set
fun executeAction(action: String?) {
if (action.isNullOrBlank()) return
val useCase = useCase ?: return
scope.launch(IO) {
useCase.executeAction(
action = action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = onRefreshKinds
)
}
}
fun updateValue(value: String, onValueChange: ((String) -> Unit)?) {
if (onValueChange != null) {
onValueChange(value)
} else {
infoMap?.let {
it[kind.title] = value
it.saveNow()
}
}
}
@Composable
fun ResolveDisplayName(override: String?) {
LaunchedEffect(override, sourceUrl, kind.title, kind.viewName, useCase) {
displayName = override
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
?: kind.title
}
}
}
@Composable
fun rememberExploreKindItemState(
kind: ExploreKind,
sourceUrl: String?,
useCase: ExploreKindUiUseCase?,
activity: AppCompatActivity?,
onRefreshKinds: () -> Unit
): ExploreKindItemState {
val scope = rememberCoroutineScope()
return remember(kind, sourceUrl, useCase, activity) {
ExploreKindItemState(kind, sourceUrl, useCase, scope, activity, onRefreshKinds)
}
}
@@ -1,14 +1,9 @@
package io.legado.app.ui.widget.components.explore package io.legado.app.ui.widget.components.explore
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -22,24 +17,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.help.source.getExploreInfoMap
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.icon.AppIcon 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.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,295 +45,362 @@ fun ExploreKindMultiTypeItem(
isMiuix: Boolean, isMiuix: Boolean,
displayNameOverride: String? = null, displayNameOverride: String? = null,
valueOverride: String? = null, valueOverride: String? = null,
isSelected: Boolean = false,
onValueChange: ((String) -> Unit)? = null, onValueChange: ((String) -> Unit)? = null,
onRunAction: (() -> Unit)? = null, onRunAction: (() -> Unit)? = null,
useCase: ExploreKindUiUseCase? = null useCase: ExploreKindUiUseCase? = null,
onClick: (() -> Unit)? = null,
content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)? = null
) { ) {
val scope = rememberCoroutineScope() val state = rememberExploreKindItemState(kind, sourceUrl, useCase, activity, onRefreshKinds)
val infoMap = remember(sourceUrl, useCase) { state.ResolveDisplayName(displayNameOverride)
if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap)
}
var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) { val trailingIcon = rememberTrailingIcon(kind.type, isSelected)
displayName = displayNameOverride
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
?: kind.title
}
fun runAction(action: String?) { if (onClick != null) {
if (action.isNullOrBlank()) return if (content != null) {
if (onRunAction != null) { content(state.displayName, isSelected, onClick, trailingIcon)
onRunAction()
} else { } else {
val useCase = useCase ?: return ExploreKindItem(
scope.launch(IO) { kind = kind,
useCase.executeAction( isClickable = true,
action = action, onClick = onClick,
title = kind.title, modifier = modifier,
sourceUrl = sourceUrl, backgroundColor = backgroundColor,
infoMap = infoMap, isMiuix = isMiuix,
activity = activity, displayText = state.displayName,
onRefreshKinds = onRefreshKinds isSelected = isSelected,
) trailingIcon = trailingIcon
} )
}
}
fun updateValue(value: String) {
if (onValueChange != null) {
onValueChange(value)
} else {
infoMap?.let {
it[kind.title] = value
it.saveNow()
}
} }
return
} }
when (kind.type) { when (kind.type) {
ExploreKind.Type.url -> { ExploreKind.Type.url -> {
val url = kind.url?.takeIf { it.isNotBlank() } val url = kind.url?.takeIf { it.isNotBlank() }
ExploreKindItem( val internalOnClick = {
kind = kind, if (!url.isNullOrBlank()) {
isClickable = !url.isNullOrBlank(),
onClick = {
if (url.isNullOrBlank()) return@ExploreKindItem
if (kind.title.startsWith("ERROR:")) { if (kind.title.startsWith("ERROR:")) {
activity?.showDialogFragment(TextDialog("ERROR", url)) activity?.showDialogFragment(TextDialog("ERROR", url))
} else { } else {
onOpenUrl(url) onOpenUrl(url)
} }
}, }
modifier = modifier, }
backgroundColor = backgroundColor, if (content != null) {
isMiuix = isMiuix, content(state.displayName, isSelected, internalOnClick, trailingIcon)
displayText = displayName } else {
) ExploreKindItem(
kind = kind,
isClickable = !url.isNullOrBlank(),
onClick = internalOnClick,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName,
isSelected = isSelected
)
}
} }
ExploreKind.Type.button -> { ExploreKind.Type.button -> {
ExploreKindItem( val internalOnClick = {
kind = kind, if (onRunAction != null) onRunAction()
isClickable = !kind.action.isNullOrBlank(), else state.executeAction(kind.action)
onClick = { runAction(kind.action) }, }
modifier = modifier, if (content != null) {
backgroundColor = backgroundColor, content(state.displayName, isSelected, internalOnClick, trailingIcon)
isMiuix = isMiuix, } else {
displayText = displayName, ExploreKindItem(
trailingIcon = { kind = kind,
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { isClickable = !kind.action.isNullOrBlank(),
AppIcon( onClick = internalOnClick,
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, modifier = modifier,
contentDescription = null, backgroundColor = backgroundColor,
modifier = Modifier.height(14.dp), isMiuix = isMiuix,
tint = LegadoTheme.colorScheme.outlineVariant displayText = state.displayName,
) isSelected = isSelected,
} trailingIcon = trailingIcon
} )
) }
} }
ExploreKind.Type.text -> { ExploreKind.Type.text -> {
var value by remember(sourceUrl, kind.title) { if (content != null) {
mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty()) content(state.displayName, isSelected, {}, null)
} else {
TextTypeItem(
kind,
sourceUrl,
state,
valueOverride,
onValueChange,
modifier,
backgroundColor
)
} }
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
value = valueOverride
}
}
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField(
value = value,
onValueChange = { newValue ->
value = newValue
updateValue(newValue)
if (!kind.action.isNullOrBlank()) {
actionJob?.cancel()
actionJob = scope.launch {
delay(600)
runAction(kind.action)
}
}
},
placeholder = displayName,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix
)
} }
ExploreKind.Type.toggle -> { ExploreKind.Type.toggle -> {
val chars = remember(kind.chars) { ToggleTypeItem(
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") kind,
} sourceUrl,
val left = kind.style().layout_justifySelf != "right" state,
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) { valueOverride,
mutableStateOf( onValueChange,
valueOverride isSelected,
?: infoMap?.get(kind.title) modifier,
?.takeUnless { it.isEmpty() } backgroundColor,
?: (kind.default ?: chars.first()).also { isMiuix,
infoMap?.let { map -> trailingIcon,
map[kind.title] = it content
map.saveNow()
}
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
char = valueOverride
}
}
val text = if (left) "$char$displayName" else "$displayName$char"
ExploreKindItem(
kind = kind,
isClickable = true,
onClick = {
val currentIndex = chars.indexOf(char)
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" }
updateValue(char)
runAction(kind.action)
},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = text,
trailingIcon = {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
) )
} }
ExploreKind.Type.select -> { ExploreKind.Type.select -> {
val chars = remember(kind.chars) { SelectTypeItem(
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") kind,
} sourceUrl,
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) { state,
mutableStateOf( valueOverride,
valueOverride onValueChange,
?: infoMap?.get(kind.title) isSelected,
?.takeUnless { it.isEmpty() } modifier,
?: (kind.default ?: chars.first()).also { backgroundColor,
infoMap?.let { map -> isMiuix,
map[kind.title] = it trailingIcon,
map.saveNow() content
} )
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) {
selected = valueOverride
}
}
var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
Box(modifier = modifier) {
ExploreKindItem(
kind = kind,
isClickable = chars.isNotEmpty(),
onClick = { showSelector = true },
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = "$displayName $selected",
trailingIcon = {
AppIcon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
)
RoundDropdownMenu(
expanded = showSelector,
onDismissRequest = { showSelector = false }
) {
chars.forEach { option ->
RoundDropdownMenuItem(
text = option,
onClick = {
showSelector = false
if (selected != option) {
selected = option
updateValue(option)
runAction(kind.action)
}
}
)
}
}
}
} }
else -> { else -> {
ExploreKindItem( if (content != null) {
kind = kind, content(state.displayName, isSelected, {}, null)
isClickable = false, } else {
onClick = {}, ExploreKindItem(
modifier = modifier, kind = kind,
backgroundColor = backgroundColor, isClickable = false,
isMiuix = isMiuix, onClick = {},
displayText = displayName modifier = modifier,
) backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName
)
}
} }
} }
} }
@Composable @Composable
private fun ExploreKindCompactTextField( private fun TextTypeItem(
value: String, kind: ExploreKind,
onValueChange: (String) -> Unit, sourceUrl: String?,
placeholder: String, state: ExploreKindItemState,
modifier: Modifier = Modifier, valueOverride: String?,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer, onValueChange: ((String) -> Unit)?,
isMiuix: Boolean modifier: Modifier,
backgroundColor: Color
) { ) {
val interactionSource = remember { MutableInteractionSource() } val scope = rememberCoroutineScope()
val shape = RoundedCornerShape(10.dp) var value by remember(sourceUrl, kind.title) {
mutableStateOf(valueOverride ?: state.infoMap?.get(kind.title).orEmpty())
BasicTextField( }
LaunchedEffect(valueOverride) {
if (valueOverride != null) value = valueOverride
}
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = { newValue ->
singleLine = true, value = newValue
textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface), state.updateValue(newValue, onValueChange)
cursorBrush = SolidColor(LegadoTheme.colorScheme.primary), if (!kind.action.isNullOrBlank()) {
interactionSource = interactionSource, actionJob?.cancel()
modifier = modifier actionJob = scope.launch {
.height(34.dp) delay(600)
.clip(shape) state.executeAction(kind.action)
.background(backgroundColor),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(34.dp)
.padding(horizontal = 10.dp),
contentAlignment = androidx.compose.ui.Alignment.CenterStart
) {
if (value.isEmpty()) {
AppText(
text = placeholder,
color = LegadoTheme.colorScheme.outline,
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
Box(modifier = Modifier.fillMaxWidth()) {
innerTextField()
} }
} }
} },
placeholder = state.displayName,
modifier = modifier,
backgroundColor = backgroundColor
) )
} }
@Composable
private fun ToggleTypeItem(
kind: ExploreKind,
sourceUrl: String?,
state: ExploreKindItemState,
valueOverride: String?,
onValueChange: ((String) -> Unit)?,
isSelected: Boolean,
modifier: Modifier,
backgroundColor: Color,
isMiuix: Boolean,
trailingIcon: @Composable (() -> Unit)?,
content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)?
) {
val chars = remember(kind.chars) {
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
}
val left = kind.style().layout_justifySelf != "right"
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
valueOverride
?: state.infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
state.updateValue(it, onValueChange)
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) char = valueOverride
}
val text = if (left) "$char${state.displayName}" else "${state.displayName}$char"
val internalOnClick = {
val currentIndex = chars.indexOf(char)
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" }
state.updateValue(char, onValueChange)
state.executeAction(kind.action)
}
if (content != null) {
content(text, isSelected, internalOnClick, trailingIcon)
} else {
ExploreKindItem(
kind = kind,
isClickable = true,
onClick = internalOnClick,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = text,
isSelected = isSelected,
trailingIcon = trailingIcon
)
}
}
@Composable
private fun SelectTypeItem(
kind: ExploreKind,
sourceUrl: String?,
state: ExploreKindItemState,
valueOverride: String?,
onValueChange: ((String) -> Unit)?,
isSelected: Boolean,
modifier: Modifier,
backgroundColor: Color,
isMiuix: Boolean,
trailingIcon: @Composable (() -> Unit)?,
content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)?
) {
val chars = remember(kind.chars) {
kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null")
}
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf(
valueOverride
?: state.infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
state.updateValue(it, onValueChange)
}
)
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) selected = valueOverride
}
var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
Box(modifier = modifier) {
val internalOnClick = { showSelector = true }
val displayText = "${state.displayName} $selected"
if (content != null) {
content(displayText, isSelected, internalOnClick, trailingIcon)
} else {
ExploreKindItem(
kind = kind,
isClickable = chars.isNotEmpty(),
onClick = internalOnClick,
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayText,
isSelected = isSelected,
trailingIcon = trailingIcon
)
}
RoundDropdownMenu(
expanded = showSelector,
onDismissRequest = { showSelector = false }
) {
chars.forEach { option ->
RoundDropdownMenuItem(
text = option,
onClick = {
showSelector = false
if (selected != option) {
selected = option
state.updateValue(option, onValueChange)
state.executeAction(kind.action)
}
}
)
}
}
}
}
@Composable
private fun rememberTrailingIcon(type: String, isSelected: Boolean): @Composable (() -> Unit)? {
return remember(type, isSelected) {
when (type) {
ExploreKind.Type.button -> {
{
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
AppIcon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(
alpha = 0.7f
) else LegadoTheme.colorScheme.outlineVariant
)
}
}
}
ExploreKind.Type.toggle -> {
{
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant
)
}
}
ExploreKind.Type.select -> {
{
AppIcon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant
)
}
}
else -> null
}
}
}
@@ -0,0 +1,156 @@
package io.legado.app.ui.widget.components.explore
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExploreKindSelectSheet(
show: Boolean,
onDismissRequest: () -> Unit,
sourceUrl: String?,
onSelected: (List<ExploreKind>) -> Unit,
multiple: Boolean = false,
initialSelectedTitles: List<String> = emptyList(),
repository: ExploreRepository = koinInject(),
useCase: ExploreKindUiUseCase = koinInject()
) {
var kinds by remember { mutableStateOf<List<ExploreKind>>(emptyList()) }
var selectedTitles by remember(initialSelectedTitles, show) {
mutableStateOf(initialSelectedTitles.toSet())
}
var query by remember { mutableStateOf("") }
val context = LocalContext.current
val activity = context as? AppCompatActivity
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
LaunchedEffect(show, sourceUrl) {
if (show && !sourceUrl.isNullOrBlank()) {
kinds = repository.getSourceExploreKinds(sourceUrl)
}
}
val filteredKinds = remember(query, kinds) {
if (query.isBlank()) kinds
else kinds.filter { kind ->
kind.title.contains(query, ignoreCase = true) ||
(kind.url?.contains(query, ignoreCase = true) == true)
}
}
val kindRows = remember(filteredKinds) {
calculateExploreKindRows(filteredKinds, 6)
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
endAction = {
if (multiple && selectedTitles.isNotEmpty()) {
TopBarActionButton(
onClick = {
val selectedKinds = kinds.filter { it.title in selectedTitles }
onSelected(selectedKinds)
onDismissRequest()
},
imageVector = Icons.Default.Check,
contentDescription = "Confirm"
)
}
}
) {
Column {
SearchBar(
query = query,
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
onQueryChange = { query = it },
placeholder = "选择或搜索分类",
autoFocus = false
)
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier.weight(1f, fill = false)
) {
items(kindRows) { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.animateItem()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
val isSelected = kind.title in selectedTitles
ExploreKindMultiTypeItem(
modifier = Modifier
.weight(span.toFloat())
.animateItem(),
kind = kind,
sourceUrl = sourceUrl,
activity = activity,
onOpenUrl = { url ->
if (!multiple) {
onSelected(listOf(kind.copy(url = url)))
onDismissRequest()
}
},
isSelected = isSelected,
onClick = {
if (multiple) {
selectedTitles = if (isSelected) {
selectedTitles - kind.title
} else {
selectedTitles + kind.title
}
} else {
onSelected(listOf(kind))
onDismissRequest()
}
},
backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f),
isMiuix = isMiuix,
useCase = useCase
)
}
val totalSpan = rowItems.sumOf { it.second }
if (totalSpan < 6) {
Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat())
)
}
}
}
}
}
}
}
@@ -0,0 +1,69 @@
package io.legado.app.ui.widget.components.explore
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.text.AppText
@Composable
fun ExploreKindCompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
) {
val interactionSource = remember { MutableInteractionSource() }
val shape = RoundedCornerShape(10.dp)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface),
cursorBrush = SolidColor(LegadoTheme.colorScheme.primary),
interactionSource = interactionSource,
modifier = modifier
.height(34.dp)
.clip(shape)
.background(backgroundColor),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(34.dp)
.padding(horizontal = 10.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
AppText(
text = placeholder,
color = LegadoTheme.colorScheme.outline,
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
Box(modifier = Modifier.fillMaxWidth()) {
innerTextField()
}
}
}
)
}
@@ -7,8 +7,10 @@ import android.text.StaticLayout
import android.text.TextPaint import android.text.TextPaint
import android.text.TextUtils import android.text.TextUtils
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -47,6 +50,9 @@ import io.legado.app.ui.theme.LegadoTheme
import org.koin.compose.koinInject import org.koin.compose.koinInject
import io.legado.app.model.BookCover as BookCoverModel import io.legado.app.model.BookCover as BookCoverModel
private const val SharedCoverRadiusCacheMaxSize = 256
private val sharedCoverRadiusCache = mutableStateMapOf<String, Dp>()
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CoilBookCover( fun CoilBookCover(
@@ -81,107 +87,152 @@ fun CoilBookCover(
mutableStateOf(sharedCoverKey != null && finalPath != null) mutableStateOf(sharedCoverKey != null && finalPath != null)
} }
Box( val transitionRadius = rememberSharedCoverTransitionRadius(
modifier = modifier sharedCoverKey = sharedCoverKey,
.aspectRatio(5f / 7f) radius = radius,
.then( animatedVisibilityScope = animatedVisibilityScope
with(sharedTransitionScope) { )
if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) }
Modifier.sharedElement(
sharedContentState = rememberSharedContentState(sharedCoverKey),
animatedVisibilityScope = animatedVisibilityScope,
renderInOverlayDuringTransition = true
)
} else Modifier
}
)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(radius))
} else Modifier
)
.background(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
LegadoTheme.colorScheme.surfaceContainerLow
} else Color.Transparent,
RoundedCornerShape(radius)
)
.clip(RoundedCornerShape(radius))
) {
if (hasCustomDefault && !isOnlineCoverLoaded) {
key(randomPath) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = showLoadingPlaceholder,
memoryCacheKey = randomPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(radius))
)
}
}
if (finalPath != null) { key(path, sharedCoverKey) {
key(finalPath) { Box(
AsyncImage( modifier = modifier
model = buildCoverImageRequest( .aspectRatio(5f / 7f)
context = context, .then(
data = finalPath, with(sharedTransitionScope) {
sourceOrigin = sourceOrigin, if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, Modifier.sharedElement(
crossfade = showLoadingPlaceholder, sharedContentState = rememberSharedContentState(sharedCoverKey),
memoryCacheKey = finalPath, animatedVisibilityScope = animatedVisibilityScope,
), clipInOverlayDuringTransition = OverlayClip(shape)
contentDescription = null, )
imageLoader = koinInject(), } else Modifier
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp)),
onSuccess = {
isOnlineCoverLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
onLoadFinish?.invoke()
} }
) )
} .then(
} else { if (CoverConfig.coverShowShadow) {
LaunchedEffect(Unit) { Modifier.shadow(4.dp, shape)
onLoadFinish?.invoke() } else Modifier
} )
} .background(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
LegadoTheme.colorScheme.surfaceContainerLow
} else Color.Transparent,
shape
)
.clip(shape)
) {
Box(modifier = Modifier.fillMaxSize()) {
if (hasCustomDefault && !isOnlineCoverLoaded) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = showLoadingPlaceholder,
memoryCacheKey = randomPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
}
if (showLoadingPlaceholder && !isOnlineCoverLoaded) { if (finalPath != null) {
if (!hasCustomDefault) { AsyncImage(
Icon( model = buildCoverImageRequest(
Icons.Default.Book, context = context,
contentDescription = null, data = finalPath,
tint = LegadoTheme.colorScheme.secondary, sourceOrigin = sourceOrigin,
modifier = Modifier loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
.fillMaxSize(0.35f) crossfade = showLoadingPlaceholder,
.align(Alignment.Center) memoryCacheKey = finalPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onSuccess = {
isOnlineCoverLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
onLoadFinish?.invoke()
}
)
} else {
LaunchedEffect(Unit) {
onLoadFinish?.invoke()
}
}
}
if (showLoadingPlaceholder && !isOnlineCoverLoaded) {
if (!hasCustomDefault) {
Icon(
Icons.Default.Book,
contentDescription = null,
tint = LegadoTheme.colorScheme.secondary,
modifier = Modifier
.fillMaxSize(0.35f)
.align(Alignment.Center)
)
}
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
) )
} }
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
} }
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun rememberSharedCoverTransitionRadius(
sharedCoverKey: String?,
radius: Dp,
animatedVisibilityScope: AnimatedVisibilityScope?
): Dp {
if (sharedCoverKey == null || animatedVisibilityScope == null) {
return radius
}
val transition = animatedVisibilityScope.transition
val startRadius = sharedCoverRadiusCache[sharedCoverKey] ?: radius
val animatedRadiusValue by transition.animateFloat(
label = "book-cover-corner-radius"
) { state ->
if (state == EnterExitState.Visible) radius.value else startRadius.value
}
LaunchedEffect(
sharedCoverKey,
radius,
transition.currentState,
transition.targetState
) {
if (
transition.currentState == EnterExitState.Visible &&
transition.targetState == EnterExitState.Visible
) {
sharedCoverRadiusCache[sharedCoverKey] = radius
if (sharedCoverRadiusCache.size > SharedCoverRadiusCacheMaxSize) {
sharedCoverRadiusCache.keys
.firstOrNull { it != sharedCoverKey }
?.let(sharedCoverRadiusCache::remove)
}
}
}
return animatedRadiusValue.dp
}
@Composable @Composable
private fun CoverTextOverlay( private fun CoverTextOverlay(
name: String?, name: String?,
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.animateFloatingActionButton import androidx.compose.material3.animateFloatingActionButton
@@ -69,6 +71,7 @@ fun <T> ListScaffold(
} }
}, },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -102,7 +105,8 @@ fun <T> ListScaffold(
bottomContent = bottomContent bottomContent = bottomContent
) )
}, },
floatingActionButton = floatingActionButton floatingActionButton = floatingActionButton,
contentWindowInsets = contentWindowInsets
) { paddingValues -> ) { paddingValues ->
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -952,6 +952,7 @@
<string name="hide_when_status_bar_show">状态栏显示时隐藏</string> <string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
<string name="reverse_toc">反转目录</string> <string name="reverse_toc">反转目录</string>
<string name="show_discovery">显示发现</string> <string name="show_discovery">显示发现</string>
<string name="show_home">显示首页</string>
<string name="style">样式</string> <string name="style">样式</string>
<string name="group_style">分组样式</string> <string name="group_style">分组样式</string>
<string name="export_file_name">导出文件名</string> <string name="export_file_name">导出文件名</string>
@@ -1710,6 +1711,11 @@
<string name="homepage_delete_custom_set_confirm">确定要删除该集及其包含的所有模块副本吗?</string> <string name="homepage_delete_custom_set_confirm">确定要删除该集及其包含的所有模块副本吗?</string>
<string name="homepage_remove_module">移除模块</string> <string name="homepage_remove_module">移除模块</string>
<string name="homepage_remove_module_confirm">确定要从当前集中移除该模块吗?</string> <string name="homepage_remove_module_confirm">确定要从当前集中移除该模块吗?</string>
<string name="homepage_select_from_kinds">从发现分类选择</string>
<string name="homepage_select_multiple_kinds">选择多个分类</string>
<string name="homepage_n_selected">已选择 %1$d 个</string>
<string name="homepage_select_one_kind">选择一个分类</string>
<string name="or"></string>
<string name="homepage_add_button_group">添加按钮组</string> <string name="homepage_add_button_group">添加按钮组</string>
<string name="homepage_module_title_label">模块标题</string> <string name="homepage_module_title_label">模块标题</string>
<string name="homepage_custom_title">自定义标题</string> <string name="homepage_custom_title">自定义标题</string>
@@ -890,6 +890,7 @@
<string name="diy_edit_source_group">輸入自訂源分組名稱</string> <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
<string name="reverse_toc">反轉目錄</string> <string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string> <string name="show_discovery">顯示發現</string>
<string name="show_home">顯示首頁</string>
<string name="style">樣式</string> <string name="style">樣式</string>
<string name="group_style">分組樣式</string> <string name="group_style">分組樣式</string>
<string name="export_file_name">導出文件名</string> <string name="export_file_name">導出文件名</string>
@@ -893,6 +893,7 @@
<string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string> <string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
<string name="reverse_toc">反轉目錄</string> <string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string> <string name="show_discovery">顯示發現</string>
<string name="show_home">顯示首頁</string>
<string name="style">樣式</string> <string name="style">樣式</string>
<string name="group_style">分組樣式</string> <string name="group_style">分組樣式</string>
<string name="export_file_name">匯出檔案名</string> <string name="export_file_name">匯出檔案名</string>
+6
View File
@@ -982,6 +982,7 @@
<string name="hide_when_status_bar_show">Hide when status bar show</string> <string name="hide_when_status_bar_show">Hide when status bar show</string>
<string name="reverse_toc">Reverse toc</string> <string name="reverse_toc">Reverse toc</string>
<string name="show_discovery">Show Discovery</string> <string name="show_discovery">Show Discovery</string>
<string name="show_home">Show Homepage</string>
<string name="style">Style</string> <string name="style">Style</string>
<string name="group_style">Group style</string> <string name="group_style">Group style</string>
<string name="export_file_name">Export file name</string> <string name="export_file_name">Export file name</string>
@@ -1716,6 +1717,11 @@
<string name="homepage_delete_custom_set_confirm">Are you sure you want to delete this set and all its module copies?</string> <string name="homepage_delete_custom_set_confirm">Are you sure you want to delete this set and all its module copies?</string>
<string name="homepage_remove_module">Remove Module</string> <string name="homepage_remove_module">Remove Module</string>
<string name="homepage_remove_module_confirm">Are you sure you want to remove this module from the current set?</string> <string name="homepage_remove_module_confirm">Are you sure you want to remove this module from the current set?</string>
<string name="homepage_select_from_kinds">Select from Kinds</string>
<string name="homepage_select_multiple_kinds">Select multiple kinds</string>
<string name="homepage_n_selected">%1$d selected</string>
<string name="homepage_select_one_kind">Select one kind</string>
<string name="or">OR</string>
<string name="homepage_add_button_group">Add Button Group</string> <string name="homepage_add_button_group">Add Button Group</string>
<string name="homepage_module_title_label">Module Title</string> <string name="homepage_module_title_label">Module Title</string>
<string name="homepage_custom_title">Custom Title</string> <string name="homepage_custom_title">Custom Title</string>