This commit is contained in:
HapeLee
2026-05-23 03:58:41 +08:00
parent d8977337fc
commit 322df247d0
57 changed files with 1735 additions and 892 deletions
@@ -45,6 +45,7 @@ object PreferKey {
const val prevKeys = "prevKeyCodes"
const val nextKeys = "nextKeyCodes"
const val showDiscovery = "showDiscovery"
const val showHome = "showHome"
const val enableReview = "enableReview"
const val showRss = "showRss"
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.SvgDecoder
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.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl
@@ -137,6 +138,7 @@ val appModule = module {
singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository)
singleOf(::SettingsRepository)
singleOf(::LocalPreferencesRepository)
singleOf(::ExploreBooksUseCase)
singleOf(::ExploreKindUiUseCase)
singleOf(::SaveSearchBooksUseCase)
@@ -3,6 +3,8 @@ package io.legado.app.domain.usecase
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ExploreBooksUseCase(
private val bookSourceRepository: BookSourceRepository,
@@ -20,7 +22,7 @@ class ExploreBooksUseCase(
moduleUrl: String?,
args: String?,
page: Int = 1
): ExploreResult {
): ExploreResult = withContext(Dispatchers.IO) {
val base = bookSourceRepository.getBookSource(sourceUrl)
?: throw SourceNotFound(sourceUrl)
val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
@@ -33,14 +35,14 @@ class ExploreBooksUseCase(
throw InvalidUrl(resolvedUrl)
}
val books = WebBook.exploreBookSuspend(source, resolvedUrl, page)
return ExploreResult(resolvedUrl, books)
ExploreResult(resolvedUrl, books)
}
suspend fun executeForRanking(
sourceUrl: String,
moduleUrl: String?,
args: String?
): List<SearchBook> {
): List<SearchBook> = withContext(Dispatchers.IO) {
val result = execute(sourceUrl, moduleUrl, args)
var books = result.books
var page = 1
@@ -50,7 +52,7 @@ class ExploreBooksUseCase(
WebBook.exploreBookSuspend(
bookSourceRepository.getBookSource(sourceUrl)
?.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,
page,
)
@@ -60,7 +62,7 @@ class ExploreBooksUseCase(
if (next.isEmpty()) break
books = (books + next)
}
return books.take(MAX_RANKING_BOOKS)
books.take(MAX_RANKING_BOOKS)
}
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? {
val source = getOrLoadBookSource(sourceUrl) ?: return null
return runScriptWithContext {
private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? =
withContext(Dispatchers.IO) {
val source = getOrLoadBookSource(sourceUrl) ?: return@withContext null
runScriptWithContext {
source.evalJS(jsStr) {
put("infoMap", infoMap)
}?.toString()
@@ -306,6 +306,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val showDiscovery: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true)
val showHome: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showHome, true)
val showRSS: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showRss, true)
@@ -3,12 +3,8 @@ package io.legado.app.help.config
import android.content.Context
import android.net.Uri
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.utils.GSON
import io.legado.app.utils.inputStream
import io.legado.app.utils.outputStream
import splitties.init.appCtx
import java.io.File
@@ -175,6 +171,7 @@ object ThemeImportExport {
customTagColorsJson = ThemeConfig.customTagColorsJson,
// 主界面设置
showHome = ThemeConfig.showHome,
showDiscovery = ThemeConfig.showDiscovery,
showRss = ThemeConfig.showRss,
showStatusBar = ThemeConfig.showStatusBar,
@@ -258,6 +255,7 @@ object ThemeImportExport {
ThemeConfig.customTagColorsJson = data.customTagColorsJson
// 主界面设置
ThemeConfig.showHome = data.showHome
ThemeConfig.showDiscovery = data.showDiscovery
ThemeConfig.showRss = data.showRss
ThemeConfig.showStatusBar = data.showStatusBar
@@ -394,6 +392,7 @@ data class ThemeExportData(
val customTagColorsJson: String? = null,
// 主界面设置
val showHome: Boolean = true,
val showDiscovery: Boolean = true,
val showRss: Boolean = true,
val showStatusBar: Boolean = true,
@@ -86,7 +86,9 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
context = requireContext(),
name = it.name,
author = it.author,
bookUrl = it.bookUrl
bookUrl = it.bookUrl,
origin = it.origin,
coverPath = it.coverUrl
)
)
dismiss()
@@ -1,8 +1,6 @@
package io.legado.app.ui.book.explore
import android.annotation.SuppressLint
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
@@ -13,8 +11,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.GridItemSpan
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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.widget.components.AppPullToRefresh
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.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.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
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.modalBottomSheet.AppModalBottomSheet
@@ -98,7 +90,7 @@ fun ExploreShowScreen(
sourceUrl: String?,
exploreUrl: String?,
onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit,
onBookClick: (SearchBook, String?) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel(),
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -111,7 +103,6 @@ fun ExploreShowScreen(
val books by viewModel.uiBooks.collectAsState()
val isBookEnd by viewModel.isEnd.collectAsState()
val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
val kinds by viewModel.kinds.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState()
val filterState by viewModel.filterState.collectAsState()
@@ -125,8 +116,6 @@ fun ExploreShowScreen(
var showGridCountSheet by remember { mutableStateOf(false) }
val gridColumnCount by viewModel.gridCount.collectAsState()
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val context = LocalContext.current
val activity = context as? AppCompatActivity
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) {
@@ -233,73 +222,16 @@ fun ExploreShowScreen(
}
AppModalBottomSheet(
ExploreKindSelectSheet(
show = showKindSheet,
onDismissRequest = { showKindSheet = false }
) {
var kindQuery by remember { mutableStateOf("") }
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)
onDismissRequest = { showKindSheet = false },
sourceUrl = sourceUrl,
onSelected = { selectedKinds ->
selectedKinds.firstOrNull()?.let { kind ->
viewModel.switchExploreUrl(kind)
}
}
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(
modifier = Modifier
@@ -433,17 +365,22 @@ fun ExploreShowScreen(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
itemsIndexed(
items = books,
key = { it.book.bookUrl }
) { item ->
key = { index, item -> "${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"explore:grid:$index"
)
ExploreBookGridItem(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book) },
onClick = { onBookClick(item.book, sharedCoverKey) },
modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
@@ -467,17 +404,22 @@ fun ExploreShowScreen(
bottom = paddingValues.calculateBottomPadding() + 16.dp
)
) {
items(
itemsIndexed(
items = books,
key = { it.book.bookUrl }
) { item ->
key = { index, item -> "${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"explore:list:$index"
)
ExploreBookItem(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book) },
onClick = { onBookClick(item.book, sharedCoverKey) },
modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
@@ -506,6 +448,7 @@ fun ExploreBookItem(
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
SearchBookListItem(
book = book,
@@ -514,7 +457,7 @@ fun ExploreBookItem(
modifier = modifier,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
}
@@ -527,6 +470,7 @@ fun ExploreBookGridItem(
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
SearchBookGridItem(
book = book,
@@ -535,60 +479,7 @@ fun ExploreBookGridItem(
modifier = modifier,
sharedTransitionScope = sharedTransitionScope,
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.help.config.AppConfig
import io.legado.app.utils.exploreLayoutGrid
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -222,7 +223,7 @@ class ExploreShowViewModel(
}
}
.onFailure {
_errorMsg.value = it.localizedMessage
_errorMsg.value = it.stackTraceStr
}
_isLoading.value = false
@@ -40,6 +40,10 @@ import kotlinx.coroutines.flow.collectLatest
@Composable
fun BookInfoRouteScreen(
bookUrl: String,
name: String? = null,
author: String? = null,
origin: String? = null,
coverPath: String? = null,
viewModel: BookInfoViewModel,
onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
@@ -80,8 +84,14 @@ fun BookInfoRouteScreen(
viewModel.onReaderResult(it.resultCode)
}
LaunchedEffect(bookUrl, viewModel) {
viewModel.initData(bookUrl)
LaunchedEffect(bookUrl, name, author, origin, coverPath, viewModel) {
viewModel.initData(
bookUrl = bookUrl,
name = name,
author = author,
origin = origin,
coverPath = coverPath
)
}
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.webBook.WebBook
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.utils.ArchiveUtils
import io.legado.app.utils.GSON
@@ -113,12 +114,37 @@ class BookInfoViewModel(
private var readRecordObserveJob: Job? = null
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
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()
currentWebFiles = emptyList()
currentKindLabels = emptyList()
@@ -128,24 +154,32 @@ class BookInfoViewModel(
bookSource = null
chapterChanged = false
clearReadRecordObserve()
_uiState.value = BookInfoUiState()
syncUiState()
execute {
val book = appDb.bookDao.getBook(bookUrl)?.let {
inBookshelf = !it.isNotShelf
it
} ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let {
inBookshelf = false
it
} ?: throw NoStackTraceException("未找到书籍")
val dbBook = appDb.bookDao.getBook(bookUrl)
if (dbBook != null) {
inBookshelf = !dbBook.isNotShelf
dbBook
} else {
val searchBook = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()
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) {
null
} else {
appDb.bookSourceDao.getBookSource(book.origin)
}
book to source
}.onSuccess {
upBook(it.first, it.second)
upBook(book, source)
}.onError {
context.toastOnUi(it.localizedMessage ?: "未找到书籍")
emitEffect(BookInfoEffect.Finish(afterTransition = true))
@@ -40,13 +40,15 @@ class SearchActivity : BaseComposeActivity() {
SearchScreen(
viewModel = viewModel,
onBack = { finish() },
onOpenBookInfo = { name, author, bookUrl ->
onOpenBookInfo = { name, author, bookUrl, origin, coverPath, _ ->
startActivity(
MainActivity.createBookInfoIntent(
context = this,
name = name,
author = author,
bookUrl = bookUrl
bookUrl = bookUrl,
origin = origin,
coverPath = coverPath
)
)
},
@@ -58,7 +58,7 @@ sealed interface SearchIntent {
data object PauseEngine : SearchIntent
data object ResumeEngine : 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 DeleteHistory(val item: SearchKeyword) : SearchIntent
data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
@@ -82,6 +82,9 @@ sealed interface SearchEffect {
val name: String,
val author: String,
val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String?,
) : SearchEffect
data object OpenSourceManage : SearchEffect
@@ -90,7 +90,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
fun SearchScreen(
viewModel: SearchViewModel,
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,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -160,7 +160,14 @@ fun SearchScreen(
viewModel.effects.collect { effect ->
when (effect) {
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()
@@ -394,16 +401,25 @@ fun SearchScreen(
itemsIndexed(
items = state.results,
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(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(SearchIntent.OpenSearchBook(item.book))
viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
},
sharedTransitionScope = sharedTransitionScope,
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.help.config.AppConfig
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.putPrefBoolean
import kotlinx.coroutines.CancellationException
@@ -107,6 +106,9 @@ class SearchViewModel(
name = intent.book.name,
author = intent.book.author,
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,
author = intent.book.author,
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 showHome by prefDelegate(PreferKey.showHome, true)
var showRss by prefDelegate(PreferKey.showRss, 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.Box
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.fillMaxSize
@@ -47,7 +46,6 @@ import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -57,7 +55,6 @@ import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.base.AppContextWrapper
import io.legado.app.constant.PreferKey
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
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.OldThemeConfig
import io.legado.app.lib.theme.ThemeStore
import io.legado.app.lib.theme.primaryColor
import io.legado.app.help.loadFontFiles
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.adaptiveContentPadding
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.modalBottomSheet.AppModalBottomSheet
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.card.GlassCard
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.DropdownListSettingItem
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.topbar.GlassMediumFlexibleTopAppBar
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.postEvent
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.toastOnUi
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)
@Composable
@@ -138,16 +129,16 @@ fun ThemeConfigScreen(
var showBorderColorPicker by remember { mutableStateOf(false) }
var showNavIconSheet by remember { mutableStateOf(false) }
var showFontSheet by remember { mutableStateOf(false) }
var fontItems by remember { mutableStateOf<List<FileDoc>>(emptyList()) }
var fontFolderUri by remember { mutableStateOf<Uri?>(null) }
val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
fun loadFonts() {
fontItems = loadFontFiles(context, fontFolderUri)
var fontFolderUri by remember {
mutableStateOf(
context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) }
)
}
remember {
val saved = context.getPrefString(PreferKey.fontFolder)
if (!saved.isNullOrEmpty()) fontFolderUri = Uri.parse(saved)
loadFonts()
val fontItems = remember(fontFolderUri) {
loadFontFiles(context, fontFolderUri)
}
val fontFolderLauncher = rememberLauncherForActivityResult(
@@ -157,7 +148,6 @@ fun ThemeConfigScreen(
fontFolderUri = uri
uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.putPrefString(PreferKey.fontFolder, uri.toString())
loadFonts()
}
}
@@ -218,16 +208,28 @@ fun ThemeConfigScreen(
themeItems.zip(themeValues).toList()
}
if (isMiuixEngine) {
MiuixCard(
AnimatedVisibility(visible = showThemeRefactorTip) {
GlassCard(
cornerRadius = 16.dp,
insideMargin = PaddingValues(16.dp),
colors = MiuixCardDefaults.defaultColors(
color = MiuixTheme.colorScheme.primaryVariant,
contentColor = MiuixTheme.colorScheme.onPrimary
)
modifier = Modifier.padding(bottom = 16.dp)
) {
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)) {
SwitchSettingItem(
title = stringResource(R.string.show_home),
checked = ThemeConfig.showHome,
onCheckedChange = { ThemeConfig.showHome = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_discovery),
checked = ThemeConfig.showDiscovery,
@@ -648,7 +655,11 @@ fun ThemeConfigScreen(
.size(28.dp)
.clip(CircleShape)
.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 = {
if (fontItems.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(120.dp),
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
Text(
@@ -789,7 +802,9 @@ fun ThemeConfigScreen(
fontItems.forEach { fontDoc ->
item {
Card(
modifier = Modifier.fillMaxWidth().height(100.dp),
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
onClick = {
ThemeConfig.appFontPath = fontDoc.uri.toString()
showFontSheet = false
@@ -2,19 +2,40 @@ package io.legado.app.ui.config.themeConfig
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.FileUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.externalFiles
import io.legado.app.utils.inputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import java.io.File
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.help.config.ThemeExportData
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.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -164,6 +163,11 @@ fun EditThemeSheet(
// Interface layout
SectionTitle(stringResource(R.string.theme_manage_section_layout))
CompactSwitchSettingItem(
title = "首页",
checked = data.showHome,
onCheckedChange = { data = data.copy(showHome = it) }
)
CompactSwitchSettingItem(
title = stringResource(R.string.theme_manage_show_discovery),
checked = data.showDiscovery,
@@ -1,3 +1,6 @@
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,
name: String? = null,
author: String? = null,
bookUrl: String
): Intent = MainIntent.createBookInfoIntent(context, name, author, bookUrl)
bookUrl: String,
origin: String? = null,
coverPath: String? = null
): Intent =
MainIntent.createBookInfoIntent(context, name, author, bookUrl, origin, coverPath)
fun createExploreShowIntent(
context: Context,
@@ -12,6 +12,8 @@ object MainIntent {
const val EXTRA_BOOK_NAME = "name"
const val EXTRA_BOOK_AUTHOR = "author"
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_SOURCE_URL = "sourceUrl"
const val EXTRA_EXPLORE_URL = "exploreUrl"
@@ -114,13 +116,17 @@ object MainIntent {
context: Context,
name: String? = null,
author: String? = null,
bookUrl: String
bookUrl: String,
origin: String? = null,
coverPath: String? = null
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_INFO)
putExtra(EXTRA_BOOK_NAME, name)
putExtra(EXTRA_BOOK_AUTHOR, author)
putExtra(EXTRA_BOOK_URL, bookUrl)
putExtra(EXTRA_BOOK_ORIGIN, origin)
putExtra(EXTRA_BOOK_COVER, coverPath)
}
}
@@ -90,12 +90,15 @@ fun MainActivity.mainEntryProvider(
onNavigateToBookCacheManage = {
onNavigateToRoute(MainRouteBookCacheManage)
},
onNavigateToBookInfo = { name, author, bookUrl ->
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToRoute(
MainRouteBookInfo(
name = name,
author = author,
bookUrl = bookUrl
bookUrl = bookUrl,
origin = origin,
coverPath = coverPath,
sharedCoverKey = sharedCoverKey
)
)
},
@@ -246,12 +249,15 @@ fun MainActivity.mainEntryProvider(
searchViewModel.onIntent(SearchIntent.ClearSearchResults)
onNavigateBack()
},
onOpenBookInfo = { name, author, bookUrl ->
onOpenBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToRoute(
MainRouteBookInfo(
name = name,
author = author,
bookUrl = bookUrl
bookUrl = bookUrl,
origin = origin,
coverPath = coverPath,
sharedCoverKey = sharedCoverKey
)
)
},
@@ -395,9 +401,13 @@ fun MainActivity.mainEntryProvider(
} else null
}
) { route ->
val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = route.bookUrl)
BookInfoRouteScreen(
bookUrl = route.bookUrl,
name = route.name,
author = route.author,
origin = route.origin,
coverPath = route.coverPath,
viewModel = bookInfoViewModel,
onBack = { onNavigateBack() },
onFinish = { _, _ -> onNavigateBack() },
@@ -406,7 +416,7 @@ fun MainActivity.mainEntryProvider(
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
sharedCoverKey = bookCoverSharedElementKey(route.bookUrl),
sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
onRegisterVariableSetter = { setter ->
onRegisterVariableSetter(setter)
}
@@ -419,12 +429,15 @@ fun MainActivity.mainEntryProvider(
sourceUrl = route.sourceUrl,
exploreUrl = route.exploreUrl,
onBack = { onNavigateBack() },
onBookClick = { book ->
onBookClick = { book, sharedCoverKey ->
onNavigateToRoute(
MainRouteBookInfo(
name = book.name,
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 author: String?,
val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String? = null,
) : MainRoute
@Serializable
@@ -254,7 +254,9 @@ object MainNavigator {
MainRouteBookInfo(
name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME),
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
@@ -110,7 +110,7 @@ fun MainScreen(
onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> 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,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: 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
when (destination) {
MainDestination.Home -> HomepageScreen(
onBookClick = { name, author, bookUrl ->
onNavigateToBookInfo(name ?: "", author ?: "", bookUrl)
onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToBookInfo(
name ?: "",
author ?: "",
bookUrl,
origin,
coverPath,
sharedCoverKey
)
},
onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
onNavigateToExploreShow(title, sourceUrl, exploreUrl)
@@ -382,8 +389,15 @@ fun MainScreen(
onBookClick = { book ->
context.startActivityForBook(book)
},
onBookLongClick = { book ->
onNavigateToBookInfo(book.name, book.author, book.bookUrl)
onBookLongClick = { book, sharedCoverKey ->
onNavigateToBookInfo(
book.name,
book.author,
book.bookUrl,
book.origin,
book.getDisplayCover(),
sharedCoverKey
)
},
onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport,
@@ -31,6 +31,7 @@ class MainViewModel(
private val prefs = context.defaultSharedPreferences
private val mainPreferenceKeys = setOf(
PreferKey.showDiscovery,
PreferKey.showHome,
PreferKey.showRss,
PreferKey.showBottomView,
PreferKey.useFloatingBottomBar,
@@ -152,10 +153,12 @@ private const val NAV_EXTENDED_KEY = "navExtended"
private fun MainViewModel.readMainUiState(): MainUiState {
val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true)
val showHome = context.getPrefBoolean(PreferKey.showHome, true)
val showRss = context.getPrefBoolean(PreferKey.showRss, true)
val destinations = MainDestination.mainDestinations.filter {
when (it) {
MainDestination.Explore -> showDiscovery
MainDestination.Home -> showHome
MainDestination.Rss -> showRss
else -> true
}
@@ -153,7 +153,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState
fun BookshelfScreen(
viewModel: BookshelfViewModel = koinViewModel(),
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit,
onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit,
onNavigateToSearch: (String) -> Unit,
onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit,
@@ -1211,7 +1211,7 @@ fun BookshelfPage(
onDragFinished: () -> Unit,
onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem, String?) -> Unit,
isCurrentPage: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -1327,6 +1327,14 @@ fun BookshelfPage(
) {
items(displayBooks, key = { it.book.bookUrl }) { bookUi ->
val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl)
val sharedCoverKey = if (isCurrentPage) {
bookCoverSharedElementKey(
bookUi.book.bookUrl,
"bookshelf:${uiState.selectedGroupId}"
)
} else {
null
}
ReorderableItem(
state = reorderableState,
key = bookUi.book.bookUrl,
@@ -1366,7 +1374,7 @@ fun BookshelfPage(
searchKey = uiState.searchKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = if (isCurrentPage) bookCoverSharedElementKey(bookUi.book.bookUrl) else null,
sharedCoverKey = sharedCoverKey,
onClick = {
if (uiState.isEditMode) {
onToggleBookSelection(bookUi)
@@ -1381,7 +1389,7 @@ fun BookshelfPage(
if (uiState.isEditMode) {
onToggleBookSelection(bookUi)
} else {
onBookLongClick(bookUi.book)
onBookLongClick(bookUi.book, sharedCoverKey)
}
}
}
@@ -5,6 +5,9 @@ sealed interface HomepageEffect {
val name: String?,
val author: String?,
val bookUrl: String,
val origin: String? = null,
val coverPath: String? = null,
val sharedCoverKey: String?,
) : HomepageEffect
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.button.SecondaryButton
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.SelectionItemCard
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.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -102,6 +105,7 @@ fun <T> HomepageModuleManageSheet(
var browseModuleType by remember(data != null) { mutableStateOf("card") }
var selectedKindTitles by remember(data != null) { mutableStateOf<Set<String>>(emptySet()) }
var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) }
var showKindSelect by remember(data != null) { mutableStateOf(false) }
var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) }
val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions)
var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) }
@@ -310,8 +314,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
@@ -353,8 +356,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -398,8 +400,7 @@ fun <T> HomepageModuleManageSheet(
sourceUrl = browseUrl,
)
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -408,90 +409,76 @@ fun <T> HomepageModuleManageSheet(
2 -> {
val isButtonGroup = browseModuleType == "buttonGroup"
val selectableKinds = exploreKinds
Column {
val typeList = remember {
HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
}
CompactDropdownSettingItem(
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()
GlassCard(
containerColor = LegadoTheme.colorScheme.onSheetContent,
cornerRadius = 12.dp
) {
CompactDropdownSettingItem(
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(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
AppText(
stringResource(R.string.homepage_source_no_discover),
color = LegadoTheme.colorScheme.onSurfaceVariant
)
}
} else {
AppText(
stringResource(R.string.homepage_select_items),
style = LegadoTheme.typography.labelMedium,
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)
ExploreKindSelectSheet(
show = showKindSelect,
onDismissRequest = { showKindSelect = false },
sourceUrl = browseUrl,
multiple = isButtonGroup,
initialSelectedTitles = selectedKindTitles.toList(),
onSelected = { kinds ->
if (isButtonGroup) {
selectedKindTitles = kinds.map { it.title }.toSet()
} else {
kinds.firstOrNull()?.let { kind ->
addDialogPrefill = AddDialogPrefill(
title = kind.title,
url = kind.url ?: "",
type = browseModuleType
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
)
PillDivider(
modifier = Modifier.padding(vertical = 12.dp)
)
SecondaryButton(
text = stringResource(R.string.homepage_manual_add),
onClick = {
@@ -544,8 +531,7 @@ fun <T> HomepageModuleManageSheet(
joinedInCurrent =
joinedInCurrent + (module.moduleKey to "temp_${module.id}")
}
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -567,8 +553,7 @@ fun <T> HomepageModuleManageSheet(
onToggleSelection = {
browsingSourceUrl = source.sourceUrl
browsingDetail = true
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -662,8 +647,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -694,8 +678,7 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteConfirmId = module.id },
imageVector = Icons.Default.Delete
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
}
@@ -770,8 +753,12 @@ fun <T> HomepageModuleManageSheet(
onClick = { deleteSetConfirmId = set.sourceUrl },
imageVector = Icons.Default.Delete
)
},
modifier = Modifier.padding(horizontal = 4.dp)
}
)
}
item {
PillDivider(
modifier = Modifier.padding(vertical = 12.dp)
)
}
item(key = "create_set") {
@@ -1001,41 +988,51 @@ fun <T> AddCustomModuleDialog(
.fillMaxWidth()
.height(400.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppTextField(
value = title,
onValueChange = { title = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.homepage_title_label),
modifier = Modifier.fillMaxWidth()
)
AppTextField(
value = url,
onValueChange = { url = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "URL",
modifier = Modifier.fillMaxWidth()
)
val typeList = remember {
HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
}
DropdownListSettingItem(
title = stringResource(R.string.homepage_type_label),
selectedValue = type,
displayEntries = typeList.map { it.title }.toTypedArray(),
entryValues = typeList.map { it.key }.toTypedArray(),
onValueChange = { type = it }
)
GlassCard(
containerColor = LegadoTheme.colorScheme.onSheetContent
) {
DropdownListSettingItem(
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(
value = args,
onValueChange = { args = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "Args (JSON)",
modifier = Modifier.fillMaxWidth()
)
AppText(
text = stringResource(R.string.homepage_layout_config_label),
style = LegadoTheme.typography.labelMedium,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
PillHeaderDivider(
title = stringResource(R.string.homepage_layout_config_label)
)
if (hasVisualizableKeys) {
JsonConfigEditor(
jsonString = layoutConfig,
@@ -10,27 +10,28 @@ import androidx.compose.foundation.layout.Box
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
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.outlined.Info
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.res.stringResource
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.unit.dp
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.RankingModule
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.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.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.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.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
@@ -84,7 +87,7 @@ import org.koin.androidx.compose.koinViewModel
@Composable
fun HomepageScreen(
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,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -137,7 +140,14 @@ fun HomepageScreen(
viewModel.effects.collect { effect ->
when (effect) {
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 ->
onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
@@ -238,6 +248,7 @@ fun HomepageScreen(
data = errorMsg,
onDismissRequest = { errorMsg = null },
title = stringResource(R.string.homepage_module_error),
text = errorMsg,
confirmText = stringResource(R.string.copy_text),
onConfirm = {
context.sendToClip(it)
@@ -355,12 +366,14 @@ private fun ModuleList(
item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
ModuleHeader(
title = moduleUi.title,
onNavigate = {
viewModel.onModuleHeaderClick(
moduleUi.sourceUrl,
moduleUi.exploreUrl,
moduleUi.title,
)
onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else {
{
viewModel.onModuleHeaderClick(
moduleUi.sourceUrl,
moduleUi.exploreUrl,
moduleUi.title,
)
}
},
)
}
@@ -390,27 +403,78 @@ private fun ModuleList(
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onErrorClick(state.message) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppText(
text = state.message,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
SecondaryButton(
text = stringResource(R.string.retry),
onClick = {
viewModel.retryModule(moduleUi.globalId)
GlassCard(
onClick = { onErrorClick(state.message) },
containerColor = LegadoTheme.colorScheme.errorContainer.copy(
alpha = 0.6f
),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.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 +499,63 @@ private fun ModuleList(
val config = moduleUi.config
when (moduleUi.type) {
HomepageModuleType.Waterfall -> {
items(
itemsIndexed(
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(
book = book,
onClick = { viewModel.onBookClick(book) },
onClick = { viewModel.onBookClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
if (state.hasMore) {
item(
key = "wf_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LaunchedEffect(state.books.size) {
viewModel.loadMoreModule(moduleUi.globalId)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
item(
key = "wf_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
)
}
}
HomepageModuleType.InfiniteGrid -> {
items(
itemsIndexed(
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(
book = book,
shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
onClick = { viewModel.onBookClick(book) },
onClick = { viewModel.onBookClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
}
if (state.hasMore) {
item(
key = "inf_grid_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LaunchedEffect(state.books.size) {
viewModel.loadMoreModule(moduleUi.globalId)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
item(
key = "inf_grid_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
)
}
}
@@ -509,12 +568,15 @@ private fun ModuleList(
) {
GridModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(),
columns = columns,
maxRows = rows,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
)
}
}
@@ -528,10 +590,13 @@ private fun ModuleList(
) {
BannerModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
)
}
}
@@ -543,10 +608,13 @@ private fun ModuleList(
) {
RankingModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
)
}
}
@@ -558,11 +626,14 @@ private fun ModuleList(
) {
GridRankingModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
)
}
}
@@ -574,10 +645,13 @@ private fun ModuleList(
) {
CardModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
)
}
}
@@ -596,7 +670,7 @@ private fun ModuleList(
@Composable
private fun ModuleHeader(
title: String,
onNavigate: () -> Unit,
onNavigate: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
@@ -612,9 +686,11 @@ private fun ModuleHeader(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
SmallTonalIconButton(
onClick = onNavigate,
imageVector = Icons.AutoMirrored.Filled.ArrowForward
)
if (onNavigate != null) {
SmallTonalIconButton(
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.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.stackTraceStr
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -215,28 +216,10 @@ class HomepageViewModel(
}
}
// sync: 只处理有 homepageModules 的书源
viewModelScope.launch {
initModulesSyncFlow.collect { sources ->
sources.forEach { source -> syncModulesFromSource(source) }
}
}
// cache: 所有启用发现的书源(包括无 homepageModules 的)
// 清理 _pendingUserModules 中已入库的条目
viewModelScope.launch {
exploreSourcesFlow.collect { sources ->
_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 +350,7 @@ class HomepageViewModel(
}.onFailure { e ->
_moduleContentStates.update {
it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error"
e.stackTraceStr
))
}
}
@@ -404,7 +387,7 @@ class HomepageViewModel(
}.onFailure { e ->
_moduleContentStates.update {
it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error"
e.stackTraceStr
))
}
}
@@ -454,7 +437,7 @@ class HomepageViewModel(
HomepageEffect.ShowSnackbar(
getApplication<Application>().getString(
R.string.homepage_load_more_failed,
e.message ?: ""
e.stackTraceStr
)
)
)
@@ -477,6 +460,13 @@ class HomepageViewModel(
_isRefreshing.value = true
loadJobs.values.forEach { it.cancel() }
loadJobs.clear()
// 刷新时同步当前已启用模块所属书源的定义
val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct()
activeSourceUrls.forEach { url ->
resolveBookSource(url)?.let { syncModulesFromSource(it) }
}
_moduleContentStates.value = emptyMap()
uiState.map { it.modules }.first { modules ->
modules.all { it.state !is ModuleLoadState.Loading }
@@ -704,6 +694,12 @@ class HomepageViewModel(
targetSetId: String? = null
): List<HomepageModuleManageUi> {
val source = resolveBookSource(sourceUrl) ?: return emptyList()
// 按需同步:只有进入该源的管理页才同步其 JSON 定义
viewModelScope.launch {
syncModulesFromSource(source)
}
val json = source.homepageModules ?: return emptyList()
val jsonDefs = parseBookSourceModules(source, json)
@@ -850,10 +846,19 @@ class HomepageViewModel(
}
}
fun onBookClick(book: SearchBook) {
fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
viewModelScope.launch {
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 +914,4 @@ private data class HomepageUiFlags(
val isRefreshing: Boolean,
val isManageMode: 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.width
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.runtime.Composable
import androidx.compose.ui.Modifier
@@ -23,10 +23,11 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun BannerModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) {
if (books.isEmpty()) return
@@ -38,7 +39,11 @@ fun BannerModule(
.fadingEdge(lazyListState, gradientWidth = 16.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(
name = book.name,
author = book.author,
@@ -47,10 +52,10 @@ fun BannerModule(
sourceOrigin = book.origin,
modifier = Modifier
.width(96.dp)
.clickable { onClick(book) },
.clickable { onClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope,
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.compose.foundation.basicMarquee
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.Spacer
@@ -12,12 +13,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -26,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ui.main.homepage.HomepageViewModel
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.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.GSON
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@Composable
@@ -51,10 +47,7 @@ fun ButtonGroupModule(
val context = LocalContext.current
val activity = context as? AppCompatActivity
val useCase: ExploreKindUiUseCase = koinInject()
val scope = rememberCoroutineScope()
val infoMap = remember(sourceUrl) {
sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) }
}
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
// 解析图标映射表和默认图标
val (iconMap, defaultIcon) = remember(layoutConfig) {
@@ -90,74 +83,76 @@ fun ButtonGroupModule(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
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 hasIcon = !buttonIcon.isNullOrBlank()
GlassCard(
onClick = {
when (kind.type) {
ExploreKind.Type.url -> {
kind.url?.takeIf { it.isNotBlank() }?.let {
viewModel.onKindUrlClick(sourceUrl, it, kind.title)
}
}
ExploreKindMultiTypeItem(
kind = kind,
sourceUrl = sourceUrl,
activity = activity,
onOpenUrl = { url ->
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) {
@@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -35,10 +35,11 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun CardModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) {
if (books.isEmpty()) return
val lazyListState = rememberLazyListState()
@@ -49,13 +50,17 @@ fun CardModule(
.fadingEdge(lazyListState, gradientWidth = 8.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(
modifier = Modifier
.width(120.dp)
.clip(RoundedCornerShape(16.dp))
.background(LegadoTheme.colorScheme.surfaceContainerLow)
.clickable { onClick(book) }
.clickable { onClick(book, sharedCoverKey) }
) {
CoilBookCover(
name = book.name,
@@ -67,19 +72,20 @@ fun CardModule(
.wrapContentWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
AppText(
text = book.name,
style = LegadoTheme.typography.labelLargeEmphasized,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 2.dp
bottom = 8.dp
),
)
@@ -88,7 +94,7 @@ fun CardModule(
if (intro != null) {
AppText(
text = intro,
style = LegadoTheme.typography.bodySmall,
style = LegadoTheme.typography.labelSmallEmphasized,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -21,12 +21,13 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun GridModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier,
columns: Int = 3,
maxRows: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) {
if (books.isEmpty()) return
var rows = books.toList().chunked(columns)
@@ -37,20 +38,25 @@ fun GridModule(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (row in rows) {
for ((rowIndex, row) in rows.withIndex()) {
Row(
modifier = Modifier.fillMaxWidth(),
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(
book = book,
shelfState = BookShelfState.NOT_IN_SHELF,
onClick = { onClick(book) },
onClick = { onClick(book, sharedCoverKey) },
modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
@@ -37,11 +37,12 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun GridRankingModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier,
rows: Int = 4,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) {
if (books.isEmpty()) return
// 限制最多显示 20 项
@@ -70,13 +71,19 @@ fun GridRankingModule(
.fillMaxWidth()
.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(
rank = pages.flatten().indexOf(book) + 1,
book = book,
onClick = { onClick(book) },
onClick = { onClick(book, sharedCoverKey) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
// 占位逻辑
@@ -96,6 +103,7 @@ private fun GridRankingItem(
onClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
Row(
modifier = Modifier
@@ -114,7 +122,7 @@ private fun GridRankingItem(
modifier = Modifier.width(48.dp),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
// 2. 排名
@@ -45,10 +45,11 @@ private const val MAX_COUNT = 20
@Composable
fun RankingModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKeySourceId: String? = null,
) {
var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) }
val displayBooks = books.take(visibleCount)
@@ -72,6 +73,10 @@ fun RankingModule(
onClick = onClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" }
)
)
}
@@ -114,14 +119,15 @@ fun RankingModule(
private fun RankingItem(
rank: Int,
book: SearchBook,
onClick: (SearchBook) -> Unit,
onClick: (SearchBook, String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(book) }
.clickable { onClick(book, sharedCoverKey) }
.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -144,7 +150,7 @@ private fun RankingItem(
modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope,
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.unit.dp
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.widget.components.book.SearchBookTagChip
import io.legado.app.ui.widget.components.card.GlassCard
@@ -36,6 +35,7 @@ fun WaterfallItem(
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
) {
GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -55,7 +55,7 @@ fun WaterfallItem(
.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
sharedCoverKey = sharedCoverKey
)
Spacer(modifier = Modifier.height(8.dp))
@@ -1,7 +1,6 @@
package io.legado.app.ui.rss.article
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.adaptiveContentPadding
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.image.cover.buildCoverImageRequest
import io.legado.app.utils.toastOnUi
@@ -163,7 +162,9 @@ fun RssArticlesPage(
}
item {
LoadMoreFooter(
state = loadState,
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) }
)
}
@@ -198,7 +199,9 @@ fun RssArticlesPage(
}
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
state = loadState,
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
onRetry = { rssSource?.let(viewModel::loadMore) }
)
}
@@ -233,7 +236,9 @@ fun RssArticlesPage(
}
item(span = StaggeredGridItemSpan.FullLine) {
LoadMoreFooter(
state = loadState,
isLoading = loadState.isRefreshing || loadState.isLoadingMore,
errorMsg = loadState.errorMessage,
isEnd = !loadState.hasMore,
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
private fun RssArticleItem(
@@ -2,6 +2,7 @@ package io.legado.app.ui.theme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
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
return copy(
headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
@@ -71,7 +71,7 @@ fun AppFloatingActionButton(
MiuixIcon(
imageVector = icon,
contentDescription = tooltipText,
tint = Color.White
tint = containerColor
)
} else {
Icon(
@@ -89,7 +89,8 @@ fun AppFloatingActionButton(
MiuixFloatingActionButton(
onClick = onClick,
modifier = modifier,
content = fabContent
content = fabContent,
containerColor = LegadoTheme.colorScheme.surfaceContainer
)
} else {
if (tooltipText != null) {
@@ -8,18 +8,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
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.ui.Alignment
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 com.google.gson.GsonBuilder
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.text.AppText
import io.legado.app.utils.GSON
@@ -39,8 +34,7 @@ fun JsonRawEditor(
) {
AppText(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
style = LegadoTheme.typography.labelMediumEmphasized
)
Row {
SmallIconButton(
@@ -67,21 +61,13 @@ fun JsonRawEditor(
}
}
TextField(
AppTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 150.dp, max = 400.dp),
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.bodySmall.fontSize
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
maxLines = 1000
)
}
@@ -0,0 +1,251 @@
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.button.AnimatedTextButton
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,
)
}
}
}
AnimatedTextButton(
isLoading = false,
onClick = onRetry,
text = "尝试加载更多"
)
}
}
}
}
}
@@ -5,6 +5,9 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -98,12 +101,16 @@ fun AppAlertDialog(
tonalElevation = AlertDialogDefaults.TonalElevation,
title = title?.let { { Text(text = it) } },
text = {
Column {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
if (text != null) {
Text(
text = text,
modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp)
)
SelectionContainer {
Text(
text = text,
modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp)
)
}
}
if (content != null) {
content()
@@ -161,16 +168,17 @@ fun <T> AppAlertDialog(
val currentData = cachedData
if (currentData != null) {
val currentText = text ?: textProvider?.invoke(currentData)
var cachedText by remember { mutableStateOf(currentText) }
var lastValidText by remember { mutableStateOf(currentText) }
if (currentText != null) {
cachedText = currentText
lastValidText = currentText
}
AppAlertDialog(
show = data != null,
onDismissRequest = onDismissRequest,
title = title,
text = currentText ?: cachedText,
text = currentText ?: lastValidText,
modifier = modifier,
confirmText = confirmText,
onConfirm = onConfirm?.let { { it(currentData) } },
@@ -82,7 +82,7 @@ fun SmallTextButton(
@Composable
fun SmallTonalTextButton(
text: String? = null,
imageVector: ImageVector,
imageVector: ImageVector? = null,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
@@ -102,11 +102,13 @@ fun SmallTonalTextButton(
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
if (imageVector != null) {
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
if (text != null) {
AppText(
text = text,
@@ -121,11 +123,13 @@ fun SmallTonalTextButton(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
if (imageVector != null) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
Spacer(Modifier.width(4.dp))
if (text != null) {
AppText(
@@ -3,10 +3,8 @@ package io.legado.app.ui.widget.components.explore
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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 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.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun ExploreKindItem(
@@ -31,6 +27,7 @@ fun ExploreKindItem(
isMiuix: Boolean,
backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer,
displayText: String = kind.title,
isSelected: Boolean = false,
trailingIcon: (@Composable () -> Unit)? = null
) {
CompositionLocalProvider(
@@ -38,31 +35,45 @@ fun ExploreKindItem(
) {
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) {
GlassCard(
onClick = onClick,
cornerRadius = cornerRadius,
containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.onSurface,
containerColor = containerColor,
contentColor = contentColor,
modifier = modifier,
) {
KindText(
text = displayText,
isClickable = true,
contentColor = contentColor,
trailingIcon = trailingIcon
)
}
} else {
GlassCard(
cornerRadius = cornerRadius,
containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.primary,
containerColor = containerColor,
contentColor = contentColor,
modifier = modifier,
) {
KindText(
text = displayText,
isClickable = false,
contentColor = contentColor,
trailingIcon = trailingIcon
)
}
@@ -75,6 +86,7 @@ fun ExploreKindItem(
private fun KindText(
text: String,
isClickable: Boolean,
contentColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface,
trailingIcon: (@Composable () -> Unit)? = null
) {
Box(
@@ -84,7 +96,7 @@ private fun KindText(
) {
AppText(
text = text,
color = if (isClickable) LegadoTheme.colorScheme.onSurface else LegadoTheme.colorScheme.primary,
color = contentColor,
modifier = Modifier
.fillMaxWidth()
.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
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.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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
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.setValue
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.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
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.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.showDialogFragment
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -56,295 +45,362 @@ fun ExploreKindMultiTypeItem(
isMiuix: Boolean,
displayNameOverride: String? = null,
valueOverride: String? = null,
isSelected: Boolean = false,
onValueChange: ((String) -> 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 infoMap = remember(sourceUrl, useCase) {
if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap)
}
var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
val state = rememberExploreKindItemState(kind, sourceUrl, useCase, activity, onRefreshKinds)
state.ResolveDisplayName(displayNameOverride)
LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) {
displayName = displayNameOverride
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
?: kind.title
}
val trailingIcon = rememberTrailingIcon(kind.type, isSelected)
fun runAction(action: String?) {
if (action.isNullOrBlank()) return
if (onRunAction != null) {
onRunAction()
if (onClick != null) {
if (content != null) {
content(state.displayName, isSelected, onClick, trailingIcon)
} else {
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) {
if (onValueChange != null) {
onValueChange(value)
} else {
infoMap?.let {
it[kind.title] = value
it.saveNow()
}
ExploreKindItem(
kind = kind,
isClickable = true,
onClick = onClick,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName,
isSelected = isSelected,
trailingIcon = trailingIcon
)
}
return
}
when (kind.type) {
ExploreKind.Type.url -> {
val url = kind.url?.takeIf { it.isNotBlank() }
ExploreKindItem(
kind = kind,
isClickable = !url.isNullOrBlank(),
onClick = {
if (url.isNullOrBlank()) return@ExploreKindItem
val internalOnClick = {
if (!url.isNullOrBlank()) {
if (kind.title.startsWith("ERROR:")) {
activity?.showDialogFragment(TextDialog("ERROR", url))
} else {
onOpenUrl(url)
}
},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName
)
}
}
if (content != null) {
content(state.displayName, isSelected, internalOnClick, trailingIcon)
} else {
ExploreKindItem(
kind = kind,
isClickable = !url.isNullOrBlank(),
onClick = internalOnClick,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName,
isSelected = isSelected
)
}
}
ExploreKind.Type.button -> {
ExploreKindItem(
kind = kind,
isClickable = !kind.action.isNullOrBlank(),
onClick = { runAction(kind.action) },
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName,
trailingIcon = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
AppIcon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.height(14.dp),
tint = LegadoTheme.colorScheme.outlineVariant
)
}
}
)
val internalOnClick = {
if (onRunAction != null) onRunAction()
else state.executeAction(kind.action)
}
if (content != null) {
content(state.displayName, isSelected, internalOnClick, trailingIcon)
} else {
ExploreKindItem(
kind = kind,
isClickable = !kind.action.isNullOrBlank(),
onClick = internalOnClick,
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName,
isSelected = isSelected,
trailingIcon = trailingIcon
)
}
}
ExploreKind.Type.text -> {
var value by remember(sourceUrl, kind.title) {
mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty())
if (content != null) {
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 -> {
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
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
map[kind.title] = it
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
)
}
ToggleTypeItem(
kind,
sourceUrl,
state,
valueOverride,
onValueChange,
isSelected,
modifier,
backgroundColor,
isMiuix,
trailingIcon,
content
)
}
ExploreKind.Type.select -> {
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
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also {
infoMap?.let { map ->
map[kind.title] = it
map.saveNow()
}
}
)
}
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)
}
}
)
}
}
}
SelectTypeItem(
kind,
sourceUrl,
state,
valueOverride,
onValueChange,
isSelected,
modifier,
backgroundColor,
isMiuix,
trailingIcon,
content
)
}
else -> {
ExploreKindItem(
kind = kind,
isClickable = false,
onClick = {},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = displayName
)
if (content != null) {
content(state.displayName, isSelected, {}, null)
} else {
ExploreKindItem(
kind = kind,
isClickable = false,
onClick = {},
modifier = modifier,
backgroundColor = backgroundColor,
isMiuix = isMiuix,
displayText = state.displayName
)
}
}
}
}
@Composable
private fun ExploreKindCompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
isMiuix: Boolean
private fun TextTypeItem(
kind: ExploreKind,
sourceUrl: String?,
state: ExploreKindItemState,
valueOverride: String?,
onValueChange: ((String) -> Unit)?,
modifier: Modifier,
backgroundColor: Color
) {
val interactionSource = remember { MutableInteractionSource() }
val shape = RoundedCornerShape(10.dp)
BasicTextField(
val scope = rememberCoroutineScope()
var value by remember(sourceUrl, kind.title) {
mutableStateOf(valueOverride ?: state.infoMap?.get(kind.title).orEmpty())
}
LaunchedEffect(valueOverride) {
if (valueOverride != null) value = valueOverride
}
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField(
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 = 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()
onValueChange = { newValue ->
value = newValue
state.updateValue(newValue, onValueChange)
if (!kind.action.isNullOrBlank()) {
actionJob?.cancel()
actionJob = scope.launch {
delay(600)
state.executeAction(kind.action)
}
}
}
},
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()
}
}
}
)
}