主页模块初始版本

This commit is contained in:
HapeLee
2026-05-22 02:49:29 +08:00
committed by Kudomaga
parent 88a3674e9a
commit b00944b612
69 changed files with 7798 additions and 471 deletions
@@ -319,4 +319,9 @@ object PreferKey {
const val navIconExplore = "navIconExplore"
const val navIconRss = "navIconRss"
const val navIconMy = "navIconMy"
const val homepageModuleOrder = "homepageModuleOrder"
const val homepageModuleHidden = "homepageModuleHidden"
const val homepageLayoutMode = "homepageLayoutMode"
const val navIconHome = "navIconHome"
}
@@ -15,6 +15,8 @@ import io.legado.app.data.dao.BookmarkDao
import io.legado.app.data.dao.CacheDao
import io.legado.app.data.dao.CookieDao
import io.legado.app.data.dao.DictRuleDao
import io.legado.app.data.dao.HomepageCustomSetDao
import io.legado.app.data.dao.HomepageModuleDao
import io.legado.app.data.dao.HttpTTSDao
import io.legado.app.data.dao.KeyboardAssistsDao
import io.legado.app.data.dao.ReadRecordDao
@@ -38,6 +40,8 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.Cache
import io.legado.app.data.entities.Cookie
import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HomepageCustomSet
import io.legado.app.data.entities.HomepageModule
import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.ReplaceRule
@@ -69,7 +73,7 @@ val appDb by lazy {
}
@Database(
version = 87,
version = 88,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -77,7 +81,7 @@ val appDb by lazy {
RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class,
SearchContentHistory::class],
SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class],
views = [BookSourcePart::class],
autoMigrations = [
AutoMigration(from = 43, to = 44),
@@ -123,7 +127,8 @@ val appDb by lazy {
AutoMigration(from = 83, to = 84),
AutoMigration(from = 84, to = 85),
AutoMigration(from = 85, to = 86),
AutoMigration(from = 86, to = 87)
AutoMigration(from = 86, to = 87),
AutoMigration(from = 87, to = 88)
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -150,6 +155,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract val keyboardAssistsDao: KeyboardAssistsDao
abstract val serverDao: ServerDao
abstract val searchContentHistoryDao: SearchContentHistoryDao
abstract val homepageModuleDao: HomepageModuleDao
abstract val homepageCustomSetDao: HomepageCustomSetDao
companion object {
@@ -497,5 +497,4 @@ object DatabaseMigrations {
columnName = "enabledReview"
)
class Migration_64_65 : AutoMigrationSpec
}
@@ -83,7 +83,21 @@ interface BookSourceDao {
fun flowDisabled(): Flow<List<BookSourcePart>>
@Query(
"""select * from book_sources_part
"""select * from book_sources
where enabled = 1 and enabledExplore = 1 and homepageModules is not null
order by customOrder asc"""
)
fun flowHomepageModules(): Flow<List<BookSource>>
@Query(
"""select * from book_sources
where enabled = 1 and enabledExplore = 1
order by customOrder asc"""
)
fun flowExploreSources(): Flow<List<BookSource>>
@Query(
"""select * from book_sources_part
where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc"""
)
fun flowExplore(): Flow<List<BookSourcePart>>
@@ -0,0 +1,30 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.HomepageCustomSet
import kotlinx.coroutines.flow.Flow
@Dao
interface HomepageCustomSetDao {
@Query("SELECT * FROM homepage_custom_sets ORDER BY sortOrder ASC")
fun flowAll(): Flow<List<HomepageCustomSet>>
@Query("SELECT * FROM homepage_custom_sets WHERE id = :id")
suspend fun getById(id: String): HomepageCustomSet?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(customSet: HomepageCustomSet)
@Query("UPDATE homepage_custom_sets SET name = :name WHERE id = :id")
suspend fun rename(id: String, name: String)
@Query("UPDATE homepage_custom_sets SET sortOrder = :order WHERE id = :id")
suspend fun setSortOrder(id: String, order: Int)
@Query("DELETE FROM homepage_custom_sets WHERE id = :id")
suspend fun delete(id: String)
}
@@ -0,0 +1,48 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.HomepageModule
import kotlinx.coroutines.flow.Flow
@Dao
interface HomepageModuleDao {
@Query("SELECT * FROM homepage_modules WHERE isEnabled = 1 ORDER BY sortOrder ASC")
fun flowEnabled(): Flow<List<HomepageModule>>
@Query("SELECT * FROM homepage_modules WHERE sourceUrl = :sourceUrl ORDER BY sortOrder ASC")
fun flowBySource(sourceUrl: String): Flow<List<HomepageModule>>
@Query("SELECT * FROM homepage_modules WHERE id = :id")
suspend fun getById(id: String): HomepageModule?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(modules: List<HomepageModule>)
@Query("UPDATE homepage_modules SET isEnabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean)
@Query("UPDATE homepage_modules SET sortOrder = :order WHERE id = :id")
suspend fun setSortOrder(id: String, order: Int)
@Query("UPDATE homepage_modules SET customSetTitle = :title WHERE id = :id")
suspend fun setCustomSetTitle(id: String, title: String?)
@Query("UPDATE homepage_modules SET customSetId = :setId WHERE id = :id")
suspend fun setCustomSetId(id: String, setId: String?)
@Query("DELETE FROM homepage_modules WHERE id = :id")
suspend fun delete(id: String)
@Query("DELETE FROM homepage_modules WHERE customSetId = :setId")
suspend fun deleteByCustomSetId(setId: String)
@Query("DELETE FROM homepage_modules WHERE sourceUrl = :sourceUrl AND isUserCreated = 0 AND id NOT IN (:currentIds)")
suspend fun deleteStale(sourceUrl: String, currentIds: List<String>)
@Query("SELECT * FROM homepage_modules ORDER BY sortOrder ASC")
fun flowAll(): Flow<List<HomepageModule>>
}
@@ -1,6 +1,11 @@
package io.legado.app.data.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import io.legado.app.data.entities.SearchBook
@Dao
@@ -98,7 +98,9 @@ data class BookSource(
@ColumnInfo(defaultValue = "0")
var eventListener: Boolean = false, // 是否监听事件来执行回调规则
@ColumnInfo(defaultValue = "0")
var customButton: Boolean = false //由书源控制的自定义按钮
var customButton: Boolean = false, //由书源控制的自定义按钮
// 首页模块定义,JSON数组。每个元素: key, type(banner/ranking/grid/card/filter), title, args?, url?
var homepageModules: String? = null
) : Parcelable, BaseSource {
override fun getTag(): String {
@@ -254,6 +256,7 @@ data class BookSource(
&& equal(loginUi, source.loginUi)
&& equal(loginCheckJs, source.loginCheckJs)
&& equal(coverDecodeJs, source.coverDecodeJs)
&& equal(homepageModules, source.homepageModules)
&& equal(exploreUrl, source.exploreUrl)
&& equal(searchUrl, source.searchUrl)
&& getSearchRule() == source.getSearchRule()
@@ -0,0 +1,12 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "homepage_custom_sets")
data class HomepageCustomSet(
@PrimaryKey
var id: String = "",
var name: String = "",
var sortOrder: Int = 0,
)
@@ -0,0 +1,25 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "homepage_modules")
data class HomepageModule(
@PrimaryKey
var id: String = "",
var sourceUrl: String = "",
var moduleKey: String = "",
var type: String = "",
var title: String = "",
var args: String? = null,
var layoutConfig: String? = null,
var url: String? = null,
var isEnabled: Boolean = true,
var sortOrder: Int = 0,
var customSetId: String? = null,
var isUserCreated: Boolean = false,
var customTitle: String? = null,
var customSetTitle: String? = null,
var sourceJsonHash: String? = null,
var syncedAt: Long = 0,
)
@@ -1,9 +1,11 @@
package io.legado.app.data.repository
import io.legado.app.data.appDb
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.GroupBookCount
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
@@ -33,7 +35,89 @@ class BookRepository(
}
suspend fun getBook(bookUrl: String): Book? {
return appDb.bookDao.getBook(bookUrl)
return withContext(Dispatchers.IO) {
bookDao.getBook(bookUrl)
}
}
suspend fun getBook(name: String, author: String): Book? {
return withContext(Dispatchers.IO) {
bookDao.getBook(name, author)
}
}
fun flowBookShelfByGroup(groupId: Long): Flow<List<BookShelfItem>> {
return bookDao.flowBookShelfByGroup(groupId)
}
fun flowSystemGroupCounts(): Flow<List<GroupBookCount>> {
return bookDao.flowSystemGroupCounts()
}
fun flowAllBookShelfCount(): Flow<Int> {
return bookDao.flowAllBookShelfCount()
}
fun flowUserGroupBookCount(groupId: Long): Flow<Int> {
return bookDao.flowUserGroupBookCount(groupId)
}
fun flowGroupPreview(groupId: Long): Flow<List<BookShelfItem>> {
return bookDao.flowGroupPreview(groupId)
}
suspend fun getChapterCount(bookUrl: String): Int {
return withContext(Dispatchers.IO) {
bookChapterDao.getChapterCount(bookUrl)
}
}
suspend fun getVolumeCount(bookUrl: String): Int {
return withContext(Dispatchers.IO) {
bookChapterDao.getVolumeCount(bookUrl)
}
}
suspend fun update(vararg book: Book) {
withContext(Dispatchers.IO) {
bookDao.update(*book)
}
}
suspend fun getMinOrder(): Int {
return withContext(Dispatchers.IO) {
bookDao.minOrder
}
}
suspend fun insert(book: Book) {
withContext(Dispatchers.IO) {
bookDao.insert(book)
}
}
suspend fun insertChapters(vararg chapters: BookChapter) {
withContext(Dispatchers.IO) {
bookChapterDao.insert(*chapters)
}
}
suspend fun getHasUpdateBooks(): List<Book> {
return withContext(Dispatchers.IO) {
bookDao.hasUpdateBooks
}
}
suspend fun replace(oldBook: Book, newBook: Book) {
withContext(Dispatchers.IO) {
bookDao.replace(oldBook, newBook)
}
}
suspend fun deleteChaptersByBook(bookUrl: String) {
withContext(Dispatchers.IO) {
bookChapterDao.delByBook(bookUrl)
}
}
}
@@ -0,0 +1,55 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
fun flowAll(): Flow<List<BookSourcePart>> {
return bookSourceDao.flowAll()
}
fun flowEnabled(): Flow<List<BookSourcePart>> {
return bookSourceDao.flowEnabled()
}
fun flowHomepageModules(): Flow<List<BookSource>> {
return bookSourceDao.flowHomepageModules()
}
fun flowExploreSources(): Flow<List<BookSource>> {
return bookSourceDao.flowExploreSources()
}
suspend fun getBookSource(sourceUrl: String): BookSource? {
return withContext(Dispatchers.IO) {
bookSourceDao.getBookSource(sourceUrl)
}
}
fun getBookSourceSync(sourceUrl: String): BookSource? {
return bookSourceDao.getBookSource(sourceUrl)
}
suspend fun getBookSourceAddBook(baseUrl: String): BookSource? {
return withContext(Dispatchers.IO) {
bookSourceDao.getBookSourceAddBook(baseUrl)
}
}
suspend fun getHasBookUrlPattern(): List<BookSourcePart> {
return withContext(Dispatchers.IO) {
bookSourceDao.hasBookUrlPattern
}
}
suspend fun getAllEnabledPart(): List<BookSourcePart> {
return withContext(Dispatchers.IO) {
bookSourceDao.allEnabledPart
}
}
}
@@ -0,0 +1,51 @@
package io.legado.app.data.repository
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.utils.cnCompare
import kotlin.math.max
class BookshelfRepository {
fun sortBooks(
list: List<BookShelfItem>,
group: BookGroup?,
sort: Int,
sortOrder: Int
): List<BookShelfItem> {
val bookSort = if (group != null && group.bookSort >= 0) {
group.bookSort
} else {
sort
}
val isDescending = sortOrder == 1
return when (bookSort) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
else list.sortedBy { it.latestChapterTime }
2 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
else
list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> if (isDescending) list.sortedByDescending { it.order }
else list.sortedBy { it.order }
4 -> if (isDescending) list.sortedByDescending {
max(
it.latestChapterTime,
it.durChapterTime
)
}
else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
5 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
else
list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
else list.sortedBy { it.durChapterTime }
}
}
}
@@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.SourceHelp
import io.legado.app.help.source.exploreKinds
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -18,7 +17,6 @@ interface ExploreRepository {
fun getExploreGroups(): Flow<List<String>>
fun getExploreSources(query: String, selectedGroup: String): Flow<List<BookSourcePart>>
suspend fun getBookSource(url: String): BookSource?
suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
suspend fun saveSearchBooks(books: List<SearchBook>)
suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
suspend fun topSource(bookSource: BookSourcePart)
@@ -77,18 +75,6 @@ class ExploreRepositoryImpl(
return appDb.bookSourceDao.getBookSource(url)
}
override suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>> {
return withContext(IO) {
try {
val books = WebBook.exploreBookSuspend(source, url, page)
Result.success(books)
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}
}
}
override suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind> = withContext(IO) {
val source = appDb.bookSourceDao.getBookSource(sourceUrl)
return@withContext source?.exploreKinds() ?: emptyList()
@@ -0,0 +1,94 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.HomepageCustomSetDao
import io.legado.app.data.dao.HomepageModuleDao
import io.legado.app.data.entities.HomepageCustomSet
import io.legado.app.data.entities.HomepageModule
import io.legado.app.domain.gateway.HomepageModulesGateway
import io.legado.app.domain.model.CustomSetItem
import io.legado.app.domain.model.ModuleItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class HomepageModulesRepository(
private val moduleDao: HomepageModuleDao,
private val customSetDao: HomepageCustomSetDao,
) : HomepageModulesGateway {
override fun flowEnabled(): Flow<List<ModuleItem>> =
moduleDao.flowEnabled().map { list -> list.map { it.toModuleItem() } }
override fun flowAll(): Flow<List<ModuleItem>> =
moduleDao.flowAll().map { list -> list.map { it.toModuleItem() } }
override fun flowBySource(sourceUrl: String): Flow<List<ModuleItem>> =
moduleDao.flowBySource(sourceUrl).map { list -> list.map { it.toModuleItem() } }
override suspend fun getById(id: String): ModuleItem? =
moduleDao.getById(id)?.toModuleItem()
override suspend fun upsertAll(modules: List<ModuleItem>) =
moduleDao.upsertAll(modules.map { it.toModuleEntity() })
override suspend fun setEnabled(id: String, enabled: Boolean) =
moduleDao.setEnabled(id, enabled)
override suspend fun setSortOrder(id: String, order: Int) = moduleDao.setSortOrder(id, order)
override suspend fun setCustomSetId(id: String, setId: String?) =
moduleDao.setCustomSetId(id, setId)
override suspend fun setCustomSetTitle(id: String, title: String?) =
moduleDao.setCustomSetTitle(id, title)
override suspend fun delete(id: String) = moduleDao.delete(id)
override suspend fun deleteStale(sourceUrl: String, currentIds: List<String>) =
moduleDao.deleteStale(sourceUrl, currentIds)
override fun flowCustomSets(): Flow<List<CustomSetItem>> =
customSetDao.flowAll().map { list -> list.map { it.toCustomSetItem() } }
override suspend fun getCustomSetById(id: String): CustomSetItem? =
customSetDao.getById(id)?.toCustomSetItem()
override suspend fun upsertCustomSet(set: CustomSetItem) =
customSetDao.upsert(set.toCustomSetEntity())
override suspend fun setCustomSetSortOrder(id: String, order: Int) =
customSetDao.setSortOrder(id, order)
override suspend fun createCustomSet(name: String): CustomSetItem {
val entity = HomepageCustomSet(
id = "cs_${System.currentTimeMillis()}", name = name
)
customSetDao.upsert(entity)
return entity.toCustomSetItem()
}
override suspend fun renameCustomSet(id: String, name: String) = customSetDao.rename(id, name)
override suspend fun deleteCustomSet(id: String) {
moduleDao.deleteByCustomSetId(id)
customSetDao.delete(id)
}
private fun HomepageModule.toModuleItem() = ModuleItem(
id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type,
title = title, customTitle = customTitle, customSetTitle = customSetTitle,
args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled,
customSetId = customSetId, isUserCreated = isUserCreated,
sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt,
)
private fun ModuleItem.toModuleEntity() = HomepageModule(
id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type,
title = title, customTitle = customTitle, customSetTitle = customSetTitle,
args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled,
customSetId = customSetId, isUserCreated = isUserCreated,
sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt,
)
private fun HomepageCustomSet.toCustomSetItem() =
CustomSetItem(id = id, name = name, sortOrder = sortOrder)
private fun CustomSetItem.toCustomSetEntity() =
HomepageCustomSet(id = id, name = name, sortOrder = sortOrder)
}
@@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.help.book.isNotShelf
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -26,6 +25,8 @@ interface SearchRepository {
suspend fun saveSearchKeyword(keyword: String)
suspend fun deleteSearchKeyword(item: SearchKeyword)
suspend fun clearSearchKeywords()
suspend fun saveSearchBooks(books: List<SearchBook>)
suspend fun saveSearchBook(book: SearchBook)
}
class SearchRepositoryImpl(
@@ -59,7 +60,7 @@ class SearchRepositoryImpl(
}
}
override suspend fun saveSearchKeyword(keyword: String) = withContext(Dispatchers.IO) {
override suspend fun saveSearchKeyword(keyword: String): Unit = withContext(Dispatchers.IO) {
val key = keyword.trim()
if (key.isBlank()) return@withContext
@@ -70,11 +71,12 @@ class SearchRepositoryImpl(
} ?: appDb.searchKeywordDao.insert(SearchKeyword(word = key, usage = 1))
}
override suspend fun deleteSearchKeyword(item: SearchKeyword) = withContext(Dispatchers.IO) {
override suspend fun deleteSearchKeyword(item: SearchKeyword): Unit =
withContext(Dispatchers.IO) {
appDb.searchKeywordDao.delete(item)
}
override suspend fun clearSearchKeywords() = withContext(Dispatchers.IO) {
override suspend fun clearSearchKeywords(): Unit = withContext(Dispatchers.IO) {
appDb.searchKeywordDao.deleteAll()
}
@@ -103,9 +105,14 @@ class SearchRepositoryImpl(
appDb.bookSourceDao.getBookSource(sourceUrl)
}
override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
override suspend fun saveSearchBooks(books: List<SearchBook>): Unit =
withContext(Dispatchers.IO) {
if (books.isNotEmpty()) {
appDb.searchBookDao.insert(books)
}
}
override suspend fun saveSearchBook(book: SearchBook): Unit = withContext(Dispatchers.IO) {
appDb.searchBookDao.insert(book)
}
}
@@ -2,7 +2,29 @@ package io.legado.app.di
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.dao.*
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.data.dao.BookmarkDao
import io.legado.app.data.dao.CacheDao
import io.legado.app.data.dao.CookieDao
import io.legado.app.data.dao.DictRuleDao
import io.legado.app.data.dao.HomepageCustomSetDao
import io.legado.app.data.dao.HomepageModuleDao
import io.legado.app.data.dao.HttpTTSDao
import io.legado.app.data.dao.KeyboardAssistsDao
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.dao.ReplaceRuleDao
import io.legado.app.data.dao.RssArticleDao
import io.legado.app.data.dao.RssReadRecordDao
import io.legado.app.data.dao.RssSourceDao
import io.legado.app.data.dao.RssStarDao
import io.legado.app.data.dao.RuleSubDao
import io.legado.app.data.dao.SearchBookDao
import io.legado.app.data.dao.SearchKeywordDao
import io.legado.app.data.dao.ServerDao
import io.legado.app.data.dao.TxtTocRuleDao
import org.koin.dsl.module
/**
@@ -36,4 +58,6 @@ val appDatabaseModule = module {
factory<DictRuleDao> { get<AppDatabase>().dictRuleDao }
factory<KeyboardAssistsDao> { get<AppDatabase>().keyboardAssistsDao }
factory<ServerDao> { get<AppDatabase>().serverDao }
factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
}
@@ -12,12 +12,15 @@ import io.legado.app.data.repository.BookDomainRepositoryImpl
import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceCallbackRepository
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.data.repository.BookshelfRepository
import io.legado.app.data.repository.CacheBookDownloadRepository
import io.legado.app.data.repository.DatabaseMaintenanceRepository
import io.legado.app.data.repository.DictRuleRepository
import io.legado.app.data.repository.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HomepageModulesRepository
import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository
@@ -35,20 +38,27 @@ import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.gateway.BookSourceCallbackGateway
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
import io.legado.app.domain.gateway.HomepageModulesGateway
import io.legado.app.domain.gateway.LocalBookGateway
import io.legado.app.domain.gateway.ReadingProgressGateway
import io.legado.app.domain.gateway.WebDavBackupGateway
import io.legado.app.domain.repository.BookDomainRepository
import io.legado.app.domain.usecase.AddBookUseCase
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.CacheBookChaptersUseCase
import io.legado.app.domain.usecase.ChangeBookSourceUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase
import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SaveSearchBooksUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
@@ -93,6 +103,7 @@ import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreViewModel
import io.legado.app.ui.main.homepage.HomepageViewModel
import io.legado.app.ui.main.my.MyViewModel
import io.legado.app.ui.main.rss.RssViewModel
import io.legado.app.ui.replace.ReplaceEditRoute
@@ -120,11 +131,15 @@ val appModule = module {
singleOf(::ReadRecordRepository)
singleOf(::BookRepository)
singleOf(::BookGroupRepository)
singleOf(::BookSourceRepository)
singleOf(::BookshelfRepository)
singleOf(::DictRuleRepository)
singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository)
singleOf(::SettingsRepository)
singleOf(::ExploreBooksUseCase)
singleOf(::ExploreKindUiUseCase)
singleOf(::SaveSearchBooksUseCase)
singleOf(::AppStartupMaintenanceUseCase)
singleOf(::BatchCacheDownloadUseCase)
singleOf(::CacheBookChaptersUseCase)
@@ -136,6 +151,10 @@ val appModule = module {
singleOf(::UpdateBooksGroupUseCase)
singleOf(::UploadReadingProgressUseCase)
singleOf(::ResolveBookShelfStateUseCase)
singleOf(::RefreshTocUseCase)
singleOf(::AddBookUseCase)
singleOf(::ImportBookshelfUseCase)
singleOf(::ExportBookshelfUseCase)
factory { GetReadRecordOverviewUseCase() }
singleOf(::ShrinkDatabaseUseCase)
singleOf(::WebDavBackupUseCase)
@@ -150,6 +169,7 @@ val appModule = module {
single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
single<WebDavBackupGateway> { WebDavBackupRepository() }
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
single<HomepageModulesGateway> { HomepageModulesRepository(get(), get()) }
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
singleOf(::RssRepository)
@@ -190,6 +210,7 @@ val appModule = module {
viewModelOf(::MyViewModel)
viewModelOf(::BookshelfViewModel)
viewModelOf(::MainViewModel)
viewModelOf(::HomepageViewModel)
viewModelOf(::AboutViewModel)
viewModelOf(::GroupViewModel)
viewModelOf(::ReplaceRuleViewModel)
@@ -219,9 +240,9 @@ val appModule = module {
viewModel {
BookshelfManageScreenViewModel(
application = get(),
bookDao = get(),
bookGroupDao = get(),
bookChapterDao = get(),
bookRepository = get(),
bookGroupRepository = get(),
searchRepository = get(),
bookshelfManageScreenConfig = get(),
batchCacheDownloadUseCase = get(),
cacheBookChaptersUseCase = get(),
@@ -0,0 +1,33 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.CustomSetItem
import io.legado.app.domain.model.ModuleItem
import kotlinx.coroutines.flow.Flow
interface HomepageModulesGateway {
// Module queries
fun flowEnabled(): Flow<List<ModuleItem>>
fun flowAll(): Flow<List<ModuleItem>>
fun flowBySource(sourceUrl: String): Flow<List<ModuleItem>>
suspend fun getById(id: String): ModuleItem?
// Module mutations
suspend fun upsertAll(modules: List<ModuleItem>)
suspend fun setEnabled(id: String, enabled: Boolean)
suspend fun setSortOrder(id: String, order: Int)
suspend fun setCustomSetId(id: String, setId: String?)
suspend fun setCustomSetTitle(id: String, title: String?)
suspend fun delete(id: String)
suspend fun deleteStale(sourceUrl: String, currentIds: List<String>)
// Custom set queries
fun flowCustomSets(): Flow<List<CustomSetItem>>
suspend fun getCustomSetById(id: String): CustomSetItem?
// Custom set mutations
suspend fun upsertCustomSet(set: CustomSetItem)
suspend fun setCustomSetSortOrder(id: String, order: Int)
suspend fun createCustomSet(name: String): CustomSetItem
suspend fun renameCustomSet(id: String, name: String)
suspend fun deleteCustomSet(id: String)
}
@@ -0,0 +1,71 @@
package io.legado.app.domain.model
import androidx.compose.runtime.Immutable
/** 供 Gateway 和 ViewModel 使用的不可变模块模型 */
@Immutable
data class ModuleItem(
val id: String = "",
val sourceUrl: String = "",
val moduleKey: String = "",
val type: String = "",
val title: String = "",
val customTitle: String? = null,
val customSetTitle: String? = null,
val args: String? = null,
val layoutConfig: String? = null,
val url: String? = null,
val isEnabled: Boolean = true,
val customSetId: String? = null,
val isUserCreated: Boolean = false,
val sortOrder: Int = 0,
val sourceJsonHash: String? = null,
val syncedAt: Long = 0,
) {
val displayTitle: String get() = customTitle ?: title
}
@Immutable
data class CustomSetItem(
val id: String = "",
val name: String = "",
val sortOrder: Int = 0,
)
/** 模块定义(来自书源 JSON 解析或用户手动添加) */
data class ModuleDef(
val key: String = "",
val type: String = "",
val title: String = "",
val args: String? = null,
val layoutConfig: String? = null,
val url: String? = null,
val sourceUrl: String = "",
) {
val globalId: String get() = globalIdOf(sourceUrl, key)
companion object {
fun globalIdOf(sourceUrl: String, key: String, setId: String? = null): String {
val targetSetId = setId ?: "src_$sourceUrl"
return "$targetSetId::$sourceUrl::$key"
}
}
}
/** 首页模块类型枚举 — 定义在 Domain 层以便 UseCase 和 ViewModel 共享 */
enum class HomepageModuleType(val key: String, val title: String) {
Banner("banner", "横滑轮播"),
Ranking("ranking", "排行榜"),
GridRanking("gridRanking", "网格排行榜"),
Grid("grid", "网格"),
Card("card", "推荐卡片"),
InfiniteGrid("infiniteGrid", "无限网格"),
ButtonGroup("buttonGroup", "按钮组"),
Waterfall("waterfall", "错位瀑布流"),
Unknown("", "未知");
companion object {
fun fromKey(key: String?): HomepageModuleType =
entries.find { it.key == key } ?: Unknown
}
}
@@ -0,0 +1,71 @@
package io.legado.app.domain.usecase
import io.legado.app.data.entities.Book
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.NetworkUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AddBookUseCase(
private val bookRepository: BookRepository,
private val bookSourceRepository: BookSourceRepository
) {
suspend fun execute(
bookUrls: String,
onProgress: suspend (Int) -> Unit = {}
): Int = withContext(Dispatchers.IO) {
var successCount = 0
val urls = bookUrls.split("\n")
val hasBookUrlPattern = bookSourceRepository.getHasBookUrlPattern()
for (url in urls) {
val bookUrl = url.trim()
if (bookUrl.isEmpty()) continue
if (bookRepository.getBook(bookUrl) != null) {
successCount++
onProgress(successCount)
continue
}
val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue
var source = bookSourceRepository.getBookSourceAddBook(baseUrl)
if (source == null) {
for (bookSourcePart in hasBookUrlPattern) {
try {
val bs = bookSourcePart.getBookSource()!!
if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) {
source = bs
break
}
} catch (_: Exception) {
}
}
}
val bookSource = source ?: continue
val book = Book(
bookUrl = bookUrl,
origin = bookSource.bookSourceUrl,
originName = bookSource.bookSourceName
)
kotlin.runCatching {
WebBook.getBookInfoAwait(bookSource, book)
}.onSuccess {
val dbBook = bookRepository.getBook(it.name, it.author)
if (dbBook != null) {
val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow()
dbBook.migrateTo(it, toc)
bookRepository.insert(it)
bookRepository.insertChapters(*toc.toTypedArray())
} else {
it.order = bookRepository.getMinOrder() - 1
bookRepository.insert(it)
}
successCount++
onProgress(successCount)
}
}
successCount
}
}
@@ -0,0 +1,71 @@
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
class ExploreBooksUseCase(
private val bookSourceRepository: BookSourceRepository,
) {
companion object {
/** 排名类模块自动加载的最大书本数 */
const val MAX_RANKING_BOOKS = 20
/** 排名类模块自动加载的最大页数 */
const val MAX_RANKING_PAGES = 3
}
suspend fun execute(
sourceUrl: String,
moduleUrl: String?,
args: String?,
page: Int = 1
): ExploreResult {
val base = bookSourceRepository.getBookSource(sourceUrl)
?: throw SourceNotFound(sourceUrl)
val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
val resolvedUrl = moduleUrl ?: source.exploreUrl
?: throw NoExploreUrl(sourceUrl)
if (!resolvedUrl.startsWith("http", ignoreCase = true)
&& !resolvedUrl.startsWith("data:", ignoreCase = true)
&& !resolvedUrl.startsWith("{{")
) {
throw InvalidUrl(resolvedUrl)
}
val books = WebBook.exploreBookSuspend(source, resolvedUrl, page)
return ExploreResult(resolvedUrl, books)
}
suspend fun executeForRanking(
sourceUrl: String,
moduleUrl: String?,
args: String?
): List<SearchBook> {
val result = execute(sourceUrl, moduleUrl, args)
var books = result.books
var page = 1
while (books.size < MAX_RANKING_BOOKS && page < MAX_RANKING_PAGES) {
page++
val next = try {
WebBook.exploreBookSuspend(
bookSourceRepository.getBookSource(sourceUrl)
?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s }
?: return books.take(MAX_RANKING_BOOKS),
result.resolvedUrl,
page,
)
} catch (_: Exception) {
emptyList()
}
if (next.isEmpty()) break
books = (books + next)
}
return books.take(MAX_RANKING_BOOKS)
}
data class ExploreResult(val resolvedUrl: String, val books: List<SearchBook>)
class SourceNotFound(url: String) : Exception("Source not found: ${url.take(60)}")
class NoExploreUrl(url: String) : Exception("No explore URL for source: ${url.take(60)}")
class InvalidUrl(url: String) : Exception("Invalid explore URL: ${url.take(80)}")
}
@@ -0,0 +1,79 @@
package io.legado.app.domain.usecase
import android.content.Context
import android.net.Uri
import com.google.gson.stream.JsonWriter
import io.legado.app.data.repository.BookRepository
import io.legado.app.ui.main.bookshelf.BookUiItem
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
class ExportBookshelfUseCase(
private val context: Context,
private val bookRepository: BookRepository
) {
suspend fun exportToUri(uri: Uri, items: List<BookUiItem>): Result<Unit> =
withContext(Dispatchers.IO) {
kotlin.runCatching {
context.contentResolver.openOutputStream(uri)?.use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
val fullBook = bookRepository.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
writer.close()
} ?: throw Exception("Failed to open output stream")
}
}
suspend fun exportToFile(items: List<BookUiItem>): Result<File> = withContext(Dispatchers.IO) {
kotlin.runCatching {
val path = "${context.filesDir}/books.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
FileOutputStream(file).use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
val fullBook = bookRepository.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
writer.close()
}
file
}
}
suspend fun exportToJson(items: List<BookUiItem>): Result<String> =
withContext(Dispatchers.IO) {
kotlin.runCatching {
val list = items.map {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
val fullBook = bookRepository.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
bookMap
}
GSON.toJson(list)
}
}
}
@@ -0,0 +1,109 @@
package io.legado.app.domain.usecase
import android.content.Context
import android.net.Uri
import io.legado.app.data.entities.Book
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.readText
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
class ImportBookshelfUseCase(
private val context: Context,
private val bookRepository: BookRepository,
private val bookSourceRepository: BookSourceRepository
) {
suspend fun import(
str: String,
groupId: Long,
onProgress: suspend (String) -> Unit = {}
): Result<Unit> = kotlin.runCatching {
val text = str.trim()
when {
text.isAbsUrl() -> {
val downloadedText = okHttpClient.newCallResponseBody {
url(text)
}.decompressed().text()
import(downloadedText, groupId, onProgress).getOrThrow()
}
text.isJsonArray() -> {
importByJson(text, groupId, onProgress)
}
else -> {
throw NoStackTraceException("格式不对")
}
}
}
suspend fun import(
uri: Uri,
groupId: Long,
onProgress: suspend (String) -> Unit = {}
): Result<Unit> = kotlin.runCatching {
val text = uri.readText(context)
import(text, groupId, onProgress).getOrThrow()
}
private suspend fun importByJson(
json: String,
groupId: Long,
onProgress: suspend (String) -> Unit
) {
onProgress("导入中...")
val bookSourceParts = bookSourceRepository.getAllEnabledPart()
val semaphore = Semaphore(AppConfig.threadCount)
val books = GSON.fromJsonArray<Map<String, String?>>(json).getOrThrow()
withContext(Dispatchers.IO) {
books.forEach { bookInfo ->
val name = bookInfo["name"] ?: ""
val author = bookInfo["author"] ?: ""
if (name.isEmpty() || bookRepository.getBook(name, author) != null) {
return@forEach
}
semaphore.withPermit {
var foundBook: Book? = null
for (s in bookSourceParts) {
ensureActive()
val source = s.getBookSource() ?: continue
foundBook = WebBook.preciseSearchAwait(source, name, author).getOrNull()
if (foundBook != null) break
}
if (foundBook != null) {
val book = foundBook
if (groupId > 0) {
book.group = groupId
}
if (bookRepository.getBook(book.bookUrl) != null) {
bookRepository.update(book)
} else {
bookRepository.insert(book)
}
} else {
withContext(Dispatchers.Main) {
context.toastOnUi("没有搜索到<$name>$author")
}
}
}
}
}
}
}
@@ -0,0 +1,61 @@
package io.legado.app.domain.usecase
import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType
import io.legado.app.help.book.isUpError
import io.legado.app.help.book.removeType
import io.legado.app.help.book.sync
import io.legado.app.model.ReadBook
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.ensureActive
class RefreshTocUseCase(
private val bookRepository: BookRepository,
private val bookSourceRepository: BookSourceRepository
) {
suspend fun execute(
bookUrl: String,
onSuccess: suspend (BookSource, Book) -> Unit = { _, _ -> }
): Result<Unit> = kotlin.runCatching {
val book = bookRepository.getBook(bookUrl) ?: throw Exception("Book not found")
val source = bookSourceRepository.getBookSource(book.origin)
if (source == null) {
if (!book.isUpError) {
book.addType(BookType.updateError)
bookRepository.update(book)
}
throw Exception("Source not found")
}
val oldBook = book.copy()
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(source, book)
} else {
WebBook.runPreUpdateJs(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
book.sync(oldBook)
book.removeType(BookType.updateError)
if (book.bookUrl == bookUrl) {
bookRepository.update(book)
} else {
bookRepository.replace(oldBook, book)
BookHelp.updateCacheFolder(oldBook, book)
}
bookRepository.deleteChaptersByBook(bookUrl)
bookRepository.insertChapters(*toc.toTypedArray())
ReadBook.onChapterListUpdated(book)
onSuccess(source, book)
}.onFailure {
kotlin.coroutines.coroutineContext.ensureActive()
bookRepository.getBook(bookUrl)?.let { book ->
book.addType(BookType.updateError)
bookRepository.update(book)
}
}
}
@@ -0,0 +1,11 @@
package io.legado.app.domain.usecase
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.SearchRepository
class SaveSearchBooksUseCase(
private val searchRepository: SearchRepository,
) {
suspend fun save(book: SearchBook) = save(listOf(book))
suspend fun save(books: List<SearchBook>) = searchRepository.saveSearchBooks(books)
}
@@ -6,10 +6,12 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.help.config.AppConfig
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ExploreBooksUseCase
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -50,7 +52,9 @@ data class ExploreBookItemUi(
class ExploreShowViewModel(
private val repository: ExploreRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val exploreBooksUseCase: ExploreBooksUseCase,
private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
) : ViewModel() {
private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
@@ -124,13 +128,13 @@ class ExploreShowViewModel(
}
fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
// 允许 incomingExploreUrl 为空,此时加载书源默认发现页
if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
return
}
sourceUrl = incomingSourceUrl
exploreUrl = incomingExploreUrl
page = 1
bookSource = null
_rawBooks.value = emptyList()
_isEndStateFlow.value = false
_errorMsg.value = null
@@ -139,8 +143,13 @@ class ExploreShowViewModel(
viewModelScope.launch {
if (bookSource == null && incomingSourceUrl != null) {
bookSource = repository.getBookSource(incomingSourceUrl)
loadKinds(incomingSourceUrl)
}
// 如果仍然没有发现 URL,且书源已加载,尝试使用书源的默认发现页
if (exploreUrl == null && bookSource != null) {
loadKinds(incomingSourceUrl!!)
}
loadMore(isRefresh = true)
}
}
@@ -175,7 +184,7 @@ class ExploreShowViewModel(
fun loadMore(isRefresh: Boolean = false) {
val source = bookSource
val url = exploreUrl
val url = exploreUrl ?: source?.exploreUrl
if (source == null || url == null || _isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
viewModelScope.launch {
@@ -188,29 +197,30 @@ class ExploreShowViewModel(
_rawBooks.value = emptyList()
}
repository.exploreBook(source, url, page)
.onSuccess { newBooks ->
if (newBooks.isEmpty()) {
kotlin.runCatching {
exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page)
}.onSuccess { result ->
if (result.books.isEmpty()) {
_isEndStateFlow.value = true
} else {
saveSearchBooksUseCase.save(result.books)
val currentList = _rawBooks.value
val existingUrls = currentList.map { it.bookUrl }.toSet()
val uniqueNewBooks = result.books
.filter { it.bookUrl !in existingUrls }
.distinctBy { it.bookUrl }
if (uniqueNewBooks.isEmpty()) {
_isEndStateFlow.value = true
} else {
repository.saveSearchBooks(newBooks)
val currentList = _rawBooks.value
val existingUrls = currentList.map { it.bookUrl }.toSet()
val uniqueNewBooks = newBooks
.filter { it.bookUrl !in existingUrls }
.distinctBy { it.bookUrl }
if (uniqueNewBooks.isEmpty()) {
_isEndStateFlow.value = true
} else {
_rawBooks.value = currentList + uniqueNewBooks
page++
_isEndStateFlow.value = false
}
_rawBooks.value = currentList + uniqueNewBooks
page++
_isEndStateFlow.value = false
}
}
}
.onFailure {
_errorMsg.value = it.localizedMessage
}
@@ -4,14 +4,13 @@ import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.BatchChangeSourceCandidate
import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
@@ -145,9 +144,9 @@ sealed interface BookshelfManageScreenEffect {
class BookshelfManageScreenViewModel(
application: Application,
private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao,
private val bookChapterDao: BookChapterDao,
private val bookRepository: BookRepository,
private val bookGroupRepository: BookGroupRepository,
private val searchRepository: SearchRepository,
val bookshelfManageScreenConfig: BookshelfManageScreenConfig,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
@@ -337,7 +336,7 @@ class BookshelfManageScreenViewModel(
private fun observeGroups() {
groupsJob?.cancel()
groupsJob = viewModelScope.launch {
bookGroupDao.flowAll().collect { groups ->
bookGroupRepository.flowAll().collect { groups ->
_uiState.update { it.copy(groupList = groups) }
}
}
@@ -346,7 +345,7 @@ class BookshelfManageScreenViewModel(
private fun observeBooks(groupId: Long) {
booksJob?.cancel()
booksJob = viewModelScope.launch {
bookDao.flowBookShelfByGroup(groupId).map { books ->
bookRepository.flowBookShelfByGroup(groupId).map { books ->
val booksDownload = books.filter { !it.isAudio }.map { it.toLightBook() }
val bookSort = bookshelfManageScreenConfig.getBookSortByGroupId(groupId)
val isDescending = bookshelfManageScreenConfig.bookshelfSortOrder == 1
@@ -475,7 +474,7 @@ class BookshelfManageScreenViewModel(
private fun refreshGroupName(groupId: Long) {
execute {
val title = bookGroupDao.getByID(groupId)?.groupName
val title = bookGroupRepository.getByID(groupId)?.groupName
title ?: context.getString(io.legado.app.R.string.no_group)
}.onSuccess { groupName ->
_uiState.update { it.copy(groupName = groupName) }
@@ -532,7 +531,7 @@ class BookshelfManageScreenViewModel(
val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl }
cacheRefreshBookUrls.forEach { bookUrl ->
if (visibleBookUrls.contains(bookUrl)) {
bookDao.getBook(bookUrl)?.let { book ->
bookRepository.getBook(bookUrl)?.let { book ->
cacheCounts[bookUrl] = calculateCacheCount(book)
changedBookUrls.add(bookUrl)
}
@@ -549,12 +548,12 @@ class BookshelfManageScreenViewModel(
emitBooksChanged(changedBookUrls)
}
private fun calculateCacheCount(book: Book): Int {
private suspend fun calculateCacheCount(book: Book): Int {
val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isEmpty()) return 0
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
val totalCount = bookRepository.getChapterCount(book.bookUrl)
val cachedFileCount = cacheNames.count { it.endsWith(".nb") }
return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
return min(cachedFileCount + bookRepository.getVolumeCount(book.bookUrl), totalCount)
}
private fun Int?.orZero(): Int = this ?: 0
@@ -661,7 +660,7 @@ class BookshelfManageScreenViewModel(
)
}
execute {
bookDao.update(*reorderedBooks.toTypedArray())
bookRepository.update(*reorderedBooks.toTypedArray())
}.onError {
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("排序保存失败\n${it.localizedMessage}"))
}
@@ -696,7 +695,7 @@ class BookshelfManageScreenViewModel(
options: ChangeSourceMigrationOptions,
) {
execute {
val oldBook = bookDao.getBook(oldBookUrl) ?: return@execute null
val oldBook = bookRepository.getBook(oldBookUrl) ?: return@execute null
changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
}.onSuccess { result ->
result ?: return@onSuccess
@@ -734,7 +733,7 @@ class BookshelfManageScreenViewModel(
batchChangePreviewItems = emptyList()
)
}
val books = bookUrls.mapNotNull { bookDao.getBook(it) }
val books = bookUrls.mapNotNull { bookRepository.getBook(it) }
changeBookSourceUseCase.prepareBatchChange(
books = books,
sources = sources,
@@ -786,7 +785,7 @@ class BookshelfManageScreenViewModel(
} ?: return
val candidate = item.selectedCandidate ?: return
execute {
val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
val oldBook = bookRepository.getBook(oldBookUrl) ?: item.oldBook
val chapters = changeBookSourceUseCase.loadCandidateChapters(
candidate.source,
candidate.book
@@ -885,10 +884,10 @@ class BookshelfManageScreenViewModel(
) ?: error("获取目录失败")
candidate.book.removeType(BookType.notShelf)
if (candidate.book.order == 0) {
candidate.book.order = bookDao.minOrder - 1
candidate.book.order = bookRepository.getMinOrder() - 1
}
bookDao.insert(candidate.book)
bookChapterDao.insert(*chapters.toTypedArray())
bookRepository.insert(candidate.book)
bookRepository.insertChapters(*chapters.toTypedArray())
candidate.book
}.onSuccess {
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
@@ -900,7 +899,7 @@ class BookshelfManageScreenViewModel(
private fun openBookInfoPreview(book: Book, inBookshelf: Boolean) {
execute {
if (!inBookshelf) {
appDb.searchBookDao.insert(book.toSearchBook())
searchRepository.saveSearchBooks(listOf(book.toSearchBook()))
}
book
}.onSuccess {
@@ -931,7 +930,7 @@ class BookshelfManageScreenViewModel(
it.copy(changeSourceProgress = "${index + 1} / ${items.size} ${item.oldBook.name}")
}
val candidate = item.selectedCandidate ?: return@forEachIndexed
val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
val oldBook = bookRepository.getBook(item.oldBook.bookUrl) ?: item.oldBook
val chapters = changeBookSourceUseCase.loadCandidateChapters(
candidate.source,
candidate.book
@@ -978,10 +977,10 @@ class BookshelfManageScreenViewModel(
) ?: return@forEachIndexed
candidate.book.removeType(BookType.notShelf)
if (candidate.book.order == 0) {
candidate.book.order = bookDao.minOrder - 1
candidate.book.order = bookRepository.getMinOrder() - 1
}
bookDao.insert(candidate.book)
bookChapterDao.insert(*chapters.toTypedArray())
bookRepository.insert(candidate.book)
bookRepository.insertChapters(*chapters.toTypedArray())
}
}.onSuccess {
_uiState.update { it.copy(batchChangePreviewItems = emptyList()) }
@@ -68,6 +68,7 @@ class BookSourceEditActivity :
private val sourceEntities: ArrayList<EditEntity> = ArrayList()
private val searchEntities: ArrayList<EditEntity> = ArrayList()
private val exploreEntities: ArrayList<EditEntity> = ArrayList()
private val homepageEntities: ArrayList<EditEntity> = ArrayList()
private val infoEntities: ArrayList<EditEntity> = ArrayList()
private val tocEntities: ArrayList<EditEntity> = ArrayList()
private val contentEntities: ArrayList<EditEntity> = ArrayList()
@@ -174,6 +175,9 @@ class BookSourceEditActivity :
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_find)
})
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_homepage)
})
binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_info)
})
@@ -234,10 +238,10 @@ class BookSourceEditActivity :
adapter.editEntities = when (tabPosition) {
1 -> searchEntities
2 -> exploreEntities
3 -> infoEntities
4 -> tocEntities
5 -> contentEntities
// 6 -> reviewEntities
3 -> homepageEntities
4 -> infoEntities
5 -> tocEntities
6 -> contentEntities
else -> sourceEntities
}
binding.recyclerView.scrollToPosition(0)
@@ -306,6 +310,11 @@ class BookSourceEditActivity :
add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url))
add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url))
}
// 主页模块
homepageEntities.clear()
homepageEntities.apply {
add(EditEntity("homepageModules", bs.homepageModules, R.string.homepage_modules))
}
// 详情页
val ir = bs.getBookInfoRule()
infoEntities.clear()
@@ -473,6 +482,12 @@ class BookSourceEditActivity :
viewModel.ruleComplete(it.value, exploreRule.bookList, 2)
}
}
homepageEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) {
"homepageModules" -> source.homepageModules = it.value
}
}
infoEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) {
@@ -170,6 +170,8 @@ object ThemeConfig {
var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
var navIconHome by prefDelegate(PreferKey.navIconHome, "")
var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "")
var navIconExplore by prefDelegate(PreferKey.navIconExplore, "")
@@ -9,6 +9,11 @@ sealed class MainDestination(
val route: String,
@StringRes val labelId: Int
) {
object Home : MainDestination(
route = "home",
labelId = R.string.home
)
object Bookshelf : MainDestination(
route = "bookshelf",
labelId = R.string.bookshelf
@@ -30,12 +35,13 @@ sealed class MainDestination(
)
companion object {
val mainDestinations = persistentListOf<MainDestination>(Bookshelf, Explore, Rss, My)
val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
}
}
val MainDestination.customIconPath: String
get() = when (this) {
MainDestination.Home -> ThemeConfig.navIconHome
MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf
MainDestination.Explore -> ThemeConfig.navIconExplore
MainDestination.Rss -> ThemeConfig.navIconRss
@@ -1,7 +0,0 @@
package io.legado.app.ui.main
interface MainFragmentInterface {
val position: Int?
}
@@ -71,6 +71,7 @@ import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreScreen
import io.legado.app.ui.main.homepage.HomepageScreen
import io.legado.app.ui.main.my.MyScreen
import io.legado.app.ui.main.my.PrefClickEvent
import io.legado.app.ui.main.rss.RssScreen
@@ -366,6 +367,17 @@ fun MainScreen(
) { page ->
val destination = destinations.getOrNull(page) ?: return@HorizontalPager
when (destination) {
MainDestination.Home -> HomepageScreen(
onBookClick = { name, author, bookUrl ->
onNavigateToBookInfo(name ?: "", author ?: "", bookUrl)
},
onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
onNavigateToExploreShow(title, sourceUrl, exploreUrl)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
MainDestination.Bookshelf -> BookshelfScreen(
onBookClick = { book ->
context.startActivityForBook(book)
@@ -4,54 +4,38 @@ import android.app.Application
import android.net.Uri
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.google.gson.stream.JsonWriter
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.data.repository.BookshelfRepository
import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.AddBookUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase
import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.addType
import io.legado.app.help.book.isUpError
import io.legado.app.help.book.removeType
import io.legado.app.help.book.sync
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook
import io.legado.app.model.SourceCallBack
import io.legado.app.model.webBook.WebBook
import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.cnCompare
import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.move
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.readText
import io.legado.app.utils.toastOnUi
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -64,9 +48,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -87,23 +69,24 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.math.min
class BookshelfViewModel(
application: Application,
private val bookRepository: BookRepository,
private val bookGroupRepository: BookGroupRepository,
private val bookSourceRepository: BookSourceRepository,
private val bookshelfRepository: BookshelfRepository,
private val uploadRepository: UploadRepository,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase,
private val refreshTocUseCase: RefreshTocUseCase,
private val addBookUseCase: AddBookUseCase,
private val importBookshelfUseCase: ImportBookshelfUseCase,
private val exportBookshelfUseCase: ExportBookshelfUseCase
) : BaseViewModel(application) {
private var addBookJob: Coroutine<*>? = null
@@ -199,14 +182,15 @@ class BookshelfViewModel(
val booksFlow: Flow<List<BookUiItem>> = groupIdFlow
.flatMapLatest { groupId ->
combine(
appDb.bookDao.flowBookShelfByGroup(groupId),
bookRepository.flowBookShelfByGroup(groupId),
groupsFlow,
sortConfigFlow
) { list, groups, sortConfig ->
sortBooks(
bookshelfRepository.sortBooks(
list,
groups.find { it.groupId == groupId },
sortConfig
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }
}
}.distinctUntilChanged().flowOn(Dispatchers.Default)
@@ -221,8 +205,13 @@ class BookshelfViewModel(
flowOf(emptyMap())
} else {
val flows = groups.map { group ->
appDb.bookDao.flowBookShelfByGroup(group.groupId).map { books ->
group.groupId to sortBooks(books, group, sortConfig).map { it.toUiItem() }
bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
group.groupId to bookshelfRepository.sortBooks(
books,
group,
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }
}
}
combine(flows) { it.toMap() }
@@ -269,8 +258,8 @@ class BookshelfViewModel(
private val groupPreviewsFlow = combine(
groupsFlow,
bookGroupStyleFlow,
appDb.bookDao.flowSystemGroupCounts(),
appDb.bookDao.flowAllBookShelfCount()
bookRepository.flowSystemGroupCounts(),
bookRepository.flowAllBookShelfCount()
) { groups, bookGroupStyle, systemCounts, totalCount ->
DataForPreviews(
groups,
@@ -291,11 +280,11 @@ class BookshelfViewModel(
} else {
val groupFlows = groups.map { group ->
val countFlow: Flow<Int> = if (group.groupId > 0) {
appDb.bookDao.flowUserGroupBookCount(group.groupId)
bookRepository.flowUserGroupBookCount(group.groupId)
} else {
flowOf(systemCountsMap[group.groupId] ?: 0)
}
val previewFlow = appDb.bookDao.flowGroupPreview(group.groupId)
val previewFlow = bookRepository.flowGroupPreview(group.groupId)
combine(countFlow, previewFlow) { count, preview ->
Triple(group.groupId, count, preview.map { it.toUiItem() })
}
@@ -572,48 +561,6 @@ class BookshelfViewModel(
}
}
private fun sortBooks(
list: List<BookShelfItem>,
group: BookGroup?,
sortConfig: BookshelfSortConfig
): List<BookShelfItem> {
val bookSort = if (group != null && group.bookSort >= 0) {
group.bookSort
} else {
sortConfig.sort
}
val isDescending = sortConfig.sortOrder == 1
return when (bookSort) {
1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
else list.sortedBy { it.latestChapterTime }
2 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
else
list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> if (isDescending) list.sortedByDescending { it.order }
else list.sortedBy { it.order }
4 -> if (isDescending) list.sortedByDescending {
max(
it.latestChapterTime,
it.durChapterTime
)
}
else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
5 -> if (isDescending)
list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
else
list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
else list.sortedBy { it.durChapterTime }
}
}
private fun buildTitle(
bookGroupStyle: Int,
isInFolderRoot: Boolean,
@@ -824,8 +771,6 @@ class BookshelfViewModel(
fun gotoTop() {
scrollTrigger.tryEmit(Unit)
}
// 更新逻辑移入
fun upAllBookToc() {
execute {
addToWaitUp(appDb.bookDao.hasUpdateBooks)
@@ -951,51 +896,8 @@ class BookshelfViewModel(
}
private suspend fun updateToc(bookUrl: String) {
val book = appDb.bookDao.getBook(bookUrl) ?: return
val source = appDb.bookSourceDao.getBookSource(book.origin)
if (source == null) {
if (!book.isUpError) {
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
return
}
if (source.eventListener) {
if (eventListenerSource.putIfAbsent(source, true) == null) {
SourceCallBack.callBackSource(
viewModelScope,
SourceCallBack.START_SHELF_REFRESH,
source
)
}
}
kotlin.runCatching {
val oldBook = book.copy()
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(source, book)
} else {
WebBook.runPreUpdateJs(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
book.sync(oldBook)
book.removeType(BookType.updateError)
if (book.bookUrl == bookUrl) {
appDb.bookDao.update(book)
} else {
appDb.bookDao.replace(oldBook, book)
BookHelp.updateCacheFolder(oldBook, book)
}
appDb.bookChapterDao.delByBook(bookUrl)
appDb.bookChapterDao.insert(*toc.toTypedArray())
ReadBook.onChapterListUpdated(book)
refreshTocUseCase.execute(bookUrl) { source, book ->
addDownload(source, book)
}.onFailure {
currentCoroutineContext().ensureActive()
AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it)
appDb.bookDao.getBook(book.bookUrl)?.let { book ->
book.addType(BookType.updateError)
appDb.bookDao.update(book)
}
}
}
@@ -1045,58 +947,11 @@ class BookshelfViewModel(
}
fun addBookByUrl(bookUrls: String) {
var successCount = 0
loadingTextFlow.value = "添加中..."
addBookJob = execute {
val hasBookUrlPattern: List<BookSourcePart> by lazy {
appDb.bookSourceDao.hasBookUrlPattern
val successCount = addBookUseCase.execute(bookUrls) {
loadingTextFlow.value = "添加中... ($it)"
}
val urls = bookUrls.split("\n")
for (url in urls) {
val bookUrl = url.trim()
if (bookUrl.isEmpty()) continue
if (appDb.bookDao.getBook(bookUrl) != null) {
successCount++
continue
}
val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue
var source = appDb.bookSourceDao.getBookSourceAddBook(baseUrl)
if (source == null) {
for (bookSource in hasBookUrlPattern) {
try {
val bs = bookSource.getBookSource()!!
if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) {
source = bs
break
}
} catch (_: Exception) {
}
}
}
val bookSource = source ?: continue
val book = Book(
bookUrl = bookUrl,
origin = bookSource.bookSourceUrl,
originName = bookSource.bookSourceName
)
kotlin.runCatching {
WebBook.getBookInfoAwait(bookSource, book)
}.onSuccess {
val dbBook = appDb.bookDao.getBook(it.name, it.author)
if (dbBook != null) {
val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow()
dbBook.migrateTo(it, toc)
appDb.bookDao.insert(it)
appDb.bookChapterDao.insert(*toc.toTypedArray())
} else {
it.order = appDb.bookDao.minOrder - 1
it.save()
}
successCount++
loadingTextFlow.value = "添加中... ($successCount)"
}
}
}.onSuccess {
if (successCount > 0) {
context.toastOnUi(R.string.success)
} else {
@@ -1111,23 +966,7 @@ class BookshelfViewModel(
fun exportToUri(uri: Uri, items: List<BookUiItem>) {
execute {
context.contentResolver.openOutputStream(uri)?.use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
// intro is not in BookShelfItem, fetch from DB if needed or skip
// For now, let's keep it simple and skip intro or fetch it
val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
writer.close()
}
exportBookshelfUseCase.exportToUri(uri, items).getOrThrow()
}.onSuccess {
_eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出成功"))
}.onError {
@@ -1137,17 +976,7 @@ class BookshelfViewModel(
fun uploadBookshelf(items: List<BookUiItem>) {
execute {
val json = withContext(Dispatchers.Default) {
val list = items.map {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
bookMap
}
GSON.toJson(list)
}
val json = exportBookshelfUseCase.exportToJson(items).getOrThrow()
uploadRepository.upload(
fileName = "bookshelf.json",
file = json,
@@ -1172,27 +1001,8 @@ class BookshelfViewModel(
fun exportBookshelf(items: List<BookUiItem>?, success: (file: File) -> Unit) {
execute {
items?.let {
val path = "${context.filesDir}/books.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
FileOutputStream(file).use { out ->
val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
writer.setIndent(" ")
writer.beginArray()
items.forEach {
val bookMap = hashMapOf<String, String?>()
bookMap["name"] = it.book.name
bookMap["author"] = it.book.author
val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
bookMap["intro"] = fullBook?.getDisplayIntro()
GSON.toJson(bookMap, bookMap::class.java, writer)
}
writer.endArray()
writer.close()
}
file
} ?: throw NoStackTraceException("书籍不能为空")
items ?: throw NoStackTraceException("书籍不能为空")
exportBookshelfUseCase.exportToFile(items).getOrThrow()
}.onSuccess {
success(it)
}.onError {
@@ -1202,70 +1012,29 @@ class BookshelfViewModel(
fun importBookshelf(str: String, groupId: Long) {
execute {
val text = str.trim()
when {
text.isAbsUrl() -> {
okHttpClient.newCallResponseBody {
url(text)
}.decompressed().text().let {
importBookshelf(it, groupId)
}
}
text.isJsonArray() -> {
importBookshelfByJson(text, groupId)
}
else -> {
throw NoStackTraceException("格式不对")
}
}
importBookshelfUseCase.import(str, groupId) {
loadingTextFlow.value = it
}.getOrThrow()
}.onSuccess {
context.toastOnUi(R.string.success)
}.onError {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}.onFinally {
loadingTextFlow.value = null
}
}
fun importBookshelf(uri: Uri, groupId: Long) {
execute {
uri.readText(context)
importBookshelfUseCase.import(uri, groupId) {
loadingTextFlow.value = it
}.getOrThrow()
}.onSuccess {
importBookshelf(it, groupId)
context.toastOnUi(R.string.success)
}.onError {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
}
private fun importBookshelfByJson(json: String, groupId: Long) {
loadingTextFlow.value = "导入中..."
execute {
val bookSourceParts = appDb.bookSourceDao.allEnabledPart
val semaphore = Semaphore(AppConfig.threadCount)
GSON.fromJsonArray<Map<String, String?>>(json).getOrThrow().forEach { bookInfo ->
val name = bookInfo["name"] ?: ""
val author = bookInfo["author"] ?: ""
if (name.isEmpty() || appDb.bookDao.has(name, author)) {
return@forEach
}
semaphore.withPermit {
WebBook.preciseSearch(
this, bookSourceParts, name, author,
semaphore = semaphore
).onSuccess {
val book = it.first
if (groupId > 0) {
book.group = groupId
}
book.save()
}.onError { e ->
context.toastOnUi(e.localizedMessage)
}
}
}
}.onError {
it.printOnDebug()
}.onFinally {
loadingTextFlow.value = null
context.toastOnUi(R.string.success)
}
}
@@ -0,0 +1,26 @@
package io.legado.app.ui.main.homepage
import androidx.compose.runtime.State
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefStateDelegate
/**
* 首页配置 — 仅保留无法入库或无需入库的顶层 UI 设置。
* 模块级配置(开关、排序、自定义集)已迁移到 homepage_modules 表。
*/
object HomepageConfig {
/**
* 首页布局模式 0: 混合列表 1: 分源Tab
*/
private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0)
var homepageLayoutMode by _homepageLayoutMode
val homepageLayoutModeState: State<Int> get() = _homepageLayoutMode.state
/**
* 首页书源隐藏
*/
private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "")
var homepageSourceHidden by _homepageSourceHidden
}
@@ -0,0 +1,76 @@
package io.legado.app.ui.main.homepage
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.model.HomepageModuleType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class HomepageUiState(
val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
val isManageMode: Boolean = false,
val isConfigMode: Boolean = false,
val isRefreshing: Boolean = false,
)
@Stable
data class HomepageSourceManageUi(
val sourceUrl: String,
val sourceName: String,
val sourceGroup: String?,
val isSelected: Boolean = false,
val moduleCount: Int = 0,
val isCustomSet: Boolean = false,
)
@Stable
data class HomepageModuleManageUi(
val id: String,
val sourceUrl: String,
val moduleKey: String,
val title: String,
val customSetTitle: String? = null,
val customSetId: String? = null,
val isVisible: Boolean,
val type: String = "card",
val url: String? = null,
val args: String? = null,
val layoutConfig: String? = null,
val originalTitle: String = "",
)
@Stable
data class HomepageModuleUi(
val sourceUrl: String,
val setName: String,
val globalId: String,
val type: HomepageModuleType,
val title: String,
val exploreUrl: String? = null,
val customSetId: String? = null,
val layoutConfig: String? = null,
val state: ModuleLoadState = ModuleLoadState.Loading,
val config: Map<String, String> = emptyMap()
)
@Stable
sealed interface ModuleLoadState {
@Stable
data object Loading : ModuleLoadState
@Stable
data class Loaded(
val books: ImmutableList<SearchBook>,
val hasMore: Boolean = false,
val isLoadingMore: Boolean = false,
val page: Int = 1
) : ModuleLoadState
@Stable
data class Buttons(val kinds: ImmutableList<ExploreKind>) : ModuleLoadState
@Stable
data class Error(val message: String) : ModuleLoadState
}
@@ -0,0 +1,17 @@
package io.legado.app.ui.main.homepage
sealed interface HomepageEffect {
data class NavigateToBookInfo(
val name: String?,
val author: String?,
val bookUrl: String,
) : HomepageEffect
data class NavigateToExploreShow(
val title: String?,
val sourceUrl: String,
val exploreUrl: String?,
) : HomepageEffect
data class ShowSnackbar(val message: String) : HomepageEffect
}
@@ -0,0 +1,51 @@
package io.legado.app.ui.main.homepage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
@Composable
fun HomepageLayoutSheet(
show: Boolean,
onDismissRequest: () -> Unit,
layoutMode: Int,
onLayoutModeChange: (Int) -> Unit,
) {
HomepageLayoutSheet(
data = if (show) Unit else null,
onDismissRequest = onDismissRequest,
layoutMode = layoutMode,
onLayoutModeChange = onLayoutModeChange,
)
}
@Composable
fun <T> HomepageLayoutSheet(
data: T?,
onDismissRequest: () -> Unit,
layoutMode: Int,
onLayoutModeChange: (Int) -> Unit,
) {
AppModalBottomSheet(
data = data,
onDismissRequest = onDismissRequest,
title = "布局设置",
) {
Column {
DropdownListSettingItem(
title = "首页布局模式",
selectedValue = layoutMode.toString(),
displayEntries = arrayOf("混合列表", "分源Tab"),
entryValues = arrayOf("0", "1"),
onValueChange = { onLayoutModeChange(it.toInt()) }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,617 @@
package io.legado.app.ui.main.homepage
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
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.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.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.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
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
import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.modules.BannerModule
import io.legado.app.ui.main.homepage.modules.ButtonGroupModule
import io.legado.app.ui.main.homepage.modules.CardModule
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.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold
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.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.utils.sendToClip
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalSharedTransitionApi::class
)
@Composable
fun HomepageScreen(
viewModel: HomepageViewModel = koinViewModel(),
onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit,
onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val allSets by viewModel.setsFlow.collectAsStateWithLifecycle()
val browseSources by viewModel.browseSourcesFlow.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val context = LocalContext.current
var errorMsg by remember { mutableStateOf<String?>(null) }
val layoutMode = HomepageConfig.homepageLayoutModeState.value
val selectedSets = remember(allSets) { allSets.filter { it.isSelected } }
val pagerState = rememberPagerState(pageCount = {
if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1
})
val mixedGridState = rememberLazyStaggeredGridState()
val currentTitle by remember(
layoutMode,
pagerState.currentPage,
selectedSets,
uiState.modules
) {
derivedStateOf {
if (layoutMode == 1) {
"首页"
} else {
val firstHeader = mixedGridState.layoutInfo.visibleItemsInfo.firstOrNull {
(it.key as? String)?.startsWith("header_") == true
}
if (firstHeader != null) {
val id = (firstHeader.key as? String).orEmpty().substringAfter("header_", "")
uiState.modules.find { it.globalId == id }?.setName ?: "首页"
} else {
"首页"
}
}
}
}
BackHandler(enabled = uiState.isManageMode || uiState.isConfigMode) {
if (uiState.isManageMode) viewModel.toggleManageMode()
else viewModel.toggleConfigMode()
}
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is HomepageEffect.NavigateToBookInfo ->
onBookClick(effect.name, effect.author, effect.bookUrl)
is HomepageEffect.NavigateToExploreShow ->
onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
is HomepageEffect.ShowSnackbar -> {}
}
}
}
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentWindowInsets = WindowInsets(0),
topBar = {
GlassMediumFlexibleTopAppBar(
title = currentTitle,
scrollBehavior = scrollBehavior,
actions = {
TopBarActionButton(
onClick = { viewModel.toggleConfigMode() },
imageVector = Icons.Default.GridView,
contentDescription = "Layout Settings",
)
TopBarActionButton(
onClick = { viewModel.toggleManageMode() },
imageVector = Icons.Default.Settings,
contentDescription = "Manage Modules",
)
},
bottomContent = {
if (layoutMode == 1 && selectedSets.isNotEmpty()) {
AppTabRow(
tabTitles = selectedSets.map { it.sourceName },
selectedTabIndex = pagerState.currentPage,
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
)
},
) { paddingValues ->
AppPullToRefresh(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.onRefresh() },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (layoutMode == 0) {
ModuleList(
modules = uiState.modules,
viewModel = viewModel,
gridState = mixedGridState,
modifier = Modifier.fillMaxSize(),
onErrorClick = { errorMsg = it },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
} else {
if (selectedSets.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AppText("未选择任何书源集")
}
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
key = { index -> selectedSets.getOrNull(index)?.sourceUrl ?: index }
) { pageIndex ->
val source = selectedSets.getOrNull(pageIndex)
val sourceModules = remember(uiState.modules, source) {
uiState.modules.filter { module ->
if (source?.isCustomSet == true) {
val setId =
HomepageViewModel.customSetIdFromUrl(source.sourceUrl)
module.customSetId == setId
} else {
module.sourceUrl == source?.sourceUrl
}
}
}
ModuleList(
modules = sourceModules,
viewModel = viewModel,
modifier = Modifier.fillMaxSize(),
onErrorClick = { errorMsg = it },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
}
}
AppAlertDialog(
data = errorMsg,
onDismissRequest = { errorMsg = null },
title = "模块错误",
confirmText = "复制",
onConfirm = {
context.sendToClip(it)
errorMsg = null
},
dismissText = "关闭",
onDismiss = { errorMsg = null }
)
HomepageModuleManageSheet(
data = if (uiState.isManageMode) Unit else null,
onDismissRequest = { viewModel.toggleManageMode() },
sets = allSets,
browseSources = browseSources,
onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) },
onGetModulesInSet = { viewModel.getJoinedModules(it) },
onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) },
onJoinModule = { sourceUrl, targetSetId, def ->
viewModel.joinModule(
sourceUrl,
targetSetId,
def
)
},
onAddCustomModule = { sourceUrl, targetSetId, def ->
viewModel.addCustomModule(
sourceUrl,
targetSetId,
def
)
},
onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds ->
viewModel.addButtonGroupFromKinds(
sourceUrl,
targetSetId,
title,
kinds
)
},
onGetExploreKinds = { viewModel.getSourceExploreKinds(it) },
onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) },
onDeleteModule = { viewModel.deleteModule(it) },
onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) },
onReorderSets = { urls -> viewModel.reorderCustomSets(urls) },
onSetCustomSetTitle = { id, title -> viewModel.setModuleCustomSetTitle(id, title) },
onCreateCustomSet = { viewModel.createCustomSet(it) },
onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) },
onDeleteCustomSet = { viewModel.deleteCustomSet(it) },
onGetAllModulesGroupedBySource = { viewModel.getAllModulesGroupedBySource() },
onGetSourceName = { viewModel.getSourceName(it) },
onAssignModuleToCustomSet = { id, setId ->
viewModel.assignModuleToCustomSet(
id,
setId
)
}
)
HomepageLayoutSheet(
data = if (uiState.isConfigMode) Unit else null,
onDismissRequest = { viewModel.toggleConfigMode() },
layoutMode = layoutMode,
onLayoutModeChange = { viewModel.setLayoutMode(it) },
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun ModuleList(
modules: List<HomepageModuleUi>,
viewModel: HomepageViewModel,
modifier: Modifier = Modifier,
gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onErrorClick: (String) -> Unit
) {
if (modules.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AppText("请在书源中添加首页模块定义")
}
} else {
// 1. 过滤和重排模块:每个集只能有一个无限流模块,且必须在最下面
val processedModules = remember(modules) {
fun isInfinite(m: HomepageModuleUi): Boolean {
return m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
val infinite = modules.firstOrNull { isInfinite(it) }
val others = modules.filter { !isInfinite(it) }
if (infinite != null) others + infinite else others
}
val gridColumns = remember(processedModules) {
val infiniteModule = processedModules.find { m ->
m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
infiniteModule?.config?.get("layout_columns")?.toIntOrNull() ?: 2
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(gridColumns),
state = gridState,
modifier = modifier,
verticalItemSpacing = 16.dp,
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp),
) {
processedModules.forEach { moduleUi ->
// 1. 头部 (全宽)
item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
ModuleHeader(
title = moduleUi.title,
onNavigate = {
viewModel.onModuleHeaderClick(
moduleUi.sourceUrl,
moduleUi.exploreUrl,
moduleUi.title,
)
},
)
}
// 2. 内容正文
when (val state = moduleUi.state) {
is ModuleLoadState.Loading -> {
item(
key = "loading_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center,
) {
AppCircularProgressIndicator()
}
}
}
is ModuleLoadState.Error -> {
item(
key = "error_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onErrorClick(state.message) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
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 = "重试",
onClick = {
viewModel.retryModule(moduleUi.globalId)
}
)
}
}
}
is ModuleLoadState.Buttons -> {
item(
key = "buttons_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
ButtonGroupModule(
kinds = state.kinds,
sourceUrl = moduleUi.sourceUrl,
globalId = moduleUi.globalId,
viewModel = viewModel,
modifier = Modifier.fillMaxWidth(),
layoutConfig = moduleUi.layoutConfig
)
}
}
is ModuleLoadState.Loaded -> {
val config = moduleUi.config
when (moduleUi.type) {
HomepageModuleType.Waterfall -> {
items(
state.books,
key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
WaterfallItem(
book = book,
onClick = { viewModel.onBookClick(book) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
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))
}
}
}
}
HomepageModuleType.InfiniteGrid -> {
items(
state.books,
key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
SearchBookGridItem(
book = book,
shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
onClick = { viewModel.onBookClick(book) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
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))
}
}
}
}
HomepageModuleType.Grid -> {
val rows = config["layout_rows"]?.toIntOrNull() ?: 2
val columns = config["layout_columns"]?.toIntOrNull() ?: 3
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
GridModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
modifier = Modifier.fillMaxWidth(),
columns = columns,
maxRows = rows,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
else -> {
when (moduleUi.type) {
HomepageModuleType.Banner -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
BannerModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
HomepageModuleType.Ranking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
RankingModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
HomepageModuleType.GridRanking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
GridRankingModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
HomepageModuleType.Card -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
CardModule(
books = state.books,
onClick = { viewModel.onBookClick(it) },
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
else -> {}
}
}
}
}
}
}
}
}
}
@Composable
private fun ModuleHeader(
title: String,
onNavigate: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
SmallTonalIconButton(
onClick = onNavigate,
imageVector = Icons.AutoMirrored.Filled.ArrowForward
)
}
}
@@ -0,0 +1,92 @@
package io.legado.app.ui.main.homepage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@Composable
fun HomepageSourceSelectSheet(
show: Boolean,
onDismissRequest: () -> Unit,
sources: List<HomepageSourceManageUi>,
onToggleSource: (String) -> Unit,
onSelectAll: () -> Unit,
) {
var filterText by remember(show) { mutableStateOf("") }
val filteredSources = remember(sources, filterText) {
if (filterText.isBlank()) sources else sources.filter {
it.sourceName.contains(filterText, ignoreCase = true) ||
it.sourceGroup?.contains(filterText, ignoreCase = true) == true
}
}
val isAllSelected = remember(sources) {
sources.all { it.isSelected } || sources.none { it.isSelected }
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = "筛选书源",
) {
Column {
SearchBar(
query = filterText,
onQueryChange = { filterText = it },
placeholder = stringResource(R.string.screen),
autoFocus = false
)
Spacer(modifier = Modifier.height(8.dp))
SelectionItemCard(
title = stringResource(R.string.all_source),
isSelected = isAllSelected,
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
onSelectAll()
}
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 480.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredSources, key = { it.sourceUrl }) { source ->
SelectionItemCard(
title = source.sourceName,
subtitle = source.sourceGroup?.takeIf { it.isNotBlank() },
containerColor = LegadoTheme.colorScheme.onSheetContent,
isSelected = source.isSelected,
inSelectionMode = true,
onToggleSelection = {
onToggleSource(source.sourceUrl)
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,882 @@
package io.legado.app.ui.main.homepage
import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.domain.gateway.HomepageModulesGateway
import io.legado.app.domain.model.CustomSetItem
import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.domain.model.ModuleDef
import io.legado.app.domain.model.ModuleItem
import io.legado.app.domain.usecase.ExploreBooksUseCase
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.MessageDigest
class HomepageViewModel(
application: Application,
private val bookSourceRepository: BookSourceRepository,
private val gateway: HomepageModulesGateway,
private val exploreBooksUseCase: ExploreBooksUseCase,
private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
) : BaseViewModel(application) {
companion object {
private const val CUSTOM_SET_URL_PREFIX = "custom://"
private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2
private const val HOMEPAGE_MAX_BUTTON_GROUP_KINDS = 5
fun customSetUrl(id: String) = "$CUSTOM_SET_URL_PREFIX$id"
fun isCustomSetUrl(url: String) = url.startsWith(CUSTOM_SET_URL_PREFIX)
fun customSetIdFromUrl(url: String): String = url.removePrefix(CUSTOM_SET_URL_PREFIX)
fun isInfinite(type: String?, layoutConfig: String?): Boolean {
return type == HomepageModuleType.Waterfall.key
|| type == HomepageModuleType.InfiniteGrid.key
}
private fun parseModuleDefs(source: BookSource, json: String): List<ModuleDef> =
GSON.fromJsonArray<ModuleDef>(json).getOrDefault(emptyList())
.map { it.copy(sourceUrl = source.bookSourceUrl) }
private fun jsonHash(json: String): String {
val digest = MessageDigest.getInstance("MD5").digest(json.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}
private fun List<ModuleItem>.groupBySourceOrdered(): Map<String, List<ModuleItem>> {
val result = linkedMapOf<String, MutableList<ModuleItem>>()
for (module in this) {
val key = module.customSetId?.let { customSetUrl(it) } ?: module.sourceUrl
result.getOrPut(key) { mutableListOf() }.add(module)
}
return result
}
}
private val _effects = MutableSharedFlow<HomepageEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
private val loadJobs = mutableMapOf<String, Job>()
private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules()
private val exploreSourcesFlow = bookSourceRepository.flowExploreSources()
private val _isRefreshing = MutableStateFlow(false)
private val _isManageMode = MutableStateFlow(false)
private val _isConfigMode = MutableStateFlow(false)
private val _configVersion = MutableStateFlow(0L)
private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
private val localModulesFlow = gateway.flowEnabled()
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
val allModulesCache = gateway.flowAll()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val customSetsFlow = gateway.flowCustomSets()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ ->
modules.groupBySourceOrdered()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private val uiFlagsFlow =
combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config ->
HomepageUiFlags(refreshing, manage, config)
}
val uiState: StateFlow<HomepageUiState> = combine(
orderedModuleDefsFlow,
_moduleContentStates,
uiFlagsFlow,
_bookSourcesCache,
customSetsFlow
) { grouped, contentStates, flags, sourcesCache, customSets ->
val setNames = customSets.associate { it.id to it.name }
val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id }
// 按照集排序设置来排布模块
val displayModules = sortedSetIds.flatMap { setId ->
val setUrl = customSetUrl(setId)
val mods = grouped[setUrl] ?: emptyList()
mods.map { module ->
val source = sourcesCache[module.sourceUrl]
val sourceName = source?.bookSourceName ?: module.sourceUrl
val setName = module.customSetId?.let { setNames[it] } ?: sourceName
val exploreUrl = module.url ?: source?.exploreUrl
val configMap = mutableMapOf<String, String>()
module.layoutConfig?.let { configStr ->
try {
val json = GSON.fromJson(configStr, Map::class.java)
json?.forEach { (k, v) ->
configMap["layout_$k"] = v.toString()
}
} catch (_: Exception) {
}
}
HomepageModuleUi(
sourceUrl = module.sourceUrl,
setName = setName,
globalId = module.id,
type = HomepageModuleType.fromKey(module.type),
title = module.displayTitle,
exploreUrl = exploreUrl,
customSetId = module.customSetId,
layoutConfig = module.layoutConfig,
state = contentStates[module.id] ?: ModuleLoadState.Loading,
config = configMap
)
}
}
HomepageUiState(
modules = displayModules.toImmutableList(),
isRefreshing = flags.isRefreshing,
isManageMode = flags.isManageMode,
isConfigMode = flags.isConfigMode,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
val setsFlow = combine(
localModulesFlow,
allModulesCache,
customSetsFlow,
_configVersion
) { _, allModules, customSets, _ ->
val hiddenSourceUrls = GSON.fromJsonArray<String>(HomepageConfig.homepageSourceHidden)
.getOrDefault(emptyList()).toSet()
val moduleCountsBySet =
allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size }
val list = mutableListOf<HomepageSourceManageUi>()
customSets.sortedBy { it.sortOrder }.forEach { set ->
list.add(
HomepageSourceManageUi(
sourceUrl = customSetUrl(set.id),
sourceName = set.name,
sourceGroup = null,
isSelected = customSetUrl(set.id) !in hiddenSourceUrls,
moduleCount = moduleCountsBySet[set.id] ?: 0,
isCustomSet = true,
)
)
}
list.toImmutableList()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf())
/** 用于「浏览书源模块」:列出有 homepageModules 的书源 */
val browseSourcesFlow = exploreSourcesFlow.map { sources ->
sources.map { source ->
HomepageSourceManageUi(
sourceUrl = source.bookSourceUrl,
sourceName = source.bookSourceName,
sourceGroup = source.bookSourceGroup,
)
}.toImmutableList()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf())
private val _exploreKindsCache =
MutableStateFlow<Map<String, List<Pair<String, String>>>>(emptyMap())
private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap())
private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList())
init {
// sync: 只处理有 homepageModules 的书源
viewModelScope.launch {
initModulesSyncFlow.collect { sources ->
sources.forEach { source -> syncModulesFromSource(source) }
}
}
// cache: 所有启用发现的书源(包括无 homepageModules 的)
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
}
}
viewModelScope.launch {
uiState.map { it.modules }.collect { modules ->
modules.forEach { ui ->
if (ui.state is ModuleLoadState.Loading && loadJobs[ui.globalId]?.isActive != true) {
val module = gateway.getById(ui.globalId)
if (module != null) loadModule(module)
}
}
}
}
// 清理 _pendingUserModules 中已入库的条目
viewModelScope.launch {
allModulesCache.collect { modules ->
val dbIds = modules.map { it.id }.toSet()
_pendingUserModules.update { pending -> pending.filter { it.id !in dbIds } }
}
}
// 一次性迁移:将 customSetId=null 的存量模块归属到书源集,并确保所有集存在
viewModelScope.launch {
val allModules = allModulesCache.first()
val orphans = allModules.filter { it.customSetId == null }
if (orphans.isNotEmpty()) {
orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) ->
val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach
ensureSetForSource(sourceUrl, source.bookSourceName)
modules.forEach { m ->
gateway.setCustomSetId(m.id, "src_$sourceUrl")
}
}
}
// 确保所有 customSetId 对应的集都存在
allModules.mapNotNull { it.customSetId }.distinct().forEach { setId ->
val isSrcSet = setId.startsWith("src_")
if (isSrcSet && gateway.getCustomSetById(setId) == null) {
val sourceUrl = setId.removePrefix("src_")
val source = bookSourceRepository.getBookSource(sourceUrl)
if (source != null) {
ensureSetForSource(sourceUrl, source.bookSourceName)
}
}
}
}
}
private suspend fun syncModulesFromSource(source: BookSource) {
val json = source.homepageModules ?: return
ensureSetForSource(source.bookSourceUrl, source.bookSourceName)
val parsedDefs = parseModuleDefs(source, json)
val newHash = jsonHash(json)
val existingModules = gateway.flowBySource(source.bookSourceUrl).first()
val existingById = existingModules.associateBy { it.id }
val parsedIds = parsedDefs.map { it.globalId }.toSet()
val toUpsert = mutableListOf<ModuleItem>()
for (i in parsedDefs.indices) {
val def = parsedDefs[i]
val existing = existingById[def.globalId]
if (existing != null) {
// 用户编辑过的模块不被 JSON 覆盖
if (existing.isUserCreated) continue
if (existing.sourceJsonHash == newHash) continue
toUpsert.add(
existing.copy(
type = def.type,
title = def.title,
args = def.args,
url = def.url,
sourceJsonHash = newHash,
syncedAt = System.currentTimeMillis(),
)
)
} else {
toUpsert.add(
ModuleItem(
id = def.globalId,
sourceUrl = source.bookSourceUrl,
moduleKey = def.key,
type = def.type,
title = def.title,
args = def.args,
url = def.url,
isEnabled = true,
customSetId = "src_${source.bookSourceUrl}",
sortOrder = i,
sourceJsonHash = newHash,
syncedAt = System.currentTimeMillis(),
)
)
}
}
if (toUpsert.isNotEmpty()) {
gateway.upsertAll(toUpsert)
}
if (parsedIds.isNotEmpty()) {
gateway.deleteStale(source.bookSourceUrl, parsedIds.toList())
}
}
private fun loadModule(module: ModuleItem) {
loadJobs[module.id]?.cancel()
if (module.type == HomepageModuleType.ButtonGroup.key) {
loadJobs[module.id] = viewModelScope.launch {
kotlin.runCatching {
val source = bookSourceRepository.getBookSource(module.sourceUrl)
?: throw Exception("Source not found")
val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() }
val selectedTitles = module.args?.let { argsStr ->
GSON.fromJsonArray<String>(argsStr).getOrNull()
}
if (selectedTitles.isNullOrEmpty()) {
allKinds.take(HOMEPAGE_MAX_BUTTON_GROUP_KINDS)
} else {
selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } }
}
}.onSuccess { kinds ->
_moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) }
}.onFailure { e ->
_moduleContentStates.update {
it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error"
))
}
}
}.also {
it.invokeOnCompletion { loadJobs.remove(module.id) }
}
return
}
loadJobs[module.id] = viewModelScope.launch {
kotlin.runCatching {
val isRanking = module.type == HomepageModuleType.Ranking.key
|| module.type == HomepageModuleType.GridRanking.key
val books = if (isRanking) {
exploreBooksUseCase.executeForRanking(module.sourceUrl, module.url, module.args)
} else {
exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books
}
val layout = try {
GSON.fromJson(module.layoutConfig, Map::class.java)
} catch (_: Exception) {
null
}
val rows = (layout?.get("rows") as? Number)?.toInt() ?: HOMEPAGE_DEFAULT_GRID_ROWS
val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty()
books to hasMore
}.onSuccess { (books, hasMore) ->
_moduleContentStates.update {
it + (module.id to ModuleLoadState.Loaded(
books.toImmutableList(),
hasMore = hasMore,
page = 1
))
}
}.onFailure { e ->
_moduleContentStates.update {
it + (module.id to ModuleLoadState.Error(
e.message ?: "Unknown error"
))
}
}
}.also {
it.invokeOnCompletion { loadJobs.remove(module.id) }
}
}
fun loadMoreModule(globalId: String) {
val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return
if (currentState.isLoadingMore || !currentState.hasMore) return
val nextPage = currentState.page + 1
_moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) }
viewModelScope.launch {
kotlin.runCatching {
val module = gateway.getById(globalId) ?: throw Exception("Module not found")
exploreBooksUseCase.execute(
module.sourceUrl,
module.url,
module.args,
page = nextPage
)
}.onSuccess { result ->
val newBooks = result.books
_moduleContentStates.update { states ->
val lastState =
states[globalId] as? ModuleLoadState.Loaded ?: return@update states
val existingUrls = lastState.books.map { it.bookUrl }.toSet()
val deduped = newBooks.filter { it.bookUrl !in existingUrls }
val combinedBooks = (lastState.books + deduped).toImmutableList()
states + (globalId to ModuleLoadState.Loaded(
books = combinedBooks,
hasMore = deduped.isNotEmpty(),
isLoadingMore = false,
page = nextPage
))
}
}.onFailure { e ->
_moduleContentStates.update { states ->
val lastState =
states[globalId] as? ModuleLoadState.Loaded ?: return@update states
states + (globalId to lastState.copy(isLoadingMore = false))
}
_effects.tryEmit(HomepageEffect.ShowSnackbar("加载更多失败: ${e.message}"))
}
}
}
fun refreshButtonGroup(globalId: String) {
viewModelScope.launch {
val module = gateway.getById(globalId) ?: return@launch
loadModule(module)
}
}
fun onKindUrlClick(sourceUrl: String, url: String, title: String) =
_effects.tryEmit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, url))
fun onRefresh() {
viewModelScope.launch {
_isRefreshing.value = true
loadJobs.values.forEach { it.cancel() }
loadJobs.clear()
_moduleContentStates.value = emptyMap()
uiState.map { it.modules }.first { modules ->
modules.all { it.state !is ModuleLoadState.Loading }
}
_isRefreshing.value = false
}
}
fun retryModule(globalId: String) {
_moduleContentStates.update { it + (globalId to ModuleLoadState.Loading) }
}
fun toggleManageMode() = _isManageMode.update { !it }
fun toggleConfigMode() = _isConfigMode.update { !it }
fun setModuleVisible(id: String, visible: Boolean) {
_pendingEnabled.update { it + (id to visible) }
viewModelScope.launch {
val existing = gateway.getById(id)
if (existing != null) {
gateway.setEnabled(id, visible)
} else {
// 如果模块尚未入库(虚拟状态),则根据 ID 规则解析并入库
val parts = id.split("::")
if (parts.size >= 3) {
val setId = parts[0]
val sourceUrl = parts[1]
val key = parts.subList(2, parts.size).joinToString("::")
ensureModuleInDb(sourceUrl, key, id, setId)
gateway.setEnabled(id, visible)
}
}
_pendingEnabled.update { it - id }
notifyConfigChanged()
}
}
fun toggleSourceFilter(sourceUrl: String, isEnabled: Boolean) {
val hidden = GSON.fromJsonArray<String>(HomepageConfig.homepageSourceHidden)
.getOrDefault(emptyList()).toMutableSet()
if (isEnabled) hidden.remove(sourceUrl) else hidden.add(sourceUrl)
HomepageConfig.homepageSourceHidden = GSON.toJson(hidden.toList())
notifyConfigChanged()
}
fun setLayoutMode(mode: Int) {
HomepageConfig.homepageLayoutMode = mode
notifyConfigChanged()
}
private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
val setId = "src_$sourceUrl"
if (gateway.getCustomSetById(setId) == null) {
gateway.upsertCustomSet(CustomSetItem(id = setId, name = sourceName))
}
return setId
}
fun addCustomModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) {
val key = def.key.ifBlank { def.title }
val setId = targetSetId ?: "src_$sourceUrl"
if (isInfinite(def.type, def.layoutConfig)) {
val hasInfinite = allModulesCache.value.any {
it.customSetId == setId && isInfinite(
it.type,
it.layoutConfig
)
}
if (hasInfinite) {
viewModelScope.launch {
_effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块"))
}
return
}
}
val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
val module = ModuleItem(
id = id,
sourceUrl = sourceUrl,
moduleKey = key,
type = def.type,
title = def.title,
args = def.args,
layoutConfig = def.layoutConfig,
url = def.url,
isEnabled = true,
isUserCreated = true,
customSetId = setId,
syncedAt = System.currentTimeMillis(),
)
viewModelScope.launch {
val source = bookSourceRepository.getBookSource(sourceUrl)
if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
gateway.upsertAll(listOf(module))
_pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
notifyConfigChanged()
}
}
fun getSourceExploreKinds(sourceUrl: String): List<Pair<String, String>> {
return _exploreKindsCache.value[sourceUrl].orEmpty()
}
fun updateModule(globalId: String, def: ModuleDef) {
viewModelScope.launch {
val existing = gateway.getById(globalId) ?: return@launch
gateway.upsertAll(
listOf(
existing.copy(
customTitle = def.title.takeIf { it != existing.title },
type = def.type,
url = def.url,
args = def.args,
layoutConfig = def.layoutConfig,
isUserCreated = true, // 标记为用户编辑,阻止 JSON 同步覆盖
syncedAt = System.currentTimeMillis(),
)
)
)
notifyConfigChanged()
}
}
fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) {
viewModelScope.launch {
gateway.setCustomSetTitle(globalId, customSetTitle)
notifyConfigChanged()
}
}
fun deleteModule(globalId: String) {
viewModelScope.launch {
gateway.delete(globalId)
_moduleContentStates.update { it - globalId }
loadJobs.remove(globalId)?.cancel()
_pendingEnabled.update { it - globalId }
_pendingUserModules.update { it.filter { m -> m.id != globalId } }
notifyConfigChanged()
}
}
fun reorderJoinedModules(orderedIds: List<String>) {
viewModelScope.launch {
orderedIds.forEachIndexed { index, id ->
gateway.setSortOrder(id, index)
}
notifyConfigChanged()
}
}
fun reorderCustomSets(orderedUrls: List<String>) {
viewModelScope.launch {
orderedUrls.forEachIndexed { index, url ->
val id = customSetIdFromUrl(url)
gateway.setCustomSetSortOrder(id, index)
}
notifyConfigChanged()
}
}
/** 获取指定集内的模块(sourceUrl 可以是书源 URL 或 custom://xxx */
fun getJoinedModules(sourceUrl: String): List<HomepageModuleManageUi> {
val isSet = isCustomSetUrl(sourceUrl)
val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null
val dbModules = if (isSet) {
allModulesCache.value.filter { it.customSetId == setId }
} else {
allModulesCache.value.filter { it.sourceUrl == sourceUrl }
}
val dbIds = dbModules.map { it.id }.toSet()
val pendingModules = _pendingUserModules.value.filter { pending ->
val matches =
if (isSet) pending.customSetId == setId else pending.sourceUrl == sourceUrl
matches && pending.id !in dbIds
}
return (dbModules + pendingModules).map { uiFromModule(it) }
}
/** 所有已添加的模块,按书源分组(用于自定义集添加模块) */
fun getAllModulesGroupedBySource(): Map<String, List<HomepageModuleManageUi>> {
return allModulesCache.value
.distinctBy { it.sourceUrl to it.moduleKey }
.map { uiFromModule(it) }
.groupBy { it.sourceUrl }
}
fun getSourceName(sourceUrl: String): String {
return _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl
}
fun assignModuleToCustomSet(moduleId: String, customSetId: String?) {
viewModelScope.launch {
val existing = gateway.getById(moduleId) ?: return@launch
if (customSetId == null) {
// 如果是取消分配,且它不是归属于书源默认集的,则直接删除该副本
if (existing.customSetId != "src_${existing.sourceUrl}") {
gateway.delete(moduleId)
}
} else {
// 核心逻辑:分配 = 复制。生成带新 setId 的 ID
val newId =
ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId)
val newModule = existing.copy(
id = newId,
customSetId = customSetId,
isEnabled = true, // 分配到新集时默认开启
isUserCreated = true // 标记为用户创建,避免被同步清理
)
gateway.upsertAll(listOf(newModule))
}
notifyConfigChanged()
}
}
/** 「书源模块」tab:仅 JSON,纯参考 */
fun getSourceModules(
sourceUrl: String,
targetSetId: String? = null
): List<HomepageModuleManageUi> {
val source = resolveBookSource(sourceUrl) ?: return emptyList()
val json = source.homepageModules ?: return emptyList()
val jsonDefs = parseBookSourceModules(source, json)
val effectiveSetId = targetSetId ?: "src_$sourceUrl"
val joinedKeys = allModulesCache.value
.filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId }
.map { it.moduleKey }.toSet()
return jsonDefs.map { def ->
val id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId)
HomepageModuleManageUi(
id = id,
sourceUrl = def.sourceUrl,
moduleKey = def.key,
title = def.title,
isVisible = joinedKeys.contains(def.key),
customSetId = if (joinedKeys.contains(def.key)) effectiveSetId else null,
originalTitle = def.title,
type = def.type,
url = def.url,
args = def.args,
)
}
}
/** 从书源模块「加入」→ 写入 DB,自动归属到该书源的集 */
/** 从发现页 Kind 创建一个 ButtonGroup 模块,args 存储选中 Kind 标题的 JSON 数组 */
fun addButtonGroupFromKinds(
sourceUrl: String,
targetSetId: String?,
title: String,
kindTitles: List<String>
) {
val key = kindTitles.firstOrNull() ?: title
val setId = targetSetId ?: "src_$sourceUrl"
val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
val module = ModuleItem(
id = id,
sourceUrl = sourceUrl,
moduleKey = key,
type = "buttonGroup",
title = title,
args = GSON.toJson(kindTitles),
isEnabled = true,
isUserCreated = true,
customSetId = setId,
syncedAt = System.currentTimeMillis(),
)
viewModelScope.launch {
val source = bookSourceRepository.getBookSource(sourceUrl)
if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
gateway.upsertAll(listOf(module))
_pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
notifyConfigChanged()
}
}
fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) {
val setId = targetSetId ?: "src_$sourceUrl"
if (isInfinite(def.type, def.layoutConfig)) {
val hasInfinite = allModulesCache.value.any {
it.customSetId == setId && isInfinite(
it.type,
it.layoutConfig
)
}
if (hasInfinite) {
viewModelScope.launch {
_effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块"))
}
return
}
}
val id = ModuleDef.globalIdOf(sourceUrl, def.key, setId)
val module = ModuleItem(
id = id,
sourceUrl = sourceUrl,
moduleKey = def.key,
type = def.type,
title = def.title,
args = def.args,
layoutConfig = def.layoutConfig,
url = def.url,
isEnabled = true,
customSetId = setId,
syncedAt = System.currentTimeMillis(),
)
viewModelScope.launch {
val source = bookSourceRepository.getBookSource(sourceUrl)
if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
gateway.upsertAll(listOf(module))
_pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
notifyConfigChanged()
}
}
private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi(
id = module.id,
sourceUrl = module.sourceUrl,
moduleKey = module.moduleKey,
title = module.displayTitle,
customSetTitle = module.customSetTitle,
customSetId = module.customSetId,
isVisible = _pendingEnabled.value[module.id] ?: module.isEnabled,
type = module.type,
url = module.url,
args = module.args,
layoutConfig = module.layoutConfig,
originalTitle = module.title,
)
fun createCustomSet(name: String) {
viewModelScope.launch {
gateway.createCustomSet(name)
notifyConfigChanged()
}
}
fun renameCustomSet(id: String, name: String) {
viewModelScope.launch {
gateway.renameCustomSet(id, name)
notifyConfigChanged()
}
}
fun deleteCustomSet(id: String) {
viewModelScope.launch {
val moduleIds = allModulesCache.value.filter { it.customSetId == id }.map { it.id }
gateway.deleteCustomSet(id)
moduleIds.forEach { mid ->
_moduleContentStates.update { it - mid }
loadJobs.remove(mid)?.cancel()
_pendingEnabled.update { it - mid }
}
notifyConfigChanged()
}
}
fun onBookClick(book: SearchBook) {
viewModelScope.launch {
saveSearchBooksUseCase.save(book)
_effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl))
}
}
fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) {
viewModelScope.launch {
_effects.emit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, exploreUrl))
}
}
private fun resolveBookSource(sourceUrl: String): BookSource? {
return _bookSourcesCache.value[sourceUrl]
?: bookSourceRepository.getBookSourceSync(sourceUrl)
}
private suspend fun ensureModuleInDb(
sourceUrl: String,
moduleKey: String,
id: String,
setId: String
) {
if (gateway.getById(id) != null) return
val source = resolveBookSource(sourceUrl) ?: return
val json = source.homepageModules ?: return
val defs = parseBookSourceModules(source, json)
val def = defs.find { it.key == moduleKey } ?: return
gateway.upsertAll(
listOf(
ModuleItem(
id = id,
sourceUrl = sourceUrl,
moduleKey = moduleKey,
type = def.type,
title = def.title,
args = def.args,
url = def.url,
isEnabled = true,
customSetId = setId,
)
)
)
}
private fun notifyConfigChanged() {
_configVersion.update { it + 1 }
}
private fun parseBookSourceModules(source: BookSource, json: String): List<ModuleDef> =
parseModuleDefs(source, json)
}
private data class HomepageUiFlags(
val isRefreshing: Boolean,
val isManageMode: Boolean,
val isConfigMode: Boolean
)
@@ -0,0 +1,57 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
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.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.fadingEdge
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun BannerModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
if (books.isEmpty()) return
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier
.fillMaxWidth()
.fadingEdge(lazyListState, gradientWidth = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(books) { book ->
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
radius = 12.dp,
sourceOrigin = book.origin,
modifier = Modifier
.width(96.dp)
.clickable { onClick(book) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
}
}
@@ -0,0 +1,171 @@
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.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
import androidx.compose.ui.text.style.TextAlign
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.widget.components.card.GlassCard
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
fun ButtonGroupModule(
kinds: List<ExploreKind>,
sourceUrl: String,
globalId: String,
viewModel: HomepageViewModel,
modifier: Modifier = Modifier,
icon: String? = null,
layoutConfig: String? = null,
) {
if (kinds.isEmpty()) return
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 (iconMap, defaultIcon) = remember(layoutConfig) {
layoutConfig?.let {
try {
val json = GSON.fromJson(it, Map::class.java)
@Suppress("UNCHECKED_CAST")
val icons = json["icons"] as? Map<String, String>
val singleIcon = json["icon"] as? String
(icons ?: emptyMap()) to (singleIcon ?: icon)
} catch (_: Exception) {
emptyMap<String, String>() to icon
}
} ?: (emptyMap<String, String>() to icon)
}
// --- 动态布局计算逻辑 ---
val maxColumns = 5
val total = kinds.size
val numRows = (total + maxColumns - 1) / maxColumns
val actualColumns = (total + numRows - 1) / numRows
// -----------------------
Column(
modifier = modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
kinds.chunked(actualColumns).forEach { rowKinds ->
Row(
modifier = Modifier.fillMaxWidth(),
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)
}
}
ExploreKind.Type.button -> {
scope.launch {
useCase.executeAction(
action = kind.action,
title = kind.title,
sourceUrl = sourceUrl,
infoMap = infoMap,
activity = activity,
onRefreshKinds = {
viewModel.refreshButtonGroup(globalId)
}
)
}
}
}
},
cornerRadius = 8.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.weight(1f)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 12.dp, horizontal = 4.dp)
) {
if (hasIcon) {
SourceIcon(
path = buttonIcon,
modifier = Modifier.size(20.dp),
placeholderIcon = {
}
)
Spacer(modifier = Modifier.height(4.dp))
}
AppText(
text = displayName,
style = LegadoTheme.typography.labelMedium,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.padding(horizontal = 4.dp)
.basicMarquee()
)
}
}
}
if (rowKinds.size < actualColumns) {
repeat(actualColumns - rowKinds.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@@ -0,0 +1,101 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.theme.fadingEdge
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.collections.immutable.ImmutableList
/**
* 卡片模块:横向滚动的推荐卡片
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CardModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
if (books.isEmpty()) return
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier
.fillMaxWidth()
.fadingEdge(lazyListState, gradientWidth = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(books, key = { it.bookUrl }) { book ->
Column(
modifier = Modifier
.width(120.dp)
.clip(RoundedCornerShape(16.dp))
.background(LegadoTheme.colorScheme.surfaceContainerLow)
.clickable { onClick(book) }
) {
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
radius = 16.dp,
sourceOrigin = book.origin,
modifier = Modifier
.wrapContentWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
AppText(
text = book.name,
style = LegadoTheme.typography.labelLargeEmphasized,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
start = 8.dp,
end = 8.dp,
top = 8.dp,
bottom = 2.dp
),
)
val intro = book.intro?.takeIf { it.isNotBlank() }
?.replace("\\s+".toRegex(), " ")
if (intro != null) {
AppText(
text = intro,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 12.dp),
)
}
}
}
}
}
@@ -0,0 +1,60 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun GridModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
modifier: Modifier = Modifier,
columns: Int = 3,
maxRows: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
if (books.isEmpty()) return
var rows = books.toList().chunked(columns)
if (maxRows != null) {
rows = rows.take(maxRows)
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (row in rows) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
for (book in row) {
SearchBookGridItem(
book = book,
shelfState = BookShelfState.NOT_IN_SHELF,
onClick = { onClick(book) },
modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@@ -0,0 +1,161 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontStyle
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 io.legado.app.data.entities.SearchBook
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.fadingEdge
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun GridRankingModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
modifier: Modifier = Modifier,
rows: Int = 4,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
if (books.isEmpty()) return
// 限制最多显示 20 项
val limitedBooks = books.take(20)
val pages = limitedBooks.chunked(rows)
val pagerState = rememberPagerState(pageCount = { pages.size })
HorizontalPager(
state = pagerState,
// 由于父容器已经有 16.dp padding,这里 start 设为 0
contentPadding = PaddingValues(start = 0.dp, end = 100.dp),
pageSpacing = 12.dp,
modifier = modifier
.fillMaxWidth()
.fadingEdge(pagerState, gradientWidth = 16.dp),
) { pageIndex ->
val page = pages[pageIndex]
GlassCard(
modifier = Modifier.fillMaxWidth(),
// 使用 MD3 标准容器色,增加微妙的深度感
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
cornerRadius = 20.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 12.dp)
) {
for (book in page) {
GridRankingItem(
rank = pages.flatten().indexOf(book) + 1,
book = book,
onClick = { onClick(book) },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
// 占位逻辑
repeat(rows - page.size) {
Spacer(modifier = Modifier.height(76.dp))
}
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun GridRankingItem(
rank: Int,
book: SearchBook,
onClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// 1. 封面
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
sourceOrigin = book.origin,
modifier = Modifier.width(48.dp),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
// 2. 排名
AppText(
text = "$rank",
style = LegadoTheme.typography.titleMedium,
fontWeight = FontWeight.Black,
fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal,
color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
modifier = Modifier.width(32.dp),
textAlign = TextAlign.Center
)
// 3. 文字信息
Column(
modifier = Modifier
.padding(start = 4.dp)
.weight(1f)
) {
AppText(
text = book.name,
style = LegadoTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subTitle = buildString {
append(book.kind?.split(",")?.firstOrNull() ?: "")
if (book.author.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(book.author)
}
}
AppText(
text = subTitle,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
@@ -0,0 +1,146 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.book.SearchBookListItem
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.text.AppText
import kotlinx.collections.immutable.ImmutableList
private const val INITIAL_COUNT = 5
private const val MAX_COUNT = 20
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun RankingModule(
books: ImmutableList<SearchBook>,
onClick: (SearchBook) -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) }
val displayBooks = books.take(visibleCount)
GlassCard(
modifier = modifier
.fillMaxWidth(),
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
cornerRadius = 16.dp
) {
Column(
modifier = Modifier
.padding(top = 12.dp)
.animateContentSize()
) {
// 显示书籍列表
displayBooks.forEachIndexed { index, book ->
RankingItem(
rank = index + 1,
book = book,
onClick = onClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
if (books.size > INITIAL_COUNT) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
visibleCount =
if (visibleCount == INITIAL_COUNT) MAX_COUNT else INITIAL_COUNT
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val isExpanded = visibleCount > INITIAL_COUNT
AppIcon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.ExpandMore,
contentDescription = null,
tint = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
AppText(
text = if (isExpanded) "收起" else "显示全部",
style = LegadoTheme.typography.labelMediumEmphasized,
color = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun RankingItem(
rank: Int,
book: SearchBook,
onClick: (SearchBook) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(book) }
.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = "$rank",
style = LegadoTheme.typography.titleLarge,
fontWeight = FontWeight.Black,
fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal,
color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
textAlign = TextAlign.Center,
modifier = Modifier
.width(42.dp)
.padding(start = 2.dp, end = 10.dp),
)
SearchBookListItem(
book = book,
shelfState = BookShelfState.NOT_IN_SHELF,
onClick = null,
showPadding = false,
modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
}
}
@@ -0,0 +1,126 @@
package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
/**
* 瀑布流单项组件
* 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun WaterfallItem(
book: SearchBook,
onClick: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) {
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
CoilBookCover(
name = book.name,
author = book.author,
path = book.coverUrl,
radius = 16.dp,
sourceOrigin = book.origin,
modifier = Modifier
.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
)
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp)
) {
AppText(
text = book.name,
style = LegadoTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val subTitle = buildString {
if (book.author.isNotBlank()) append(book.author)
val kind = book.kind?.split(",")?.firstOrNull()
if (!kind.isNullOrBlank()) {
if (isNotEmpty()) append(" · ")
append(kind)
}
}
if (subTitle.isNotBlank()) {
AppText(
text = subTitle,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
val intro = book.intro?.replace("\\s+".toRegex(), " ")
if (!intro.isNullOrBlank()) {
AppText(
text = intro,
style = LegadoTheme.typography.labelSmallEmphasized,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
}
val kinds = book.getKindList()
if (kinds.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
kinds.forEach { kind ->
SearchBookTagChip(
text = kind,
color = LegadoTheme.colorScheme.surfaceContainerHigh
)
}
}
}
}
}
}
}
@@ -64,6 +64,24 @@ fun adaptiveContentPadding(
)
}
@Composable
fun adaptiveContentPadding(
top: Dp,
bottom: Dp,
miuixHorizontal: Dp,
m3Horizontal: Dp
): PaddingValues {
val horizontal =
if (ThemeResolver.isMiuixEngine(composeEngine)) miuixHorizontal else m3Horizontal
val adjustedTop = if (ThemeResolver.isMiuixEngine(composeEngine)) top + 12.dp else top + 16.dp
return PaddingValues(
top = adjustedTop,
bottom = bottom,
start = horizontal,
end = horizontal
)
}
@Composable
fun adaptiveContentPadding(
top: Dp,
@@ -2,11 +2,11 @@ package io.legado.app.ui.theme
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
@@ -52,29 +52,62 @@ fun Modifier.fadingEdge(
/**
* Convenience overload that derives fade alphas from a [LazyListState].
* Left fade appears when scrolled past the start; right fade appears when more content is available.
*/
@Composable
fun Modifier.fadingEdge(
listState: LazyListState,
gradientWidth: Dp = 24.dp
): Modifier {
val showLeft by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val showRight by remember {
derivedStateOf { listState.canScrollForward }
}
val leftAlpha by animateFloatAsState(
targetValue = if (showLeft) 1f else 0f,
animationSpec = tween(200),
targetValue = if (listState.canScrollBackward) 1f else 0f,
animationSpec = tween(300),
label = "LeftFadeAlpha"
)
val rightAlpha by animateFloatAsState(
targetValue = if (showRight) 1f else 0f,
animationSpec = tween(200),
targetValue = if (listState.canScrollForward) 1f else 0f,
animationSpec = tween(300),
label = "RightFadeAlpha"
)
return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
}
/**
* Convenience overload that derives fade alphas from a [PagerState].
*/
@Composable
fun Modifier.fadingEdge(
pagerState: PagerState,
gradientWidth: Dp = 24.dp
): Modifier {
val leftAlpha by animateFloatAsState(
targetValue = if (pagerState.canScrollBackward) 1f else 0f,
animationSpec = tween(300),
label = "LeftFadeAlpha"
)
val rightAlpha by animateFloatAsState(
targetValue = if (pagerState.canScrollForward) 1f else 0f,
animationSpec = tween(300),
label = "RightFadeAlpha"
)
return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
}
/**
* Convenience overload that derives fade alphas from a [ScrollState].
*/
@Composable
fun Modifier.fadingEdge(
scrollState: ScrollState,
gradientWidth: Dp = 24.dp
): Modifier {
val leftAlpha by animateFloatAsState(
targetValue = if (scrollState.canScrollBackward) 1f else 0f,
animationSpec = tween(300),
label = "LeftFadeAlpha"
)
val rightAlpha by animateFloatAsState(
targetValue = if (scrollState.canScrollForward) 1f else 0f,
animationSpec = tween(300),
label = "RightFadeAlpha"
)
return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
@@ -74,7 +74,8 @@ data class LegadoColorScheme(
val cardContainer: Color,
val onCardContainer: Color,
val onSheetContent: Color
val onSheetContent: Color,
val cardPrimaryContainer: Color
)
data class LegadoTypography(
@@ -74,7 +74,8 @@ fun ColorScheme.toLegadoColorScheme(
onTertiaryFixedVariant = onTertiaryFixedVariant,
cardContainer = primaryContainer.copy(alpha = 0.5f),
onCardContainer = primary,
onSheetContent = surface
onSheetContent = surface,
cardPrimaryContainer = primaryContainer
)
}
@@ -12,12 +12,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import io.legado.app.ui.config.themeConfig.ThemeConfig
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
import top.yukonga.miuix.kmp.theme.TextStyles
@Composable
fun rememberCustomFont(fontPath: String?): FontFamily? {
@@ -180,7 +180,9 @@ fun MiuixThemeWrapper(
cardContainer = miuixColorScheme.surfaceContainer,
onCardContainer = miuixColorScheme.onSurface,
onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f)
onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f),
cardPrimaryContainer = miuixColorScheme.primary.copy(alpha = 0.1f)
.compositeOver(miuixColorScheme.surface)
)
}
@@ -0,0 +1,138 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
import io.legado.app.utils.GSON
sealed class JsonKeyEditorConfig {
data class Slider(val range: ClosedFloatingPointRange<Float>, val steps: Int = 0) :
JsonKeyEditorConfig()
data class Dropdown(val displayEntries: Array<String>, val entryValues: Array<String>) :
JsonKeyEditorConfig() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Dropdown) return false
return displayEntries.contentEquals(other.displayEntries) && entryValues.contentEquals(
other.entryValues
)
}
override fun hashCode(): Int {
return 31 * displayEntries.contentHashCode() + entryValues.contentHashCode()
}
}
object Switch : JsonKeyEditorConfig()
}
@Composable
fun JsonConfigEditor(
jsonString: String,
onJsonStringChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyConfigs: Map<String, JsonKeyEditorConfig> = emptyMap()
) {
val jsonObject = remember(jsonString) {
runCatching {
JsonParser.parseString(jsonString).asJsonObject
}.getOrElse { JsonObject() }
}
Column(modifier = modifier) {
jsonObject.entrySet().forEach { entry ->
val key = entry.key
val value = entry.value
val config = keyConfigs[key]
val displayTitle = when (key) {
"columns" -> "列数 (Columns)"
"rows" -> "行数 (Rows)"
else -> key
}
when {
(config is JsonKeyEditorConfig.Slider || key.contains("columns") || key.contains("rows")) &&
value.isJsonPrimitive && value.asJsonPrimitive.isNumber -> {
val range = (config as? JsonKeyEditorConfig.Slider)?.range ?: 0f..10f
val steps = (config as? JsonKeyEditorConfig.Slider)?.steps ?: 10
CompactSliderSettingItem(
title = displayTitle,
value = value.asFloat,
valueRange = range,
steps = steps,
onValueChange = {
val newObj = jsonObject.deepCopy()
if (it == it.toInt().toFloat()) {
newObj.addProperty(key, it.toInt())
} else {
newObj.addProperty(key, it)
}
onJsonStringChange(GSON.toJson(newObj))
}
)
}
config is JsonKeyEditorConfig.Dropdown -> {
CompactDropdownSettingItem(
title = displayTitle,
selectedValue = value.asString,
displayEntries = config.displayEntries,
entryValues = config.entryValues,
onValueChange = {
val newObj = jsonObject.deepCopy()
newObj.addProperty(key, it)
onJsonStringChange(GSON.toJson(newObj))
}
)
}
(config is JsonKeyEditorConfig.Switch || (value.isJsonPrimitive && value.asJsonPrimitive.isBoolean)) -> {
CompactSwitchSettingItem(
title = displayTitle,
checked = value.asBoolean,
onCheckedChange = {
val newObj = jsonObject.deepCopy()
newObj.addProperty(key, it)
onJsonStringChange(GSON.toJson(newObj))
}
)
}
else -> {
JsonRawEditor(
value = if (value.isJsonPrimitive) value.asString else GSON.toJson(value),
onValueChange = {
val newObj = jsonObject.deepCopy()
if (it.toLongOrNull() != null) {
newObj.addProperty(key, it.toLong())
} else if (it.toDoubleOrNull() != null) {
newObj.addProperty(key, it.toDouble())
} else if (it == "true" || it == "false") {
newObj.addProperty(key, it.toBoolean())
} else {
try {
val element = JsonParser.parseString(it)
newObj.add(key, element)
} catch (_: Exception) {
newObj.addProperty(key, it)
}
}
onJsonStringChange(GSON.toJson(newObj))
},
label = displayTitle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
@@ -0,0 +1,88 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.GSON
@Composable
fun JsonRawEditor(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Row {
SmallIconButton(
onClick = {
runCatching {
val jsonElement = JsonParser.parseString(value)
onValueChange(GSON.toJson(jsonElement))
}
},
imageVector = Icons.Default.AutoFixHigh,
contentDescription = "格式化"
)
SmallIconButton(
onClick = {
runCatching {
val jsonElement = JsonParser.parseString(value)
val compactGson = GsonBuilder().create()
onValueChange(compactGson.toJson(jsonElement))
}
},
imageVector = Icons.Default.Compress,
contentDescription = "压缩"
)
}
}
TextField(
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,
),
maxLines = 1000
)
}
}
@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@@ -16,15 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -32,6 +33,9 @@ import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.fadingEdge
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
@@ -41,8 +45,9 @@ import io.legado.app.ui.widget.components.text.AppText
fun SearchBookListItem(
book: SearchBook,
shelfState: BookShelfState,
onClick: () -> Unit,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
showPadding: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null,
@@ -50,8 +55,8 @@ fun SearchBookListItem(
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.then(if (showPadding) Modifier.adaptiveHorizontalPadding(vertical = 8.dp) else Modifier)
) {
Box(modifier = Modifier
.width(72.dp)
@@ -113,13 +118,14 @@ fun SearchBookListItem(
AppText(
text = "",
style = LegadoTheme.typography.bodySmall,
color = Color.Gray,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
AppText(
text = "最新: $latestChapter",
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -133,7 +139,7 @@ fun SearchBookListItem(
AppText(
text = intro,
style = LegadoTheme.typography.labelSmall,
color = Color.Gray,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -143,8 +149,14 @@ fun SearchBookListItem(
val kinds = book.getKindList()
if (kinds.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
kinds.forEach { kind ->
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = Modifier
.fillMaxWidth()
.fadingEdge(lazyListState, gradientWidth = 8.dp)
) {
items(kinds) { kind ->
SearchBookTagChip(text = kind)
Spacer(modifier = Modifier.width(6.dp))
}
@@ -168,8 +180,8 @@ fun SearchBookGridItem(
Column(
modifier = modifier
.width(IntrinsicSize.Min)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(4.dp)
) {
Box(
modifier = Modifier
@@ -207,28 +219,35 @@ fun SearchBookGridItem(
}
}
Spacer(modifier = Modifier.height(4.dp))
AppText(
text = book.name,
style = LegadoTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
AppText(
text = book.name,
style = LegadoTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SearchBookTagChip(text: String) {
Surface(
color = LegadoTheme.colorScheme.cardContainer,
shape = RoundedCornerShape(4.dp),
fun SearchBookTagChip(
text: String,
color: Color = LegadoTheme.colorScheme.surfaceContainerHigh
) {
GlassCard(
containerColor = color,
cornerRadius = 4.dp
) {
AppText(
text = text,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = LegadoTheme.typography.labelSmall,
style = LegadoTheme.typography.labelSmallEmphasized,
color = LegadoTheme.colorScheme.onCardContainer,
)
}
@@ -4,12 +4,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.MyLocation
@@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.runtime.Composable
@@ -37,6 +40,7 @@ import top.yukonga.miuix.kmp.icon.extended.Edit
import top.yukonga.miuix.kmp.icon.extended.Favorites
import top.yukonga.miuix.kmp.icon.extended.Filter
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.icon.extended.Notes
import top.yukonga.miuix.kmp.icon.extended.Pin
import top.yukonga.miuix.kmp.icon.extended.Refresh
import top.yukonga.miuix.kmp.icon.extended.Settings
@@ -81,6 +85,10 @@ object AppIcons {
@Composable
get() = if (isMiuix) MiuixIcons.Settings else Icons.Default.Settings
val BugReport: ImageVector
@Composable
get() = Icons.Default.BugReport
val PrecisionSearch: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Pin else Icons.Default.MyLocation
@@ -100,8 +108,14 @@ object AppIcons {
@Composable
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
return when (destination) {
MainDestination.Bookshelf -> if (isMiuix) {
MainDestination.Home -> if (isMiuix) {
if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook
} else {
if (selected) Icons.Default.Home else Icons.Outlined.Home
}
MainDestination.Bookshelf -> if (isMiuix) {
if (selected) MiuixIcons.Regular.Notes else MiuixIcons.Regular.Notes
} else {
if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withSave
import coil.compose.AsyncImage
@@ -51,6 +53,7 @@ fun CoilBookCover(
name: String?,
author: String?,
path: String?,
radius: Dp = 4.dp,
modifier: Modifier = Modifier.width(64.dp),
sourceOrigin: String? = null,
onLoadFinish: (() -> Unit)? = null,
@@ -75,7 +78,7 @@ fun CoilBookCover(
val hasCustomDefault = !randomPath.isNullOrBlank()
var isOnlineCoverLoaded by remember(path) {
mutableStateOf(sharedCoverKey != null && finalPath != null)
mutableStateOf(false)
}
Box(
@@ -94,61 +97,65 @@ fun CoilBookCover(
)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
Modifier.shadow(4.dp, RoundedCornerShape(radius))
} else Modifier
)
.background(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
LegadoTheme.colorScheme.surfaceContainerLow
} else Color.Transparent,
RoundedCornerShape(4.dp)
RoundedCornerShape(radius)
)
.clip(RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(radius))
) {
if (hasCustomDefault && !isOnlineCoverLoaded) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = showLoadingPlaceholder,
memoryCacheKey = randomPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
)
key(randomPath) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = showLoadingPlaceholder,
memoryCacheKey = randomPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(radius))
)
}
}
if (finalPath != null) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = finalPath,
sourceOrigin = sourceOrigin,
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
crossfade = showLoadingPlaceholder,
memoryCacheKey = finalPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp)),
onSuccess = {
isOnlineCoverLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
onLoadFinish?.invoke()
}
)
key(finalPath) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = finalPath,
sourceOrigin = sourceOrigin,
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
crossfade = showLoadingPlaceholder,
memoryCacheKey = finalPath,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp)),
onSuccess = {
isOnlineCoverLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
onLoadFinish?.invoke()
}
)
}
} else {
LaunchedEffect(Unit) {
onLoadFinish?.invoke()
@@ -81,7 +81,6 @@ fun AppModalBottomSheet(
backgroundColor = sheetContainerColor,
dragHandleColor = sheetDragHandleColor,
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissRequest,
enableWindowDim = true,
allowDismiss = true
) {
@@ -90,6 +89,7 @@ fun AppModalBottomSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
.animateContentSize(),
content = content
)
@@ -120,7 +120,7 @@ fun AppModalBottomSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp)
.heightIn(max = maxHeight)
.animateContentSize()
.then(modifier)
@@ -12,7 +12,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.SplicedColumnDivider
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.DropdownItem
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
@Composable
@@ -31,7 +31,7 @@ fun DropdownListSettingItem(
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val selectedIndex = entryValues.indexOf(selectedValue).coerceAtLeast(0)
val spinnerItems = displayEntries.map { display ->
SpinnerEntry(title = display)
DropdownItem(title = display)
}
OverlaySpinnerPreference(