主页模块初始版本
This commit is contained in:
@@ -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()
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user