HapeLee hai 4 meses
pai
achega
9f897ddd27
Modificáronse 39 ficheiros con 2093 adicións e 1512 borrados
  1. 2 3
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 51 0
      app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt
  3. 14 0
      app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt
  4. 2 1
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  5. 96 0
      app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt
  6. 102 65
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  7. 1 1
      app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt
  8. 15 9
      app/src/main/java/io/legado/app/di/appModule.kt
  9. 27 0
      app/src/main/java/io/legado/app/domain/gateway/HomeDashboardGateway.kt
  10. 48 0
      app/src/main/java/io/legado/app/domain/model/HomeDashboard.kt
  11. 88 0
      app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt
  12. 3 0
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  13. 6 0
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  14. 5 0
      app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt
  15. 166 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/MainNavigationSettingsSheet.kt
  16. 7 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  17. 10 16
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  18. 5 0
      app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt
  19. 10 2
      app/src/main/java/io/legado/app/ui/main/MainDestination.kt
  20. 13 21
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  21. 4 31
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  22. 1 63
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  23. 0 12
      app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt
  24. 21 3
      app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt
  25. 668 146
      app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt
  26. 182 125
      app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt
  27. 1 10
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt
  28. 10 1
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt
  29. 0 57
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt
  30. 493 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt
  31. 0 795
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  32. 0 92
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt
  33. 9 12
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  34. 4 4
      app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt
  35. 7 10
      app/src/main/res/values-zh-rCN/strings.xml
  36. 7 10
      app/src/main/res/values-zh-rHK/strings.xml
  37. 7 10
      app/src/main/res/values-zh-rTW/strings.xml
  38. 1 3
      app/src/main/res/values/arrays.xml
  39. 7 10
      app/src/main/res/values/strings.xml

+ 2 - 3
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -44,9 +44,11 @@ object PreferKey {
     const val ttsSpeechRate = "ttsSpeechRate"
     const val ttsSpeechRate = "ttsSpeechRate"
     const val prevKeys = "prevKeyCodes"
     const val prevKeys = "prevKeyCodes"
     const val nextKeys = "nextKeyCodes"
     const val nextKeys = "nextKeyCodes"
+    const val showHome = "showHome"
     const val showDiscovery = "showDiscovery"
     const val showDiscovery = "showDiscovery"
     const val enableReview = "enableReview"
     const val enableReview = "enableReview"
     const val showRss = "showRss"
     const val showRss = "showRss"
+    const val mainNavigationOrder = "mainNavigationOrder"
     const val showStatusBar = "showStatusBar"
     const val showStatusBar = "showStatusBar"
     const val swipeAnimation = "swipeAnimation"
     const val swipeAnimation = "swipeAnimation"
     const val bookshelfLayout = "bookshelfLayout"
     const val bookshelfLayout = "bookshelfLayout"
@@ -364,9 +366,6 @@ object PreferKey {
     const val llmTemperature = "llmTemperature"
     const val llmTemperature = "llmTemperature"
     const val llmPrompt = "llmPrompt"
     const val llmPrompt = "llmPrompt"
 
 
-    const val homepageModuleOrder = "homepageModuleOrder"
-    const val homepageModuleHidden = "homepageModuleHidden"
-    const val homepageLayoutMode = "homepageLayoutMode"
     const val navIconHome = "navIconHome"
     const val navIconHome = "navIconHome"
 
 
     // Lab
     // Lab

+ 51 - 0
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -6,6 +6,7 @@ import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
 import androidx.room.Query
 import androidx.room.Update
 import androidx.room.Update
+import io.legado.app.data.entities.readRecord.HomeRecentBookRow
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -25,6 +26,56 @@ interface ReadRecordDao {
     @Query("SELECT sum(readTime) FROM readRecord")
     @Query("SELECT sum(readTime) FROM readRecord")
     fun getTotalReadTime(): Flow<Long?>
     fun getTotalReadTime(): Flow<Long?>
 
 
+    @Query(
+        """
+        SELECT COUNT(*) FROM (
+            SELECT 1
+            FROM readRecord
+            GROUP BY bookName, bookAuthor
+        )
+        """
+    )
+    fun observeTotalReadBookCount(): Flow<Int>
+
+    @Query("SELECT SUM(readTime) FROM readRecordDetail WHERE date = :date")
+    fun observeReadTimeByDate(date: String): Flow<Long?>
+
+    @Query(
+        """
+        WITH recent AS (
+            SELECT
+                bookName,
+                bookAuthor,
+                MAX(lastRead) AS lastRead
+            FROM readRecord
+            GROUP BY bookName, bookAuthor
+            ORDER BY lastRead DESC
+            LIMIT :limit
+        )
+        SELECT
+            recent.bookName AS recordName,
+            recent.bookAuthor AS recordAuthor,
+            book.bookUrl AS bookUrl,
+            book.origin AS origin,
+            book.coverUrl AS coverUrl,
+            book.customCoverUrl AS customCoverUrl,
+            book.durChapterTitle AS chapterTitle,
+            book.totalChapterNum AS totalChapterNum,
+            book.durChapterIndex AS chapterIndex
+        FROM recent
+        LEFT JOIN books AS book ON book.bookUrl = (
+            SELECT candidate.bookUrl
+            FROM books AS candidate
+            WHERE candidate.name = recent.bookName
+                AND candidate.author = recent.bookAuthor
+            ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC
+            LIMIT 1
+        )
+        ORDER BY recent.lastRead DESC
+        """
+    )
+    fun observeRecentHomeBooks(limit: Int): Flow<List<HomeRecentBookRow>>
+
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     @Query("select sum(readTime) from readRecord where bookName = :bookName")
     fun getReadTime(bookName: String): Long?
     fun getReadTime(bookName: String): Long?
 
 

+ 14 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt

@@ -0,0 +1,14 @@
+package io.legado.app.data.entities.readRecord
+
+data class HomeRecentBookRow(
+    val recordName: String,
+    val recordAuthor: String,
+    val bookUrl: String?,
+    val origin: String?,
+    val coverUrl: String?,
+    val customCoverUrl: String?,
+    val chapterTitle: String?,
+    val totalChapterNum: Int?,
+    val chapterIndex: Int?,
+)
+

+ 2 - 1
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -19,13 +19,14 @@ object LocalPreferencesKeys {
     val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
     val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
     val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
     val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
     val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape")
     val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape")
-    val EXPLORE_STYLE = stringPreferencesKey("explore_style")
     val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
     val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
     val LAST_BACKUP = longPreferencesKey("last_backup")
     val LAST_BACKUP = longPreferencesKey("last_backup")
     val PASSWORD = stringPreferencesKey("password")
     val PASSWORD = stringPreferencesKey("password")
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
+    val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
+    val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
 
 
     // Change source options
     // Change source options
     val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
     val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")

+ 96 - 0
app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt

@@ -0,0 +1,96 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.domain.gateway.HomeDashboardGateway
+import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
+import io.legado.app.domain.model.HomeDashboardSection
+import io.legado.app.domain.model.HomeReadingBook
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class HomeDashboardRepository(
+    private val readRecordDao: ReadRecordDao,
+    private val localPreferencesRepository: LocalPreferencesRepository,
+) : HomeDashboardGateway {
+
+    override fun observeTotalReadBooks(): Flow<Int> =
+        readRecordDao.observeTotalReadBookCount()
+
+    override fun observeTotalReadTime(): Flow<Long> =
+        readRecordDao.getTotalReadTime().map { it ?: 0L }
+
+    override fun observeReadTime(date: String): Flow<Long> =
+        readRecordDao.observeReadTimeByDate(date).map { it ?: 0L }
+
+    override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> =
+        readRecordDao.observeRecentHomeBooks(limit).map { rows ->
+            rows.map { row ->
+                HomeReadingBook(
+                    bookUrl = row.bookUrl,
+                    name = row.recordName,
+                    author = row.recordAuthor,
+                    origin = row.origin,
+                    coverPath = if (row.customCoverUrl.isNullOrEmpty()) {
+                        row.coverUrl
+                    } else {
+                        row.customCoverUrl
+                    },
+                    chapterTitle = row.chapterTitle,
+                    chapterProgress = if (
+                        row.totalChapterNum != null &&
+                        row.totalChapterNum > 0 &&
+                        row.chapterIndex != null
+                    ) {
+                        (row.chapterIndex + 1)
+                            .coerceIn(0, row.totalChapterNum)
+                            .toFloat() / row.totalChapterNum
+                    } else {
+                        null
+                    },
+                )
+            }
+        }
+
+    override fun observeDailyGoal(defaultValue: Int): Flow<Int> =
+        localPreferencesRepository.getPreference(
+            LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
+            defaultValue,
+        )
+
+    override fun observeSelectedSourceSetUrl(): Flow<String?> =
+        localPreferencesRepository.getPreference(
+            LocalPreferencesKeys.HOME_SOURCE_SET_URL,
+            "",
+        ).map { it.takeIf(String::isNotBlank) }
+
+    override fun observeVisibleSections(): Flow<Set<HomeDashboardSection>> =
+        localPreferencesRepository.getPreference(
+            LocalPreferencesKeys.HOME_DASHBOARD_SECTIONS,
+            DEFAULT_HOME_DASHBOARD_SECTIONS.joinToString(",") { it.storageValue },
+        ).map(HomeDashboardSection::fromStorage)
+
+    override suspend fun updateDailyGoal(minutes: Int) {
+        localPreferencesRepository.updatePreference(
+            LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
+            minutes,
+        )
+    }
+
+    override suspend fun updateSelectedSourceSetUrl(sourceUrl: String) {
+        localPreferencesRepository.updatePreference(
+            LocalPreferencesKeys.HOME_SOURCE_SET_URL,
+            sourceUrl,
+        )
+    }
+
+    override suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>) {
+        localPreferencesRepository.updatePreference(
+            LocalPreferencesKeys.HOME_DASHBOARD_SECTIONS,
+            HomeDashboardSection.entries
+                .filter(sections::contains)
+                .joinToString(",") { it.storageValue },
+        )
+    }
+}

+ 102 - 65
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -1,8 +1,9 @@
 package io.legado.app.data.repository
 package io.legado.app.data.repository
 
 
-import androidx.room.Transaction
+import androidx.room.withTransaction
 import cn.hutool.core.date.DatePattern
 import cn.hutool.core.date.DatePattern
 import cn.hutool.core.date.DateUtil
 import cn.hutool.core.date.DateUtil
+import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
@@ -15,7 +16,8 @@ import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.min
 
 
 class ReadRecordRepository(
 class ReadRecordRepository(
-    private val dao: ReadRecordDao
+    private val dao: ReadRecordDao,
+    private val database: AppDatabase,
 ) {
 ) {
     private fun getCurrentDeviceId(): String = ""
     private fun getCurrentDeviceId(): String = ""
 
 
@@ -85,11 +87,15 @@ class ReadRecordRepository(
      * 保存一个完整的阅读会话.
      * 保存一个完整的阅读会话.
      */
      */
     suspend fun saveReadSession(newSession: ReadRecordSession) {
     suspend fun saveReadSession(newSession: ReadRecordSession) {
-        val segmentDuration = newSession.endTime - newSession.startTime
-        dao.insertSession(newSession)
-        val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
-        updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
-        updateReadRecord(newSession, segmentDuration)
+        if (newSession.endTime <= newSession.startTime) return
+        database.withTransaction {
+            val segmentDuration = newSession.endTime - newSession.startTime
+            dao.insertSession(newSession)
+            val dateString =
+                DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
+            updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
+            updateReadRecord(newSession, segmentDuration)
+        }
     }
     }
 
 
     private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
     private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
@@ -151,58 +157,72 @@ class ReadRecordRepository(
     }
     }
 
 
     suspend fun deleteDetail(detail: ReadRecordDetail) {
     suspend fun deleteDetail(detail: ReadRecordDetail) {
-        dao.deleteDetail(detail)
-        dao.deleteSessionsByBookAndDate(
-            detail.deviceId,
-            detail.bookName,
-            detail.bookAuthor,
-            detail.date
-        )
-        updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
+        database.withTransaction {
+            dao.deleteDetail(detail)
+            dao.deleteSessionsByBookAndDate(
+                detail.deviceId,
+                detail.bookName,
+                detail.bookAuthor,
+                detail.date
+            )
+            updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
+        }
     }
     }
 
 
-    @Transaction
     suspend fun deleteSession(session: ReadRecordSession) {
     suspend fun deleteSession(session: ReadRecordSession) {
-        dao.deleteSession(session)
-
-        val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
-        val remainingSessions =
-            dao.getSessionsByBookAndDate(
-                session.deviceId,
-                session.bookName,
-                session.bookAuthor,
-                dateString
-            )
+        database.withTransaction {
+            dao.deleteSession(session)
+
+            val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
+            val remainingSessions =
+                dao.getSessionsByBookAndDate(
+                    session.deviceId,
+                    session.bookName,
+                    session.bookAuthor,
+                    dateString
+                )
 
 
-        if (remainingSessions.isEmpty()) {
-            val detail = dao.getDetail(
-                session.deviceId,
-                session.bookName,
-                session.bookAuthor,
-                dateString
-            )
-            detail?.let { dao.deleteDetail(it) }
-        } else {
-            val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
-            val totalWords = remainingSessions.sumOf { it.words }
-            val firstRead = remainingSessions.minOf { it.startTime }
-            val lastRead = remainingSessions.maxOf { it.endTime }
-
-            val existingDetail = dao.getDetail(
-                session.deviceId,
-                session.bookName,
-                session.bookAuthor,
-                dateString
-            )
-            existingDetail?.copy(
-                readTime = totalTime,
-                readWords = totalWords,
-                firstReadTime = firstRead,
-                lastReadTime = lastRead
-            )?.let { dao.insertDetail(it) }
-        }
+            if (remainingSessions.isEmpty()) {
+                val detail = dao.getDetail(
+                    session.deviceId,
+                    session.bookName,
+                    session.bookAuthor,
+                    dateString
+                )
+                detail?.let { dao.deleteDetail(it) }
+            } else {
+                val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
+                val totalWords = remainingSessions.sumOf { it.words }
+                val firstRead = remainingSessions.minOf { it.startTime }
+                val lastRead = remainingSessions.maxOf { it.endTime }
+
+                val existingDetail = dao.getDetail(
+                    session.deviceId,
+                    session.bookName,
+                    session.bookAuthor,
+                    dateString
+                )
+                dao.insertDetail(
+                    existingDetail?.copy(
+                        readTime = totalTime,
+                        readWords = totalWords,
+                        firstReadTime = firstRead,
+                        lastReadTime = lastRead
+                    ) ?: ReadRecordDetail(
+                        deviceId = session.deviceId,
+                        bookName = session.bookName,
+                        bookAuthor = session.bookAuthor,
+                        date = dateString,
+                        readTime = totalTime,
+                        readWords = totalWords,
+                        firstReadTime = firstRead,
+                        lastReadTime = lastRead,
+                    )
+                )
+            }
 
 
-        updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
+            updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
+        }
     }
     }
 
 
     private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
     private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
@@ -214,27 +234,44 @@ class ReadRecordRepository(
             val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
             val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
             val lastRead = allRemainingSessions.maxOf { it.endTime }
             val lastRead = allRemainingSessions.maxOf { it.endTime }
 
 
-            dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
-                readTime = totalTime,
-                lastRead = lastRead
-            )?.let { dao.update(it) }
+            val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
+            if (existingRecord == null) {
+                dao.insert(
+                    ReadRecord(
+                        deviceId = deviceId,
+                        bookName = bookName,
+                        bookAuthor = bookAuthor,
+                        readTime = totalTime,
+                        lastRead = lastRead,
+                    )
+                )
+            } else {
+                dao.update(
+                    existingRecord.copy(
+                        readTime = totalTime,
+                        lastRead = lastRead
+                    )
+                )
+            }
         }
         }
     }
     }
 
 
     suspend fun deleteReadRecord(record: ReadRecord) {
     suspend fun deleteReadRecord(record: ReadRecord) {
-        dao.deleteReadRecord(record)
-        dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
-        dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
+        database.withTransaction {
+            dao.deleteReadRecord(record)
+            dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
+            dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
+        }
     }
     }
 
 
-    @Transaction
     suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
     suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
-        sourceRecords.forEach { sourceRecord ->
-            mergeSingleReadRecordInto(targetRecord, sourceRecord)
+        database.withTransaction {
+            sourceRecords.forEach { sourceRecord ->
+                mergeSingleReadRecordInto(targetRecord, sourceRecord)
+            }
         }
         }
     }
     }
 
 
-    @Transaction
     private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
     private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
         if (targetRecord == sourceRecord) return
         if (targetRecord == sourceRecord) return
         if (targetRecord.deviceId != sourceRecord.deviceId) return
         if (targetRecord.deviceId != sourceRecord.deviceId) return

+ 1 - 1
app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt

@@ -39,7 +39,7 @@ class WebDavBackupRepository : WebDavBackupGateway {
 
 
     override suspend fun getLatestBackup(): WebDavBackup? {
     override suspend fun getLatestBackup(): WebDavBackup? {
         return withContext(IO) {
         return withContext(IO) {
-            AppWebDav.lastBackUp().getOrNull()?.let {
+            AppWebDav.lastBackUp().getOrThrow()?.let {
                 WebDavBackup(
                 WebDavBackup(
                     name = it.displayName,
                     name = it.displayName,
                     lastModify = it.lastModify
                     lastModify = it.lastModify

+ 15 - 9
app/src/main/java/io/legado/app/di/appModule.kt

@@ -7,14 +7,14 @@ import coil.decode.ImageDecoderDecoder
 import coil.decode.SvgDecoder
 import coil.decode.SvgDecoder
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
-import io.legado.app.data.repository.AppStartupRepository
-import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiMemoryRepository
 import io.legado.app.data.repository.AiMemoryRepository
 import io.legado.app.data.repository.AiProfileRepository
 import io.legado.app.data.repository.AiProfileRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.AiToolRepository
+import io.legado.app.data.repository.AppStartupRepository
+import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.BookGroupRepository
@@ -30,6 +30,7 @@ import io.legado.app.data.repository.DirectLinkUploadRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.data.repository.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
@@ -47,14 +48,14 @@ import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
-import io.legado.app.domain.gateway.AppStartupGateway
-import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiChatGateway
 import io.legado.app.domain.gateway.AiChatGateway
 import io.legado.app.domain.gateway.AiMemoryGateway
 import io.legado.app.domain.gateway.AiMemoryGateway
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.AiToolGateway
+import io.legado.app.domain.gateway.AppStartupGateway
+import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSearchGateway
@@ -62,6 +63,7 @@ import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
+import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
@@ -70,6 +72,7 @@ import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
+import io.legado.app.domain.usecase.AiChatGenerationUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -81,10 +84,10 @@ import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
+import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
-import io.legado.app.domain.usecase.AiChatGenerationUseCase
-import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
+import io.legado.app.domain.usecase.HomeDashboardUseCase
 import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
@@ -102,6 +105,7 @@ import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.about.AboutViewModel
+import io.legado.app.ui.ai.chat.AiChatViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
 import io.legado.app.ui.book.changecover.ChangeCoverViewModel
 import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -124,11 +128,10 @@ import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
-import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
 import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
-import io.legado.app.ui.ai.chat.AiChatViewModel
+import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
@@ -138,7 +141,6 @@ import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
-import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -156,10 +158,12 @@ import io.legado.app.ui.rss.favorites.RssFavoritesViewModel
 import io.legado.app.ui.rss.read.ReadRssViewModel
 import io.legado.app.ui.rss.read.ReadRssViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceViewModel
 import io.legado.app.ui.rss.subscription.RuleSubViewModel
 import io.legado.app.ui.rss.subscription.RuleSubViewModel
+import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
 import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
 import org.koin.dsl.module
+import java.time.Clock
 
 
 val appModule = module {
 val appModule = module {
 
 
@@ -170,6 +174,7 @@ val appModule = module {
     single { get<AppDatabase>().bookSourceDao }
     single { get<AppDatabase>().bookSourceDao }
 
 
     singleOf(::ReadRecordRepository)
     singleOf(::ReadRecordRepository)
+    single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
     singleOf(::BookRepository)
     singleOf(::BookRepository)
     singleOf(::BookGroupRepository)
     singleOf(::BookGroupRepository)
     singleOf(::BookSourceRepository)
     singleOf(::BookSourceRepository)
@@ -195,6 +200,7 @@ val appModule = module {
     singleOf(::ClearBookCacheUseCase)
     singleOf(::ClearBookCacheUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::GetReadingProgressUseCase)
     singleOf(::GetReadingProgressUseCase)
+    single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
     singleOf(::RemoveBookGroupAssignmentUseCase)
     singleOf(::RemoveBookGroupAssignmentUseCase)
     singleOf(::UpdateBooksGroupUseCase)
     singleOf(::UpdateBooksGroupUseCase)
     singleOf(::UploadReadingProgressUseCase)
     singleOf(::UploadReadingProgressUseCase)

+ 27 - 0
app/src/main/java/io/legado/app/domain/gateway/HomeDashboardGateway.kt

@@ -0,0 +1,27 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.HomeDashboardSection
+import io.legado.app.domain.model.HomeReadingBook
+import kotlinx.coroutines.flow.Flow
+
+interface HomeDashboardGateway {
+    fun observeTotalReadBooks(): Flow<Int>
+
+    fun observeTotalReadTime(): Flow<Long>
+
+    fun observeReadTime(date: String): Flow<Long>
+
+    fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>>
+
+    fun observeDailyGoal(defaultValue: Int): Flow<Int>
+
+    fun observeSelectedSourceSetUrl(): Flow<String?>
+
+    fun observeVisibleSections(): Flow<Set<HomeDashboardSection>>
+
+    suspend fun updateDailyGoal(minutes: Int)
+
+    suspend fun updateSelectedSourceSetUrl(sourceUrl: String)
+
+    suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>)
+}

+ 48 - 0
app/src/main/java/io/legado/app/domain/model/HomeDashboard.kt

@@ -0,0 +1,48 @@
+package io.legado.app.domain.model
+
+data class HomeDashboard(
+    val totalReadBooks: Int,
+    val totalReadTimeMillis: Long,
+    val todayReadTimeMillis: Long,
+    val dailyGoalMinutes: Int,
+    val recentBooks: List<HomeReadingBook>,
+)
+
+data class HomeReadingBook(
+    val bookUrl: String?,
+    val name: String,
+    val author: String,
+    val origin: String?,
+    val coverPath: String?,
+    val chapterTitle: String?,
+    val chapterProgress: Float?,
+)
+
+enum class HomeDashboardSection(val storageValue: String) {
+    RecentBook("recent_book"),
+    TotalReadBooks("total_read_books"),
+    TotalReadTime("total_read_time"),
+    RecentBooks("recent_books"),
+    DailyGoal("daily_goal"),
+    WebDavBackup("webdav_backup");
+
+    companion object {
+        fun fromStorage(value: String): Set<HomeDashboardSection> {
+            if (value.isBlank()) return emptySet()
+            val sections = value
+                .split(',')
+                .map(String::trim)
+                .mapNotNull { stored ->
+                    entries.firstOrNull { it.storageValue == stored }
+                }
+                .toSet()
+            return sections.ifEmpty { entries.toSet() }
+        }
+    }
+}
+
+val DEFAULT_HOME_DASHBOARD_SECTIONS: Set<HomeDashboardSection> =
+    HomeDashboardSection.entries.toSet()
+
+const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
+const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60

+ 88 - 0
app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt

@@ -0,0 +1,88 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.HomeDashboardGateway
+import io.legado.app.domain.model.DEFAULT_DAILY_READING_GOAL_MINUTES
+import io.legado.app.domain.model.HomeDashboard
+import io.legado.app.domain.model.HomeDashboardSection
+import io.legado.app.domain.model.MAX_DAILY_READING_GOAL_MINUTES
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import java.time.Clock
+import java.time.Duration
+import java.time.ZonedDateTime
+
+class HomeDashboardUseCase(
+    private val gateway: HomeDashboardGateway,
+    private val clock: Clock,
+) {
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun observe(): Flow<HomeDashboard> {
+        val todayReadTime = currentDate()
+            .flatMapLatest(gateway::observeReadTime)
+
+        return combine(
+            gateway.observeTotalReadBooks(),
+            gateway.observeTotalReadTime(),
+            todayReadTime,
+            gateway.observeRecentBooks(RECENT_BOOK_LIMIT),
+            gateway.observeDailyGoal(DEFAULT_DAILY_READING_GOAL_MINUTES),
+        ) { totalBooks, totalTime, todayTime, recentBooks, dailyGoal ->
+            HomeDashboard(
+                totalReadBooks = totalBooks,
+                totalReadTimeMillis = totalTime,
+                todayReadTimeMillis = todayTime,
+                dailyGoalMinutes = dailyGoal.coerceIn(
+                    1,
+                    MAX_DAILY_READING_GOAL_MINUTES,
+                ),
+                recentBooks = recentBooks,
+            )
+        }
+    }
+
+    suspend fun updateDailyGoal(minutes: Int) {
+        gateway.updateDailyGoal(
+            minutes.coerceIn(1, MAX_DAILY_READING_GOAL_MINUTES)
+        )
+    }
+
+    fun observeSelectedSourceSetUrl(): Flow<String?> =
+        gateway.observeSelectedSourceSetUrl()
+
+    fun observeVisibleSections(): Flow<Set<HomeDashboardSection>> =
+        gateway.observeVisibleSections()
+
+    suspend fun updateSelectedSourceSetUrl(sourceUrl: String) {
+        gateway.updateSelectedSourceSetUrl(sourceUrl)
+    }
+
+    suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>) {
+        gateway.updateVisibleSections(sections)
+    }
+
+    private fun currentDate(): Flow<String> = flow {
+        while (true) {
+            val now = ZonedDateTime.now(clock)
+            emit(now.toLocalDate().toString())
+            val nextDay = now.toLocalDate()
+                .plusDays(1)
+                .atStartOfDay(clock.zone)
+            delay(
+                Duration.between(now, nextDay)
+                    .toMillis()
+                    .coerceAtLeast(MIN_DATE_REFRESH_DELAY_MILLIS)
+            )
+        }
+    }.distinctUntilChanged()
+
+    private companion object {
+        const val RECENT_BOOK_LIMIT = 7
+        const val MIN_DATE_REFRESH_DELAY_MILLIS = 1_000L
+    }
+}

+ 3 - 0
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -314,6 +314,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             BackupConfig.backupPath = value
             BackupConfig.backupPath = value
         }
         }
 
 
+    val showHome: Boolean
+        get() = ThemeConfig.showHome
+
     val showDiscovery: Boolean
     val showDiscovery: Boolean
         get() = ThemeConfig.showDiscovery
         get() = ThemeConfig.showDiscovery
 
 

+ 6 - 0
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -174,6 +174,7 @@ object ThemeImportExport {
             customTagColorsJson = ThemeConfig.customTagColorsJson,
             customTagColorsJson = ThemeConfig.customTagColorsJson,
 
 
             // 主界面设置
             // 主界面设置
+            showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
             showDiscovery = ThemeConfig.showDiscovery,
             showRss = ThemeConfig.showRss,
             showRss = ThemeConfig.showRss,
             showStatusBar = ThemeConfig.showStatusBar,
             showStatusBar = ThemeConfig.showStatusBar,
@@ -184,6 +185,7 @@ object ThemeImportExport {
             tabletInterface = ThemeConfig.tabletInterface,
             tabletInterface = ThemeConfig.tabletInterface,
             labelVisibilityMode = ThemeConfig.labelVisibilityMode,
             labelVisibilityMode = ThemeConfig.labelVisibilityMode,
             defaultHomePage = ThemeConfig.defaultHomePage,
             defaultHomePage = ThemeConfig.defaultHomePage,
+            mainNavigationOrder = ThemeConfig.mainNavigationOrder,
 
 
             // 导航栏图标
             // 导航栏图标
             navIconHome = ThemeConfig.navIconHome,
             navIconHome = ThemeConfig.navIconHome,
@@ -343,6 +345,7 @@ object ThemeImportExport {
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
 
         // 主界面设置
         // 主界面设置
+        ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showRss = data.showRss
         ThemeConfig.showRss = data.showRss
         ThemeConfig.showStatusBar = data.showStatusBar
         ThemeConfig.showStatusBar = data.showStatusBar
@@ -353,6 +356,7 @@ object ThemeImportExport {
         ThemeConfig.tabletInterface = data.tabletInterface
         ThemeConfig.tabletInterface = data.tabletInterface
         ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
         ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
         ThemeConfig.defaultHomePage = data.defaultHomePage
         ThemeConfig.defaultHomePage = data.defaultHomePage
+        ThemeConfig.mainNavigationOrder = data.mainNavigationOrder
 
 
         // 导航栏图标
         // 导航栏图标
         ThemeConfig.navIconHome = data.navIconHome
         ThemeConfig.navIconHome = data.navIconHome
@@ -579,6 +583,7 @@ data class ThemeExportData(
     val customTagColorsJson: String? = null,
     val customTagColorsJson: String? = null,
 
 
     // 主界面设置
     // 主界面设置
+    val showHome: Boolean = true,
     val showDiscovery: Boolean = true,
     val showDiscovery: Boolean = true,
     val showRss: Boolean = true,
     val showRss: Boolean = true,
     val showStatusBar: Boolean = true,
     val showStatusBar: Boolean = true,
@@ -589,6 +594,7 @@ data class ThemeExportData(
     val tabletInterface: String = "auto",
     val tabletInterface: String = "auto",
     val labelVisibilityMode: String = "auto",
     val labelVisibilityMode: String = "auto",
     val defaultHomePage: String = "bookshelf",
     val defaultHomePage: String = "bookshelf",
+    val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
 
 
     // 导航栏图标
     // 导航栏图标
     val navIconHome: String = "",
     val navIconHome: String = "",

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt

@@ -4,6 +4,7 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.prefDelegate
 import io.legado.app.ui.config.prefDelegate
 
 
 object MainConfig {
 object MainConfig {
+    var showHome by prefDelegate(PreferKey.showHome, true)
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
     var showRSS by prefDelegate(PreferKey.showRss, true)
     var showRSS by prefDelegate(PreferKey.showRss, true)
     var showBottomView by prefDelegate(PreferKey.showBottomView, true)
     var showBottomView by prefDelegate(PreferKey.showBottomView, true)
@@ -13,6 +14,10 @@ object MainConfig {
         false
         false
     )
     )
     var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
     var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
+    var mainNavigationOrder by prefDelegate(
+        PreferKey.mainNavigationOrder,
+        "home,bookshelf,explore,rss,my",
+    )
     var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
     var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
     var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
     var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
     var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)
     var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)

+ 166 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/MainNavigationSettingsSheet.kt

@@ -0,0 +1,166 @@
+package io.legado.app.ui.config.themeConfig
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.main.MainDestination
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.move
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@Composable
+fun MainNavigationSettingsSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+) {
+    val homeLabel = stringResource(R.string.home)
+    val bookshelfLabel = stringResource(R.string.bookshelf)
+    val discoveryLabel = stringResource(R.string.discovery)
+    val rssLabel = stringResource(R.string.rss)
+    val myLabel = stringResource(R.string.my)
+    val destinations = listOfNotNull(
+        if (ThemeConfig.showHome) homeLabel to MainDestination.Home.route else null,
+        bookshelfLabel to MainDestination.Bookshelf.route,
+        if (ThemeConfig.showDiscovery) {
+            discoveryLabel to MainDestination.Explore.route
+        } else {
+            null
+        },
+        if (ThemeConfig.showRss) rssLabel to MainDestination.Rss.route else null,
+        myLabel to MainDestination.My.route,
+    )
+    val selectedDefault = ThemeConfig.defaultHomePage.takeIf { route ->
+        destinations.any { it.second == route }
+    } ?: MainDestination.Bookshelf.route
+    val configuredOrder = MainDestination.ordered(ThemeConfig.mainNavigationOrder)
+    var navigationItems by remember(show) { mutableStateOf(configuredOrder) }
+    val navigationListState = rememberLazyListState()
+    val reorderableState =
+        rememberReorderableLazyListState(navigationListState) { from, to ->
+            navigationItems = navigationItems.toMutableList().apply {
+                move(from.index, to.index)
+            }
+        }
+
+    LaunchedEffect(configuredOrder, reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging) {
+            navigationItems = configuredOrder
+        }
+    }
+    LaunchedEffect(reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging) {
+            ThemeConfig.mainNavigationOrder =
+                navigationItems.joinToString(",") { it.route }
+        }
+    }
+
+    fun updateVisibility(
+        route: String,
+        visible: Boolean,
+        update: (Boolean) -> Unit,
+    ) {
+        update(visible)
+        if (!visible && ThemeConfig.defaultHomePage == route) {
+            ThemeConfig.defaultHomePage = MainDestination.Bookshelf.route
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.main_navigation_settings),
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(bottom = 24.dp),
+        ) {
+            CompactSwitchSettingItem(
+                title = stringResource(R.string.show_home),
+                checked = ThemeConfig.showHome,
+                onCheckedChange = {
+                    updateVisibility(
+                        MainDestination.Home.route,
+                        it,
+                    ) { value -> ThemeConfig.showHome = value }
+                },
+            )
+            CompactSwitchSettingItem(
+                title = stringResource(R.string.show_discovery),
+                checked = ThemeConfig.showDiscovery,
+                onCheckedChange = {
+                    updateVisibility(
+                        MainDestination.Explore.route,
+                        it,
+                    ) { value -> ThemeConfig.showDiscovery = value }
+                },
+            )
+            CompactSwitchSettingItem(
+                title = stringResource(R.string.show_rss),
+                checked = ThemeConfig.showRss,
+                onCheckedChange = {
+                    updateVisibility(
+                        MainDestination.Rss.route,
+                        it,
+                    ) { value -> ThemeConfig.showRss = value }
+                },
+            )
+            CompactDropdownSettingItem(
+                title = stringResource(R.string.default_home_page),
+                selectedValue = selectedDefault,
+                displayEntries = destinations.map { it.first }.toTypedArray(),
+                entryValues = destinations.map { it.second }.toTypedArray(),
+                onValueChange = { ThemeConfig.defaultHomePage = it },
+            )
+            AppText(
+                text = stringResource(R.string.navigation_order),
+                style = LegadoTheme.typography.titleSmallEmphasized,
+                modifier = Modifier.padding(
+                    start = 16.dp,
+                    top = 16.dp,
+                    end = 16.dp,
+                    bottom = 8.dp,
+                ),
+            )
+            LazyColumn(
+                state = navigationListState,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 360.dp),
+                verticalArrangement = Arrangement.spacedBy(4.dp),
+            ) {
+                items(
+                    items = navigationItems,
+                    key = { it.route },
+                ) { destination ->
+                    ReorderableSelectionItem(
+                        state = reorderableState,
+                        key = destination.route,
+                        title = stringResource(destination.labelId),
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                    )
+                }
+            }
+        }
+    }
+}

+ 7 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -142,6 +142,8 @@ object ThemeConfig {
         customTagColorsJson = GSON.toJson(colors)
         customTagColorsJson = GSON.toJson(colors)
     }
     }
 
 
+    var showHome by prefDelegate(PreferKey.showHome, true)
+
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
 
 
     var showRss by prefDelegate(PreferKey.showRss, true)
     var showRss by prefDelegate(PreferKey.showRss, true)
@@ -165,6 +167,11 @@ object ThemeConfig {
 
 
     var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
     var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
 
 
+    var mainNavigationOrder by prefDelegate(
+        PreferKey.mainNavigationOrder,
+        "home,bookshelf,explore,rss,my",
+    )
+
     var navExtended by prefDelegate("navExtended", false)
     var navExtended by prefDelegate("navExtended", false)
 
 
     var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
     var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)

+ 10 - 16
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -123,6 +123,7 @@ fun ThemeConfigScreen(
     var showLauncherIconPicker by remember { mutableStateOf(false) }
     var showLauncherIconPicker by remember { mutableStateOf(false) }
     var showBorderColorPicker by remember { mutableStateOf(false) }
     var showBorderColorPicker by remember { mutableStateOf(false) }
     var showNavIconSheet by remember { mutableStateOf(false) }
     var showNavIconSheet by remember { mutableStateOf(false) }
+    var showMainNavigationSheet by remember { mutableStateOf(false) }
     var showFontSheet by remember { mutableStateOf(false) }
     var showFontSheet by remember { mutableStateOf(false) }
     val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
     val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
 
 
@@ -375,15 +376,10 @@ fun ThemeConfigScreen(
                 }
                 }
 
 
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
-                    SwitchSettingItem(
-                        title = stringResource(R.string.show_discovery),
-                        checked = ThemeConfig.showDiscovery,
-                        onCheckedChange = { ThemeConfig.showDiscovery = it }
-                    )
-                    SwitchSettingItem(
-                        title = stringResource(R.string.show_rss),
-                        checked = ThemeConfig.showRss,
-                        onCheckedChange = { ThemeConfig.showRss = it }
+                    ClickableSettingItem(
+                        title = stringResource(R.string.main_navigation_settings),
+                        description = stringResource(R.string.main_navigation_settings_summary),
+                        onClick = { showMainNavigationSheet = true },
                     )
                     )
                     SwitchSettingItem(
                     SwitchSettingItem(
                         title = stringResource(R.string.show_status),
                         title = stringResource(R.string.show_status),
@@ -446,13 +442,6 @@ fun ThemeConfigScreen(
                         entryValues = stringArrayResource(R.array.label_vis_mode_value),
                         entryValues = stringArrayResource(R.array.label_vis_mode_value),
                         onValueChange = { ThemeConfig.labelVisibilityMode = it }
                         onValueChange = { ThemeConfig.labelVisibilityMode = it }
                     )
                     )
-                    DropdownListSettingItem(
-                        title = stringResource(R.string.default_home_page),
-                        selectedValue = ThemeConfig.defaultHomePage,
-                        displayEntries = stringArrayResource(R.array.default_home_page),
-                        entryValues = stringArrayResource(R.array.default_home_page_value),
-                        onValueChange = { ThemeConfig.defaultHomePage = it }
-                    )
                 }
                 }
 
 
                 SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
                 SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
@@ -841,6 +830,11 @@ fun ThemeConfigScreen(
         onDismissRequest = { showNavIconSheet = false }
         onDismissRequest = { showNavIconSheet = false }
     )
     )
 
 
+    MainNavigationSettingsSheet(
+        show = showMainNavigationSheet,
+        onDismissRequest = { showMainNavigationSheet = false },
+    )
+
 
 
     LauncherIconPickerSheet(
     LauncherIconPickerSheet(
         show = showLauncherIconPicker,
         show = showLauncherIconPicker,

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt

@@ -163,6 +163,11 @@ fun EditThemeSheet(
 
 
             // Interface layout
             // Interface layout
             SectionTitle(stringResource(R.string.theme_manage_section_layout))
             SectionTitle(stringResource(R.string.theme_manage_section_layout))
+            CompactSwitchSettingItem(
+                title = stringResource(R.string.show_home),
+                checked = data.showHome,
+                onCheckedChange = { data = data.copy(showHome = it) }
+            )
             CompactSwitchSettingItem(
             CompactSwitchSettingItem(
                 title = stringResource(R.string.theme_manage_show_discovery),
                 title = stringResource(R.string.theme_manage_show_discovery),
                 checked = data.showDiscovery,
                 checked = data.showDiscovery,

+ 10 - 2
app/src/main/java/io/legado/app/ui/main/MainDestination.kt

@@ -35,9 +35,17 @@ sealed class MainDestination(
     )
     )
 
 
     companion object {
     companion object {
-        const val DISCOVERY_MODULES_ROUTE = "explore_modules"
-
         val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
         val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
+
+        fun ordered(order: String): List<MainDestination> {
+            val byRoute = mainDestinations.associateBy { it.route }
+            val ordered = order
+                .split(',')
+                .map(String::trim)
+                .distinct()
+                .mapNotNull(byRoute::get)
+            return ordered + mainDestinations.filterNot { it in ordered }
+        }
     }
     }
 }
 }
 
 

+ 13 - 21
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -182,14 +182,9 @@ fun MainScreen(
     val destinations = mainUiState.destinations
     val destinations = mainUiState.destinations
 
 
     val initialPage = remember(destinations, mainUiState.defaultHomePage) {
     val initialPage = remember(destinations, mainUiState.defaultHomePage) {
-        val defaultRoute = if (
-            mainUiState.defaultHomePage == MainDestination.DISCOVERY_MODULES_ROUTE
-        ) {
-            MainDestination.Explore.route
-        } else {
-            mainUiState.defaultHomePage
+        val index = destinations.indexOfFirst {
+            it.route == mainUiState.defaultHomePage
         }
         }
-        val index = destinations.indexOfFirst { it.route == defaultRoute }
         if (index != -1) index else 0
         if (index != -1) index else 0
     }
     }
     val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
     val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
@@ -392,6 +387,17 @@ fun MainScreen(
                                 onOpenBook = { book ->
                                 onOpenBook = { book ->
                                     context.startActivityForBook(book)
                                     context.startActivityForBook(book)
                                 },
                                 },
+                                onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
+                                    onNavigateToBookInfo(
+                                        name ?: "",
+                                        author ?: "",
+                                        bookUrl,
+                                        origin,
+                                        coverPath,
+                                        sharedCoverKey,
+                                    )
+                                },
+                                onOpenExploreShow = onNavigateToExploreShow,
                                 onOpenBackupSettings = onNavigateToBackupSettings,
                                 onOpenBackupSettings = onNavigateToBackupSettings,
                                 onNavigateToReadRecord = onNavigateToReadRecord,
                                 onNavigateToReadRecord = onNavigateToReadRecord,
                                 onNavigateToReadRecordOverview = onNavigateToReadRecordOverview,
                                 onNavigateToReadRecordOverview = onNavigateToReadRecordOverview,
@@ -423,21 +429,7 @@ fun MainScreen(
                             )
                             )
 
 
                             MainDestination.Explore -> ExploreScreen(
                             MainDestination.Explore -> ExploreScreen(
-                                style = mainUiState.exploreStyle,
-                                onStyleChange = viewModel::setExploreStyle,
-                                onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
-                                    onNavigateToBookInfo(
-                                        name ?: "",
-                                        author ?: "",
-                                        bookUrl,
-                                        origin,
-                                        coverPath,
-                                        sharedCoverKey
-                                    )
-                                },
                                 onOpenExploreShow = onNavigateToExploreShow,
                                 onOpenExploreShow = onNavigateToExploreShow,
-                                sharedTransitionScope = sharedTransitionScope,
-                                animatedVisibilityScope = animatedVisibilityScope,
                             )
                             )
                             MainDestination.Rss -> RssScreen(
                             MainDestination.Rss -> RssScreen(
                                 onOpenSort = { sourceUrl, sortUrl, key ->
                                 onOpenSort = { sourceUrl, sortUrl, key ->

+ 4 - 31
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -6,12 +6,9 @@ import androidx.compose.runtime.snapshotFlow
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
-import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.ui.main.explore.ExploreStyle
 import io.legado.app.ui.main.my.PrefClickEvent
 import io.legado.app.ui.main.my.PrefClickEvent
 import io.legado.app.utils.eventBus.FlowEventBus
 import io.legado.app.utils.eventBus.FlowEventBus
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableList
@@ -27,7 +24,6 @@ class MainViewModel(
     application: Application,
     application: Application,
     private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
     private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
     private val webDavBackupUseCase: WebDavBackupUseCase,
     private val webDavBackupUseCase: WebDavBackupUseCase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
 ) : BaseViewModel(application) {
 ) : BaseViewModel(application) {
 
 
     private val _uiState = MutableStateFlow(readMainUiState())
     private val _uiState = MutableStateFlow(readMainUiState())
@@ -36,24 +32,12 @@ class MainViewModel(
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
     init {
     init {
-        // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State,全链路走 DS
+        // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State
         viewModelScope.launch {
         viewModelScope.launch {
             snapshotFlow {
             snapshotFlow {
                 readMainUiState()
                 readMainUiState()
             }.collect { newState ->
             }.collect { newState ->
-                _uiState.update { currentState ->
-                    newState.copy(exploreStyle = currentState.exploreStyle)
-                }
-            }
-        }
-        viewModelScope.launch {
-            localPreferencesRepository.getPreference(
-                LocalPreferencesKeys.EXPLORE_STYLE,
-                ExploreStyle.ClassicDiscovery.storageValue,
-            ).collect { storedValue ->
-                _uiState.update {
-                    it.copy(exploreStyle = ExploreStyle.fromStorageValue(storedValue))
-                }
+                _uiState.value = newState
             }
             }
         }
         }
         deleteNotShelfBook()
         deleteNotShelfBook()
@@ -89,17 +73,6 @@ class MainViewModel(
         ThemeConfig.navExtended = expanded
         ThemeConfig.navExtended = expanded
     }
     }
 
 
-    fun setExploreStyle(style: ExploreStyle) {
-        if (_uiState.value.exploreStyle == style) return
-        _uiState.update { it.copy(exploreStyle = style) }
-        viewModelScope.launch {
-            localPreferencesRepository.updatePreference(
-                LocalPreferencesKeys.EXPLORE_STYLE,
-                style.storageValue,
-            )
-        }
-    }
-
     fun onPrefClickEvent(event: PrefClickEvent) {
     fun onPrefClickEvent(event: PrefClickEvent) {
         when (event) {
         when (event) {
             is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
             is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
@@ -158,12 +131,12 @@ data class MainUiState(
     val useFloatingBottomBarLiquidGlass: Boolean = false,
     val useFloatingBottomBarLiquidGlass: Boolean = false,
     val labelVisibilityMode: String = "auto",
     val labelVisibilityMode: String = "auto",
     val navExtended: Boolean = false,
     val navExtended: Boolean = false,
-    val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
 )
 )
 
 
 private fun MainViewModel.readMainUiState(): MainUiState {
 private fun MainViewModel.readMainUiState(): MainUiState {
-    val destinations = MainDestination.mainDestinations.filter {
+    val destinations = MainDestination.ordered(ThemeConfig.mainNavigationOrder).filter {
         when (it) {
         when (it) {
+            MainDestination.Home -> ThemeConfig.showHome
             MainDestination.Explore -> ThemeConfig.showDiscovery
             MainDestination.Explore -> ThemeConfig.showDiscovery
             MainDestination.Rss -> ThemeConfig.showRss
             MainDestination.Rss -> ThemeConfig.showRss
             else -> true
             else -> true

+ 1 - 63
app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt

@@ -2,16 +2,10 @@ package io.legado.app.ui.main.explore
 
 
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibilityScope
-import androidx.compose.animation.ExperimentalSharedTransitionApi
-import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
@@ -32,7 +26,6 @@ import androidx.compose.material.icons.filled.ChevronRight
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.Group
-import androidx.compose.material.icons.filled.Home
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.VerticalAlignTop
 import androidx.compose.material.icons.filled.VerticalAlignTop
@@ -47,7 +40,6 @@ import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveableStateHolder
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
@@ -67,7 +59,6 @@ import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.main.homepage.HomepageScreen
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.ThemeResolver
@@ -95,64 +86,19 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @Composable
 @Composable
 fun ExploreScreen(
 fun ExploreScreen(
-    style: ExploreStyle,
-    onStyleChange: (ExploreStyle) -> Unit,
-    onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
-    sharedTransitionScope: SharedTransitionScope? = null,
-    animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
 ) {
-    val stateHolder = rememberSaveableStateHolder()
-
-    AnimatedContent(
-        targetState = style,
-        modifier = Modifier.fillMaxSize(),
-        transitionSpec = {
-            fadeIn(
-                animationSpec = tween(
-                    durationMillis = 220,
-                    delayMillis = 90,
-                )
-            ) togetherWith fadeOut(
-                animationSpec = tween(durationMillis = 90)
-            )
-        },
-        contentKey = { it },
-        label = "ExploreStyle",
-    ) { page ->
-        stateHolder.SaveableStateProvider(page) {
-            when (page) {
-                ExploreStyle.DiscoveryModules -> HomepageScreen(
-                    onBookClick = onBookClick,
-                    onModuleHeaderClick = onOpenExploreShow,
-                    onSwitchToDiscovery = {
-                        onStyleChange(ExploreStyle.ClassicDiscovery)
-                    },
-                    sharedTransitionScope = sharedTransitionScope,
-                    animatedVisibilityScope = animatedVisibilityScope,
-                )
-
-                else -> ExploreDiscoveryScreen(
-                    onOpenExploreShow = onOpenExploreShow,
-                    onSwitchToHomepage = {
-                        onStyleChange(ExploreStyle.DiscoveryModules)
-                    },
-                )
-            }
-        }
-    }
+    ExploreDiscoveryScreen(onOpenExploreShow = onOpenExploreShow)
 }
 }
 
 
 @OptIn(
 @OptIn(
     ExperimentalMaterial3Api::class,
     ExperimentalMaterial3Api::class,
     ExperimentalFoundationApi::class,
     ExperimentalFoundationApi::class,
-    ExperimentalSharedTransitionApi::class,
 )
 )
 @Composable
 @Composable
 private fun ExploreDiscoveryScreen(
 private fun ExploreDiscoveryScreen(
     viewModel: ExploreViewModel = koinViewModel(),
     viewModel: ExploreViewModel = koinViewModel(),
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
-    onSwitchToHomepage: () -> Unit,
 ) {
 ) {
     val context = LocalContext.current
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
     val activity = context as? AppCompatActivity
@@ -208,14 +154,6 @@ private fun ExploreDiscoveryScreen(
         onSearchToggle = { viewModel.toggleSearchVisible(it) },
         onSearchToggle = { viewModel.toggleSearchVisible(it) },
         searchPlaceholder = stringResource(R.string.search),
         searchPlaceholder = stringResource(R.string.search),
         dropDownMenuContent = { dismiss ->
         dropDownMenuContent = { dismiss ->
-            RoundDropdownMenuItem(
-                leadingIcon = { MenuItemIcon(Icons.Default.Home) },
-                text = stringResource(R.string.switch_to_discovery_modules),
-                onClick = {
-                    dismiss()
-                    onSwitchToHomepage()
-                },
-            )
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 leadingIcon = { MenuItemIcon(Icons.Default.Group) },
                 leadingIcon = { MenuItemIcon(Icons.Default.Group) },
                 text = stringResource(R.string.all),
                 text = stringResource(R.string.all),

+ 0 - 12
app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt

@@ -1,12 +0,0 @@
-package io.legado.app.ui.main.explore
-
-enum class ExploreStyle(val storageValue: String) {
-    DiscoveryModules("discovery_modules"),
-    ClassicDiscovery("classic_discovery");
-
-    companion object {
-        fun fromStorageValue(value: String): ExploreStyle {
-            return entries.firstOrNull { it.storageValue == value } ?: ClassicDiscovery
-        }
-    }
-}

+ 21 - 3
app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt

@@ -3,6 +3,13 @@ package io.legado.app.ui.main.home
 import androidx.annotation.StringRes
 import androidx.annotation.StringRes
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
+import io.legado.app.domain.model.DEFAULT_DAILY_READING_GOAL_MINUTES
+import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
+import io.legado.app.domain.model.HomeDashboardSection
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableSet
 
 
 @Stable
 @Stable
 data class HomeUiState(
 data class HomeUiState(
@@ -11,8 +18,13 @@ data class HomeUiState(
     val todayReadTimeMillis: Long = 0L,
     val todayReadTimeMillis: Long = 0L,
     val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
     val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
     val recentBook: HomeRecentBookUi? = null,
     val recentBook: HomeRecentBookUi? = null,
+    val recentBooks: ImmutableList<HomeRecentBookUi> = persistentListOf(),
+    val selectedSourceSetUrl: String? = null,
+    val visibleSections: ImmutableSet<HomeDashboardSection> =
+        DEFAULT_HOME_DASHBOARD_SECTIONS.toImmutableSet(),
     val latestBackup: HomeBackupUi? = null,
     val latestBackup: HomeBackupUi? = null,
     val isBackupLoading: Boolean = true,
     val isBackupLoading: Boolean = true,
+    val isBackupLoadError: Boolean = false,
     val isBackupActionRunning: Boolean = false,
     val isBackupActionRunning: Boolean = false,
     val activeDialog: HomeDialog? = null,
     val activeDialog: HomeDialog? = null,
     val activeSheet: HomeSheet? = null,
     val activeSheet: HomeSheet? = null,
@@ -37,6 +49,13 @@ data class HomeBackupUi(
 
 
 sealed interface HomeIntent {
 sealed interface HomeIntent {
     data object RecentBookClick : HomeIntent
     data object RecentBookClick : HomeIntent
+    data class RecentHistoryBookClick(val bookUrl: String) : HomeIntent
+    data class SelectSourceSet(val sourceUrl: String) : HomeIntent
+    data object DashboardSettingsClick : HomeIntent
+    data class SetSectionVisible(
+        val section: HomeDashboardSection,
+        val visible: Boolean,
+    ) : HomeIntent
     data object ReadingGoalClick : HomeIntent
     data object ReadingGoalClick : HomeIntent
     data class UpdateReadingGoal(val minutes: Int) : HomeIntent
     data class UpdateReadingGoal(val minutes: Int) : HomeIntent
     data object BackupClick : HomeIntent
     data object BackupClick : HomeIntent
@@ -52,6 +71,7 @@ sealed interface HomeIntent {
     data class RestoreLocalFileSelected(val uri: String) : HomeIntent
     data class RestoreLocalFileSelected(val uri: String) : HomeIntent
     data object ConfirmRestore : HomeIntent
     data object ConfirmRestore : HomeIntent
     data object BackupSettingsClick : HomeIntent
     data object BackupSettingsClick : HomeIntent
+    data object RetryBackupInfo : HomeIntent
     data object DismissDialog : HomeIntent
     data object DismissDialog : HomeIntent
     data object DismissSheet : HomeIntent
     data object DismissSheet : HomeIntent
 }
 }
@@ -83,6 +103,7 @@ sealed interface HomeDialog {
 
 
 @Stable
 @Stable
 sealed interface HomeSheet {
 sealed interface HomeSheet {
+    data object DashboardSettings : HomeSheet
     data object BackupOptions : HomeSheet
     data object BackupOptions : HomeSheet
     data object RestoreOptions : HomeSheet
     data object RestoreOptions : HomeSheet
 }
 }
@@ -92,6 +113,3 @@ enum class HomeBackupDestination(val mode: String) {
     WebDav("webdav"),
     WebDav("webdav"),
     LocalAndWebDav("both"),
     LocalAndWebDav("both"),
 }
 }
-
-const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
-const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 668 - 146
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt


+ 182 - 125
app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt

@@ -3,31 +3,35 @@ package io.legado.app.ui.main.home
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.data.entities.Book
-import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookRepository
-import io.legado.app.data.repository.ReadRecordRepository
+import io.legado.app.domain.model.HomeDashboardSection
+import io.legado.app.domain.model.HomeReadingBook
 import io.legado.app.domain.model.WebDavBackup
 import io.legado.app.domain.model.WebDavBackup
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
+import io.legado.app.domain.usecase.HomeDashboardUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.ui.config.backupConfig.BackupConfig
 import io.legado.app.ui.config.backupConfig.BackupConfig
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isContentScheme
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
-import java.time.LocalDate
+import kotlinx.coroutines.sync.Mutex
 
 
 class HomeViewModel(
 class HomeViewModel(
-    private val readRecordRepository: ReadRecordRepository,
+    private val homeDashboardUseCase: HomeDashboardUseCase,
     private val bookRepository: BookRepository,
     private val bookRepository: BookRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository,
     private val webDavBackupUseCase: WebDavBackupUseCase,
     private val webDavBackupUseCase: WebDavBackupUseCase,
     private val backupRestoreUseCase: BackupRestoreUseCase,
     private val backupRestoreUseCase: BackupRestoreUseCase,
 ) : ViewModel() {
 ) : ViewModel() {
@@ -37,58 +41,15 @@ class HomeViewModel(
     private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
     private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
     private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
     private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
+    private var backupRefreshJob: Job? = null
+    private val backupActionMutex = Mutex()
 
 
     private val dashboardData = combine(
     private val dashboardData = combine(
-        readRecordRepository.getLatestReadRecords(),
-        readRecordRepository.getTotalReadTime(),
-        readRecordRepository.getAllRecordDetails(),
-        bookRepository.getAllBooks(),
-        localPreferencesRepository.getPreference(
-            LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
-            DEFAULT_DAILY_READING_GOAL_MINUTES,
-        ),
-    ) { records, totalReadTime, details, books, dailyGoalMinutes ->
-        val latestRecord = records.firstOrNull()
-        val latestBook = latestRecord?.let { record ->
-            books.firstOrNull {
-                it.name == record.bookName && it.author == record.bookAuthor
-            }
-        }
-        val today = LocalDate.now().toString()
-
-        HomeDashboardData(
-            totalReadBooks = records
-                .distinctBy { it.bookName to it.bookAuthor }
-                .size,
-            totalReadTimeMillis = totalReadTime,
-            todayReadTimeMillis = details
-                .asSequence()
-                .filter { it.date == today }
-                .sumOf { it.readTime },
-            dailyGoalMinutes = dailyGoalMinutes.coerceIn(
-                1,
-                MAX_DAILY_READING_GOAL_MINUTES,
-            ),
-            recentBook = latestRecord?.let { record ->
-                HomeRecentBookUi(
-                    bookUrl = latestBook?.bookUrl,
-                    name = record.bookName,
-                    author = record.bookAuthor,
-                    origin = latestBook?.origin,
-                    coverPath = latestBook?.getDisplayCover(),
-                    chapterTitle = latestBook?.durChapterTitle,
-                    chapterProgress = latestBook?.let { book ->
-                        if (book.totalChapterNum > 0) {
-                            (book.durChapterIndex + 1)
-                                .coerceIn(0, book.totalChapterNum)
-                                .toFloat() / book.totalChapterNum
-                        } else {
-                            null
-                        }
-                    },
-                )
-            },
-        )
+        homeDashboardUseCase.observe(),
+        homeDashboardUseCase.observeSelectedSourceSetUrl(),
+        homeDashboardUseCase.observeVisibleSections(),
+    ) { dashboard, selectedSourceUrl, visibleSections ->
+        Triple(dashboard, selectedSourceUrl, visibleSections)
     }
     }
 
 
     val uiState = combine(
     val uiState = combine(
@@ -96,15 +57,24 @@ class HomeViewModel(
         _backupState,
         _backupState,
         _activeDialog,
         _activeDialog,
         _activeSheet,
         _activeSheet,
-    ) { dashboard, backup, dialog, sheet ->
+    ) { dashboardData, backup, dialog, sheet ->
+        val (dashboard, selectedSourceUrl, visibleSections) = dashboardData
         HomeUiState(
         HomeUiState(
             totalReadBooks = dashboard.totalReadBooks,
             totalReadBooks = dashboard.totalReadBooks,
             totalReadTimeMillis = dashboard.totalReadTimeMillis,
             totalReadTimeMillis = dashboard.totalReadTimeMillis,
             todayReadTimeMillis = dashboard.todayReadTimeMillis,
             todayReadTimeMillis = dashboard.todayReadTimeMillis,
             dailyGoalMinutes = dashboard.dailyGoalMinutes,
             dailyGoalMinutes = dashboard.dailyGoalMinutes,
-            recentBook = dashboard.recentBook,
+            recentBook = dashboard.recentBooks.firstOrNull()?.toUi(),
+            recentBooks = dashboard.recentBooks
+                .drop(1)
+                .take(6)
+                .map { it.toUi() }
+                .toImmutableList(),
+            selectedSourceSetUrl = selectedSourceUrl,
+            visibleSections = visibleSections.toImmutableSet(),
             latestBackup = backup.latest?.toUi(),
             latestBackup = backup.latest?.toUi(),
             isBackupLoading = backup.isLoading,
             isBackupLoading = backup.isLoading,
+            isBackupLoadError = backup.isLoadError,
             isBackupActionRunning = backup.isActionRunning,
             isBackupActionRunning = backup.isActionRunning,
             activeDialog = dialog,
             activeDialog = dialog,
             activeSheet = sheet,
             activeSheet = sheet,
@@ -116,12 +86,33 @@ class HomeViewModel(
     )
     )
 
 
     init {
     init {
-        refreshLatestBackup()
+        viewModelScope.launch {
+            homeDashboardUseCase.observeVisibleSections()
+                .map { HomeDashboardSection.WebDavBackup in it }
+                .distinctUntilChanged()
+                .collect { visible ->
+                    if (visible) {
+                        refreshLatestBackup()
+                    } else {
+                        backupRefreshJob?.cancel()
+                        _backupState.update { it.copy(isLoading = false) }
+                    }
+                }
+        }
     }
     }
 
 
     fun onIntent(intent: HomeIntent) {
     fun onIntent(intent: HomeIntent) {
         when (intent) {
         when (intent) {
             HomeIntent.RecentBookClick -> openRecentBook()
             HomeIntent.RecentBookClick -> openRecentBook()
+            is HomeIntent.RecentHistoryBookClick -> openBook(intent.bookUrl)
+            is HomeIntent.SelectSourceSet -> selectSourceSet(intent.sourceUrl)
+            HomeIntent.DashboardSettingsClick -> {
+                _activeSheet.value = HomeSheet.DashboardSettings
+            }
+
+            is HomeIntent.SetSectionVisible -> {
+                setSectionVisible(intent.section, intent.visible)
+            }
             HomeIntent.ReadingGoalClick -> {
             HomeIntent.ReadingGoalClick -> {
                 _activeDialog.value = HomeDialog.SetReadingGoal(
                 _activeDialog.value = HomeDialog.SetReadingGoal(
                     uiState.value.dailyGoalMinutes
                     uiState.value.dailyGoalMinutes
@@ -159,6 +150,7 @@ class HomeViewModel(
                 _effects.tryEmit(HomeEffect.OpenBackupSettings)
                 _effects.tryEmit(HomeEffect.OpenBackupSettings)
             }
             }
 
 
+            HomeIntent.RetryBackupInfo -> refreshLatestBackup()
             HomeIntent.DismissDialog -> _activeDialog.value = null
             HomeIntent.DismissDialog -> _activeDialog.value = null
             HomeIntent.DismissSheet -> _activeSheet.value = null
             HomeIntent.DismissSheet -> _activeSheet.value = null
         }
         }
@@ -166,6 +158,10 @@ class HomeViewModel(
 
 
     private fun openRecentBook() {
     private fun openRecentBook() {
         val bookUrl = uiState.value.recentBook?.bookUrl ?: return
         val bookUrl = uiState.value.recentBook?.bookUrl ?: return
+        openBook(bookUrl)
+    }
+
+    private fun openBook(bookUrl: String) {
         viewModelScope.launch {
         viewModelScope.launch {
             bookRepository.getBook(bookUrl)?.let {
             bookRepository.getBook(bookUrl)?.let {
                 _effects.emit(HomeEffect.OpenBook(it))
                 _effects.emit(HomeEffect.OpenBook(it))
@@ -174,20 +170,42 @@ class HomeViewModel(
     }
     }
 
 
     private fun updateReadingGoal(minutes: Int) {
     private fun updateReadingGoal(minutes: Int) {
-        val validMinutes = minutes.coerceIn(1, MAX_DAILY_READING_GOAL_MINUTES)
         _activeDialog.value = null
         _activeDialog.value = null
         viewModelScope.launch {
         viewModelScope.launch {
-            localPreferencesRepository.updatePreference(
-                LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
-                validMinutes,
-            )
+            homeDashboardUseCase.updateDailyGoal(minutes)
+        }
+    }
+
+    private fun selectSourceSet(sourceUrl: String) {
+        viewModelScope.launch {
+            homeDashboardUseCase.updateSelectedSourceSetUrl(sourceUrl)
+        }
+    }
+
+    private fun setSectionVisible(
+        section: HomeDashboardSection,
+        visible: Boolean,
+    ) {
+        val sections = uiState.value.visibleSections.toMutableSet().apply {
+            if (visible) add(section) else remove(section)
+        }
+        viewModelScope.launch {
+            homeDashboardUseCase.updateVisibleSections(sections)
         }
         }
     }
     }
 
 
     private fun requestRestore() {
     private fun requestRestore() {
         val backup = _backupState.value.latest
         val backup = _backupState.value.latest
         if (backup == null) {
         if (backup == null) {
-            _effects.tryEmit(HomeEffect.ShowMessage(R.string.home_no_webdav_backup))
+            _effects.tryEmit(
+                HomeEffect.ShowMessage(
+                    if (_backupState.value.isLoadError) {
+                        R.string.home_webdav_backup_load_error
+                    } else {
+                        R.string.home_no_webdav_backup
+                    }
+                )
+            )
             return
             return
         }
         }
         _activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
         _activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
@@ -221,101 +239,130 @@ class HomeViewModel(
         path: String?,
         path: String?,
         savePath: Boolean = false,
         savePath: Boolean = false,
     ) {
     ) {
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         _backupState.update { it.copy(isActionRunning = true) }
         viewModelScope.launch(Dispatchers.IO) {
         viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                if (savePath) {
-                    BackupConfig.backupPath = path
-                }
-                if (destination != HomeBackupDestination.Local) {
-                    webDavBackupUseCase.refreshConfig()
-                }
-                backupRestoreUseCase.backup(path, destination.mode)
-            }.onSuccess {
-                _effects.emit(HomeEffect.ShowMessage(R.string.backup_success))
-                if (destination != HomeBackupDestination.Local) {
-                    loadLatestBackup()
-                }
-            }.onFailure { error ->
-                _effects.emit(
-                    HomeEffect.ShowMessage(
-                        messageRes = R.string.backup_error,
-                        detail = error.localizedMessage,
+            try {
+                runCatching {
+                    if (savePath) {
+                        BackupConfig.backupPath = path
+                    }
+                    if (destination != HomeBackupDestination.Local) {
+                        webDavBackupUseCase.refreshConfig()
+                    }
+                    backupRestoreUseCase.backup(path, destination.mode)
+                }.onSuccess {
+                    _effects.emit(HomeEffect.ShowMessage(R.string.backup_success))
+                    if (destination != HomeBackupDestination.Local) {
+                        refreshLatestBackup()
+                    }
+                }.onFailure { error ->
+                    if (error is CancellationException) throw error
+                    _effects.emit(
+                        HomeEffect.ShowMessage(
+                            messageRes = R.string.backup_error,
+                            detail = error.localizedMessage,
+                        )
                     )
                     )
-                )
+                }
+            } finally {
+                _backupState.update { it.copy(isActionRunning = false) }
+                backupActionMutex.unlock()
             }
             }
-            _backupState.update { it.copy(isActionRunning = false) }
         }
         }
     }
     }
 
 
     private fun restoreLocal(uri: String) {
     private fun restoreLocal(uri: String) {
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         _backupState.update { it.copy(isActionRunning = true) }
         viewModelScope.launch(Dispatchers.IO) {
         viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                backupRestoreUseCase.restoreLocal(uri)
-            }.onSuccess {
-                _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
-            }.onFailure { error ->
-                _effects.emit(
-                    HomeEffect.ShowMessage(
-                        messageRes = R.string.restore_error,
-                        detail = error.localizedMessage,
+            try {
+                runCatching {
+                    backupRestoreUseCase.restoreLocal(uri)
+                }.onSuccess {
+                    _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
+                }.onFailure { error ->
+                    if (error is CancellationException) throw error
+                    _effects.emit(
+                        HomeEffect.ShowMessage(
+                            messageRes = R.string.restore_error,
+                            detail = error.localizedMessage,
+                        )
                     )
                     )
-                )
+                }
+            } finally {
+                _backupState.update { it.copy(isActionRunning = false) }
+                backupActionMutex.unlock()
             }
             }
-            _backupState.update { it.copy(isActionRunning = false) }
         }
         }
     }
     }
 
 
     private fun restore() {
     private fun restore() {
         val backup = _backupState.value.latest ?: return
         val backup = _backupState.value.latest ?: return
         _activeDialog.value = null
         _activeDialog.value = null
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         _backupState.update { it.copy(isActionRunning = true) }
         viewModelScope.launch(Dispatchers.IO) {
         viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                webDavBackupUseCase.restore(backup.name)
-            }.onSuccess {
-                _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
-            }.onFailure { error ->
-                _effects.emit(
-                    HomeEffect.ShowMessage(
-                        messageRes = R.string.restore_error,
-                        detail = error.localizedMessage,
+            try {
+                runCatching {
+                    webDavBackupUseCase.restore(backup.name)
+                }.onSuccess {
+                    _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
+                }.onFailure { error ->
+                    if (error is CancellationException) throw error
+                    _effects.emit(
+                        HomeEffect.ShowMessage(
+                            messageRes = R.string.restore_error,
+                            detail = error.localizedMessage,
+                        )
                     )
                     )
-                )
+                }
+            } finally {
+                _backupState.update { it.copy(isActionRunning = false) }
+                backupActionMutex.unlock()
             }
             }
-            _backupState.update { it.copy(isActionRunning = false) }
         }
         }
     }
     }
 
 
     private fun refreshLatestBackup() {
     private fun refreshLatestBackup() {
-        viewModelScope.launch(Dispatchers.IO) {
+        backupRefreshJob?.cancel()
+        backupRefreshJob = viewModelScope.launch(Dispatchers.IO) {
             loadLatestBackup()
             loadLatestBackup()
         }
         }
     }
     }
 
 
     private suspend fun loadLatestBackup() {
     private suspend fun loadLatestBackup() {
-        _backupState.update { it.copy(isLoading = true) }
-        val latest = runCatching {
-            webDavBackupUseCase.getLatestBackup()
-        }.getOrNull()
-        _backupState.update { it.copy(latest = latest, isLoading = false) }
+        _backupState.update {
+            it.copy(
+                isLoading = true,
+                isLoadError = false,
+            )
+        }
+        try {
+            val latest = webDavBackupUseCase.getLatestBackup()
+            _backupState.update {
+                it.copy(
+                    latest = latest,
+                    isLoading = false,
+                    isLoadError = false,
+                )
+            }
+        } catch (error: CancellationException) {
+            throw error
+        } catch (_: Exception) {
+            _backupState.update {
+                it.copy(
+                    isLoading = false,
+                    isLoadError = true,
+                )
+            }
+        }
     }
     }
 
 
-    private data class HomeDashboardData(
-        val totalReadBooks: Int,
-        val totalReadTimeMillis: Long,
-        val todayReadTimeMillis: Long,
-        val dailyGoalMinutes: Int,
-        val recentBook: HomeRecentBookUi?,
-    )
-
     private data class HomeBackupState(
     private data class HomeBackupState(
         val latest: WebDavBackup? = null,
         val latest: WebDavBackup? = null,
         val isLoading: Boolean = true,
         val isLoading: Boolean = true,
+        val isLoadError: Boolean = false,
         val isActionRunning: Boolean = false,
         val isActionRunning: Boolean = false,
     )
     )
 
 
@@ -323,4 +370,14 @@ class HomeViewModel(
         name = name,
         name = name,
         lastModify = lastModify,
         lastModify = lastModify,
     )
     )
+
+    private fun HomeReadingBook.toUi() = HomeRecentBookUi(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        origin = origin,
+        coverPath = coverPath,
+        chapterTitle = chapterTitle,
+        chapterProgress = chapterProgress,
+    )
 }
 }

+ 1 - 10
app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt

@@ -1,7 +1,5 @@
 package io.legado.app.ui.main.homepage
 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
 import io.legado.app.ui.config.prefStateDelegate
 
 
 /**
 /**
@@ -10,17 +8,10 @@ import io.legado.app.ui.config.prefStateDelegate
  */
  */
 object HomepageConfig {
 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", "")
     private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "")
     var homepageSourceHidden by _homepageSourceHidden
     var homepageSourceHidden by _homepageSourceHidden
 
 
-}
+}

+ 10 - 1
app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt

@@ -19,7 +19,6 @@ data class HomepageBookItemUi(
 data class HomepageUiState(
 data class HomepageUiState(
     val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
     val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
     val isManageMode: Boolean = false,
     val isManageMode: Boolean = false,
-    val isConfigMode: Boolean = false,
     val isRefreshing: Boolean = false,
     val isRefreshing: Boolean = false,
     val manageState: HomepageManageUiState = HomepageManageUiState()
     val manageState: HomepageManageUiState = HomepageManageUiState()
 )
 )
@@ -53,6 +52,16 @@ data class HomepageManageActions(
     val onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> },
     val onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> },
 )
 )
 
 
+@Stable
+data class HomepageFeedActions(
+    val onModuleHeaderClick: (String, String?, String?) -> Unit,
+    val onRetryModule: (String) -> Unit,
+    val onLoadMoreModule: (String) -> Unit,
+    val onBookClick: (SearchBook, String?) -> Unit,
+    val onKindUrlClick: (String, String, String) -> Unit,
+    val onRefreshButtonGroup: (String) -> Unit,
+)
+
 @Stable
 @Stable
 data class HomepageSourceManageUi(
 data class HomepageSourceManageUi(
     val sourceUrl: String,
     val sourceUrl: String,

+ 0 - 57
app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt

@@ -1,57 +0,0 @@
-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.res.stringResource
-import androidx.compose.ui.unit.dp
-import io.legado.app.R
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
-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 = stringResource(R.string.homepage_layout_settings),
-    ) {
-        Column {
-            CompactDropdownSettingItem(
-                title = stringResource(R.string.homepage_layout_mode),
-                selectedValue = layoutMode.toString(),
-                displayEntries = arrayOf(
-                    stringResource(R.string.homepage_layout_mixed),
-                    stringResource(R.string.homepage_layout_tabs)
-                ),
-                entryValues = arrayOf("0", "1"),
-                onValueChange = { onLayoutModeChange(it.toInt()) }
-            )
-
-            Spacer(modifier = Modifier.height(16.dp))
-        }
-    }
-}

+ 493 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt

@@ -0,0 +1,493 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+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.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.itemsIndexed
+import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.HomepageModuleType
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+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.HomepageModuleSkeleton
+import io.legado.app.ui.main.homepage.modules.RankingModule
+import io.legado.app.ui.main.homepage.modules.WaterfallItem
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.LoadMoreFooter
+import io.legado.app.ui.widget.components.book.SearchBookGridItem
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+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
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun HomepageModuleFeed(
+    modules: List<HomepageModuleUi>,
+    actions: HomepageFeedActions,
+    modifier: Modifier = Modifier,
+    paddingValues: PaddingValues = PaddingValues(0.dp),
+    gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
+    verticalItemSpacing: Dp = 16.dp,
+    @StringRes emptyMessageRes: Int = R.string.homepage_add_module_definition,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
+    onErrorClick: (String) -> Unit
+) {
+    if (modules.isEmpty()) {
+        Box(modifier = modifier, contentAlignment = Alignment.Center) {
+            AppText(stringResource(emptyMessageRes))
+        }
+    } else {
+        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 = verticalItemSpacing,
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) {
+                    120.dp
+                } else {
+                    8.dp
+                },
+            ),
+        ) {
+            processedModules.forEach { moduleUi ->
+                item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
+                    ModuleHeader(
+                        title = moduleUi.title,
+                        onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) {
+                            null
+                        } else {
+                            {
+                                actions.onModuleHeaderClick(
+                                    moduleUi.sourceUrl,
+                                    moduleUi.exploreUrl,
+                                    moduleUi.title,
+                                )
+                            }
+                        },
+                    )
+                }
+
+                when (val state = moduleUi.state) {
+                    is ModuleLoadState.Loading,
+                    is ModuleLoadState.Error -> {
+                        item(
+                            key = "status_${moduleUi.globalId}",
+                            span = StaggeredGridItemSpan.FullLine
+                        ) {
+                            AnimatedContent(
+                                targetState = state,
+                                transitionSpec = {
+                                    fadeIn(tween(300)) togetherWith fadeOut(tween(300))
+                                },
+                                contentKey = { it::class },
+                            ) { targetState ->
+                                when (targetState) {
+                                    is ModuleLoadState.Loading -> {
+                                        HomepageModuleSkeleton(
+                                            type = moduleUi.type,
+                                            modifier = Modifier.fillMaxWidth(),
+                                        )
+                                    }
+
+                                    is ModuleLoadState.Error -> {
+                                        Column(
+                                            modifier = Modifier.fillMaxWidth(),
+                                            horizontalAlignment = Alignment.CenterHorizontally
+                                        ) {
+                                            GlassCard(
+                                                onClick = { onErrorClick(targetState.message) },
+                                                containerColor = LegadoTheme.colorScheme.errorContainer.copy(
+                                                    alpha = 0.6f
+                                                ),
+                                            ) {
+                                                Column(
+                                                    modifier = Modifier.fillMaxWidth()
+                                                ) {
+                                                    Row(
+                                                        modifier = Modifier
+                                                            .fillMaxWidth()
+                                                            .padding(
+                                                                horizontal = 16.dp,
+                                                                vertical = 16.dp
+                                                            ),
+                                                        verticalAlignment = Alignment.CenterVertically,
+                                                        horizontalArrangement = Arrangement.spacedBy(
+                                                            12.dp
+                                                        )
+                                                    ) {
+                                                        AppIcon(
+                                                            imageVector = Icons.Outlined.Info,
+                                                            contentDescription = null,
+                                                            tint = LegadoTheme.colorScheme.error
+                                                        )
+
+                                                        AppText(
+                                                            text = targetState.message,
+                                                            color = LegadoTheme.colorScheme.error,
+                                                            style = LegadoTheme.typography.bodySmall,
+                                                            modifier = Modifier.weight(1f),
+                                                            maxLines = 2,
+                                                            overflow = TextOverflow.Ellipsis,
+                                                        )
+                                                    }
+
+                                                    HorizontalDivider(
+                                                        color = LegadoTheme.colorScheme.error.copy(
+                                                            alpha = 0.3f
+                                                        )
+                                                    )
+
+                                                    Box(
+                                                        modifier = Modifier
+                                                            .fillMaxWidth()
+                                                            .clickable {
+                                                                actions.onRetryModule(moduleUi.globalId)
+                                                            }
+                                                            .padding(vertical = 10.dp),
+                                                        contentAlignment = Alignment.Center
+                                                    ) {
+                                                        Row(
+                                                            horizontalArrangement = Arrangement.spacedBy(
+                                                                8.dp
+                                                            ),
+                                                            verticalAlignment = Alignment.CenterVertically
+                                                        ) {
+                                                            AppIcon(
+                                                                imageVector = Icons.Default.Refresh,
+                                                                contentDescription = null,
+                                                                tint = LegadoTheme.colorScheme.error
+                                                            )
+
+                                                            AppText(
+                                                                text = stringResource(R.string.retry),
+                                                                color = LegadoTheme.colorScheme.error,
+                                                                style = LegadoTheme.typography.labelMedium
+                                                            )
+                                                        }
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+
+                                    else -> {}
+                                }
+                            }
+                        }
+                    }
+
+                    is ModuleLoadState.Buttons -> {
+                        item(
+                            key = "buttons_${moduleUi.globalId}",
+                            span = StaggeredGridItemSpan.FullLine
+                        ) {
+                            ButtonGroupModule(
+                                kinds = state.kinds,
+                                sourceUrl = moduleUi.sourceUrl,
+                                globalId = moduleUi.globalId,
+                                onOpenKind = actions.onKindUrlClick,
+                                onRefreshKinds = actions.onRefreshButtonGroup,
+                                modifier = Modifier.fillMaxWidth(),
+                                layoutConfig = moduleUi.layoutConfig
+                            )
+                        }
+                    }
+
+                    is ModuleLoadState.Loaded -> {
+                        val config = moduleUi.config
+                        when (moduleUi.type) {
+                            HomepageModuleType.Waterfall -> {
+                                itemsIndexed(
+                                    state.books,
+                                    key = { index, item ->
+                                        "wf_${moduleUi.globalId}_${item.book.bookUrl}_$index"
+                                    },
+                                ) { index, item ->
+                                    val sharedCoverKey = bookCoverSharedElementKey(
+                                        item.book.bookUrl,
+                                        "home:${moduleUi.globalId}:waterfall:$index"
+                                    )
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        WaterfallItem(
+                                            item = item,
+                                            onClick = {
+                                                actions.onBookClick(
+                                                    item.book,
+                                                    sharedCoverKey
+                                                )
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKey = sharedCoverKey,
+                                        )
+                                    }
+                                }
+
+                                item(
+                                    key = "wf_more_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    LoadMoreFooter(
+                                        isLoading = state.isLoadingMore,
+                                        errorMsg = null,
+                                        isEnd = !state.hasMore,
+                                        onRetry = { actions.onLoadMoreModule(moduleUi.globalId) }
+                                    )
+                                }
+                            }
+
+                            HomepageModuleType.InfiniteGrid -> {
+                                itemsIndexed(
+                                    state.books,
+                                    key = { index, item ->
+                                        "inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index"
+                                    },
+                                ) { index, item ->
+                                    val sharedCoverKey = bookCoverSharedElementKey(
+                                        item.book.bookUrl,
+                                        "home:${moduleUi.globalId}:infinite:$index"
+                                    )
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        SearchBookGridItem(
+                                            book = item.book,
+                                            shelfState = item.shelfState,
+                                            onClick = {
+                                                actions.onBookClick(
+                                                    item.book,
+                                                    sharedCoverKey
+                                                )
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKey = sharedCoverKey
+                                        )
+                                    }
+                                }
+
+                                item(
+                                    key = "inf_grid_more_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    LoadMoreFooter(
+                                        isLoading = state.isLoadingMore,
+                                        errorMsg = null,
+                                        isEnd = !state.hasMore,
+                                        onRetry = { actions.onLoadMoreModule(moduleUi.globalId) }
+                                    )
+                                }
+                            }
+
+                            HomepageModuleType.Grid -> {
+                                val rows = config["layout_rows"]?.toIntOrNull() ?: 2
+                                val columns = config["layout_columns"]?.toIntOrNull() ?: 3
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        GridModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                actions.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            columns = columns,
+                                            maxRows = rows,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
+                                        )
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.Banner -> {
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        BannerModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                actions.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
+                                        )
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.Ranking -> {
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        RankingModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                actions.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
+                                        )
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.GridRanking -> {
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        GridRankingModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                actions.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            rows = config["layout_rows"]?.toIntOrNull() ?: 4,
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
+                                        )
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.Card -> {
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
+                                        CardModule(
+                                            books = state.books,
+                                            onClick = { book, sharedCoverKey ->
+                                                actions.onBookClick(book, sharedCoverKey)
+                                            },
+                                            onLongClick = onBookLongClick,
+                                            modifier = Modifier.fillMaxWidth(),
+                                            sharedTransitionScope = sharedTransitionScope,
+                                            animatedVisibilityScope = animatedVisibilityScope,
+                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
+                                        )
+                                    }
+                                }
+                            }
+
+                            else -> {}
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ModuleHeader(
+    title: String,
+    onNavigate: (() -> Unit)? = null,
+) {
+    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),
+        )
+        if (onNavigate != null) {
+            SmallTonalButton(
+                onClick = onNavigate,
+                icon = Icons.AutoMirrored.Filled.ArrowForward
+            )
+        }
+    }
+}

+ 0 - 795
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -1,795 +0,0 @@
-package io.legado.app.ui.main.homepage
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.AnimatedVisibilityScope
-import androidx.compose.animation.ExperimentalSharedTransitionApi
-import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.togetherWith
-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.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-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.itemsIndexed
-import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.foundation.verticalScroll
-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.Explore
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material.icons.outlined.Info
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-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.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import io.legado.app.R
-import io.legado.app.data.entities.SearchBook
-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.HomepageModuleSkeleton
-import io.legado.app.ui.main.homepage.modules.RankingModule
-import io.legado.app.ui.main.homepage.modules.WaterfallItem
-import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.adaptiveContentPadding
-import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf
-import io.legado.app.ui.widget.components.AppPullToRefresh
-import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.LoadMoreFooter
-import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.book.SearchBookGridItem
-import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
-import io.legado.app.ui.widget.components.button.series.SmallTonalButton
-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.tabRow.AppTabRow
-import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.icon.AppIcons
-import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
-import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
-import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
-import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.ui.widget.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.flow.collectLatest
-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, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
-    onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
-    onSwitchToDiscovery: () -> Unit,
-    sharedTransitionScope: SharedTransitionScope? = null,
-    animatedVisibilityScope: AnimatedVisibilityScope? = null,
-) {
-    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
-    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
-    // Removed allSets and browseSources as they are now part of uiState.manageState
-    val scope = rememberCoroutineScope()
-    val context = LocalContext.current
-    var errorMsg by remember { mutableStateOf<String?>(null) }
-    var showPageMenu by remember { mutableStateOf(false) }
-
-    val layoutMode = HomepageConfig.homepageLayoutModeState.value
-
-    val selectedSets = remember(uiState.manageState.sets) {
-        uiState.manageState.sets.filter { it.isSelected }
-    }
-    val pagerState = rememberPagerState(pageCount = {
-        selectedSets.size.coerceAtLeast(1)
-    })
-
-    val discoveryString = stringResource(R.string.discovery)
-    val currentTitle by remember(layoutMode, selectedSets) {
-        derivedStateOf {
-            if (layoutMode == 1) {
-                discoveryString
-            } else {
-                selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: discoveryString
-            }
-        }
-    }
-
-    BackHandler(enabled = uiState.isManageMode || uiState.isConfigMode) {
-        if (uiState.isManageMode) viewModel.toggleManageMode()
-        else viewModel.toggleConfigMode()
-    }
-
-    LaunchedEffect(viewModel) {
-        viewModel.effects.collectLatest { effect ->
-            when (effect) {
-                is HomepageEffect.NavigateToBookInfo ->
-                    onBookClick(
-                        effect.name,
-                        effect.author,
-                        effect.bookUrl,
-                        effect.origin,
-                        effect.coverPath,
-                        effect.sharedCoverKey
-                    )
-
-                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.toggleManageMode() },
-                        imageVector = AppIcons.Settings,
-                        contentDescription = "Manage Modules",
-                    )
-                    Box {
-                        TopBarActionButton(
-                            onClick = { showPageMenu = true },
-                            imageVector = Icons.Default.MoreVert,
-                            contentDescription = "更多",
-                        )
-                        RoundDropdownMenu(
-                            expanded = showPageMenu,
-                            onDismissRequest = { showPageMenu = false },
-                        ) {
-                            RoundDropdownMenuItem(
-                                leadingIcon = {
-                                    MenuItemIcon(Icons.Default.GridView)
-                                },
-                                text = stringResource(R.string.homepage_layout_settings),
-                                onClick = {
-                                    showPageMenu = false
-                                    viewModel.toggleConfigMode()
-                                },
-                            )
-                            RoundDropdownMenuItem(
-                                leadingIcon = {
-                                    MenuItemIcon(Icons.Default.Explore)
-                                },
-                                text = stringResource(R.string.switch_to_classic_discovery),
-                                onClick = {
-                                    showPageMenu = false
-                                    onSwitchToDiscovery()
-                                },
-                            )
-                        }
-                    }
-                },
-                bottomContent = {
-                    if (layoutMode == 1 && selectedSets.isNotEmpty()) {
-                        AppTabRow(
-                            tabTitles = selectedSets.map { it.sourceName },
-                            selectedTabIndex = pagerState.currentPage.coerceIn(
-                                0,
-                                selectedSets.size - 1
-                            ),
-                            onTabSelected = { index ->
-                                scope.launch { pagerState.animateScrollToPage(index) }
-                            }
-                        )
-                    }
-                }
-            )
-        },
-    ) { paddingValues ->
-        AppPullToRefresh(
-            isRefreshing = uiState.isRefreshing,
-            onRefresh = { viewModel.onRefresh() },
-            modifier = Modifier.fillMaxSize(),
-        ) {
-            if (selectedSets.isEmpty()) {
-                EmptyMessage(
-                    messageResId = R.string.homepage_no_source_sets_selected,
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .padding(paddingValues),
-                )
-            } 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(),
-                        paddingValues = paddingValues,
-                        onErrorClick = { errorMsg = it },
-                        sharedTransitionScope = sharedTransitionScope,
-                        animatedVisibilityScope = animatedVisibilityScope,
-                        onBookLongClick = { book, sharedCoverKey ->
-                            previewBook = book
-                            previewSharedCoverKey = sharedCoverKey
-                        },
-                    )
-                }
-            }
-        }
-
-        AppAlertDialog(
-            data = errorMsg,
-            onDismissRequest = { errorMsg = null },
-            title = stringResource(R.string.homepage_module_error),
-            confirmText = stringResource(R.string.copy_text),
-            onConfirm = {
-                context.sendToClip(it)
-                errorMsg = null
-            },
-            dismissText = stringResource(R.string.close),
-            onDismiss = { errorMsg = null },
-            content = { msg ->
-                SelectionContainer {
-                    AppText(
-                        text = msg,
-                        style = LegadoTheme.typography.bodyMedium,
-                        modifier = Modifier
-                            .heightIn(max = 400.dp)
-                            .verticalScroll(rememberScrollState())
-                    )
-                }
-            }
-        )
-
-        HomepageModuleManageSheet(
-            data = if (uiState.isManageMode) Unit else null,
-            onDismissRequest = { viewModel.toggleManageMode() },
-            state = uiState.manageState,
-            actions = remember {
-                HomepageManageActions(
-                    onToggleSet = { url, isEnabled ->
-                        viewModel.toggleSourceFilter(
-                            url,
-                            isEnabled
-                        )
-                    },
-                    onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
-                    onSyncSourceModules = { viewModel.syncSourceModules(it) },
-                    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) },
-                    onAssignModuleToCustomSet = { id, setId ->
-                        viewModel.assignModuleToCustomSet(id, setId)
-                    }
-                )
-            }
-        )
-
-        HomepageLayoutSheet(
-            data = if (uiState.isConfigMode) Unit else null,
-            onDismissRequest = { viewModel.toggleConfigMode() },
-            layoutMode = layoutMode,
-            onLayoutModeChange = { viewModel.setLayoutMode(it) },
-        )
-
-        SearchBookPreviewSheet(
-            data = previewBook,
-            shelfState = previewBook?.let { viewModel.getCurrentBookShelfState(it) },
-            sharedCoverKey = previewSharedCoverKey,
-            onDismissRequest = { previewBook = null },
-            onOpenDetail = { book, sharedCoverKey ->
-                previewBook = null
-                onBookClick(
-                    book.name, book.author, book.bookUrl,
-                    book.origin, book.coverUrl, sharedCoverKey
-                )
-            },
-            onAddToShelf = { book ->
-                viewModel.onAddToShelf(book)
-            },
-        )
-    }
-}
-
-@OptIn(ExperimentalSharedTransitionApi::class)
-@Composable
-private fun ModuleList(
-    modules: List<HomepageModuleUi>,
-    viewModel: HomepageViewModel,
-    modifier: Modifier = Modifier,
-    paddingValues: PaddingValues = PaddingValues(0.dp),
-    gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
-    sharedTransitionScope: SharedTransitionScope? = null,
-    animatedVisibilityScope: AnimatedVisibilityScope? = null,
-    onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
-    onErrorClick: (String) -> Unit
-) {
-    if (modules.isEmpty()) {
-        Box(modifier = modifier, contentAlignment = Alignment.Center) {
-            AppText(stringResource(R.string.homepage_add_module_definition))
-        }
-    } 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 = adaptiveContentPadding(
-                top = paddingValues.calculateTopPadding(),
-                bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp
-            ),
-        ) {
-            processedModules.forEach { moduleUi ->
-                // 1. 头部 (全宽)
-                item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
-                    ModuleHeader(
-                        title = moduleUi.title,
-                        onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else {
-                            {
-                                viewModel.onModuleHeaderClick(
-                                    moduleUi.sourceUrl,
-                                    moduleUi.exploreUrl,
-                                    moduleUi.title,
-                                )
-                            }
-                        },
-                    )
-                }
-
-                // 2. 内容正文
-                when (val state = moduleUi.state) {
-                    is ModuleLoadState.Loading,
-                    is ModuleLoadState.Error -> {
-                        item(
-                            key = "status_${moduleUi.globalId}",
-                            span = StaggeredGridItemSpan.FullLine
-                        ) {
-                            AnimatedContent(
-                                targetState = state,
-                                transitionSpec = {
-                                    fadeIn(tween(300)) togetherWith fadeOut(tween(300))
-                                },
-                                contentKey = { it::class },
-                            ) { targetState ->
-                                when (targetState) {
-                                    is ModuleLoadState.Loading -> {
-                                        HomepageModuleSkeleton(
-                                            type = moduleUi.type,
-                                            modifier = Modifier.fillMaxWidth(),
-                                        )
-                                    }
-
-                                    is ModuleLoadState.Error -> {
-                                        Column(
-                                            modifier = Modifier.fillMaxWidth(),
-                                            horizontalAlignment = Alignment.CenterHorizontally
-                                        ) {
-                                            GlassCard(
-                                                onClick = { onErrorClick(targetState.message) },
-                                                containerColor = LegadoTheme.colorScheme.errorContainer.copy(
-                                                    alpha = 0.6f
-                                                ),
-                                            ) {
-                                                Column(
-                                                    modifier = Modifier.fillMaxWidth()
-                                                ) {
-                                                    Row(
-                                                        modifier = Modifier
-                                                            .fillMaxWidth()
-                                                            .padding(
-                                                                horizontal = 16.dp,
-                                                                vertical = 16.dp
-                                                            ),
-                                                        verticalAlignment = Alignment.CenterVertically,
-                                                        horizontalArrangement = Arrangement.spacedBy(12.dp)
-                                                    ) {
-                                                        AppIcon(
-                                                            imageVector = Icons.Outlined.Info,
-                                                            contentDescription = null,
-                                                            tint = LegadoTheme.colorScheme.error
-                                                        )
-
-                                                        AppText(
-                                                            text = targetState.message,
-                                                            color = LegadoTheme.colorScheme.error,
-                                                            style = LegadoTheme.typography.bodySmall,
-                                                            modifier = Modifier.weight(1f),
-                                                            maxLines = 2,
-                                                            overflow = TextOverflow.Ellipsis,
-                                                        )
-                                                    }
-
-                                                    HorizontalDivider(
-                                                        color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
-                                                    )
-
-                                                    Box(
-                                                        modifier = Modifier
-                                                            .fillMaxWidth()
-                                                            .clickable {
-                                                                viewModel.retryModule(moduleUi.globalId)
-                                                            }
-                                                            .padding(vertical = 10.dp),
-                                                        contentAlignment = Alignment.Center
-                                                    ) {
-                                                        Row(
-                                                            horizontalArrangement = Arrangement.spacedBy(8.dp),
-                                                            verticalAlignment = Alignment.CenterVertically
-                                                        ) {
-                                                            AppIcon(
-                                                                imageVector = Icons.Default.Refresh,
-                                                                contentDescription = null,
-                                                                tint = LegadoTheme.colorScheme.error
-                                                            )
-
-                                                            AppText(
-                                                                text = stringResource(R.string.retry),
-                                                                color = LegadoTheme.colorScheme.error,
-                                                                style = LegadoTheme.typography.labelMedium
-                                                            )
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-
-                                    else -> {}
-                                }
-                            }
-                        }
-                    }
-
-                    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 -> {
-                                itemsIndexed(
-                                    state.books,
-                                    key = { index, item -> "wf_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
-                                    val sharedCoverKey = bookCoverSharedElementKey(
-                                        item.book.bookUrl,
-                                        "home:${moduleUi.globalId}:waterfall:$index"
-                                    )
-                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                        WaterfallItem(
-                                            item = item,
-                                            onClick = {
-                                                viewModel.onBookClick(
-                                                    item.book,
-                                                    sharedCoverKey
-                                                )
-                                            },
-                                            onLongClick = onBookLongClick,
-                                            sharedTransitionScope = sharedTransitionScope,
-                                            animatedVisibilityScope = animatedVisibilityScope,
-                                            sharedCoverKey = sharedCoverKey,
-                                        )
-                                    }
-                                }
-
-                                item(
-                                    key = "wf_more_${moduleUi.globalId}",
-                                    span = StaggeredGridItemSpan.FullLine
-                                ) {
-                                    LoadMoreFooter(
-                                        isLoading = state.isLoadingMore,
-                                        errorMsg = null,
-                                        isEnd = !state.hasMore,
-                                        onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
-                                    )
-                                }
-                            }
-
-                            HomepageModuleType.InfiniteGrid -> {
-                                itemsIndexed(
-                                    state.books,
-                                    key = { index, item -> "inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
-                                    val sharedCoverKey = bookCoverSharedElementKey(
-                                        item.book.bookUrl,
-                                        "home:${moduleUi.globalId}:infinite:$index"
-                                    )
-                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                        SearchBookGridItem(
-                                            book = item.book,
-                                            shelfState = item.shelfState,
-                                            onClick = {
-                                                viewModel.onBookClick(
-                                                    item.book,
-                                                    sharedCoverKey
-                                                )
-                                            },
-                                            onLongClick = onBookLongClick,
-                                            sharedTransitionScope = sharedTransitionScope,
-                                            animatedVisibilityScope = animatedVisibilityScope,
-                                            sharedCoverKey = sharedCoverKey
-                                        )
-                                    }
-                                }
-
-                                item(
-                                    key = "inf_grid_more_${moduleUi.globalId}",
-                                    span = StaggeredGridItemSpan.FullLine
-                                ) {
-                                    LoadMoreFooter(
-                                        isLoading = state.isLoadingMore,
-                                        errorMsg = null,
-                                        isEnd = !state.hasMore,
-                                        onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
-                                    )
-                                }
-                            }
-
-                            HomepageModuleType.Grid -> {
-                                val rows = config["layout_rows"]?.toIntOrNull() ?: 2
-                                val columns = config["layout_columns"]?.toIntOrNull() ?: 3
-                                item(
-                                    key = "content_${moduleUi.globalId}",
-                                    span = StaggeredGridItemSpan.FullLine
-                                ) {
-                                    AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                        GridModule(
-                                            books = state.books,
-                                            onClick = { book, sharedCoverKey ->
-                                                viewModel.onBookClick(book, sharedCoverKey)
-                                            },
-                                            onLongClick = onBookLongClick,
-                                            modifier = Modifier.fillMaxWidth(),
-                                            columns = columns,
-                                            maxRows = rows,
-                                            sharedTransitionScope = sharedTransitionScope,
-                                            animatedVisibilityScope = animatedVisibilityScope,
-                                            sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
-                                        )
-                                    }
-                                }
-                            }
-
-                            else -> {
-                                when (moduleUi.type) {
-                                    HomepageModuleType.Banner -> {
-                                        item(
-                                            key = "content_${moduleUi.globalId}",
-                                            span = StaggeredGridItemSpan.FullLine
-                                        ) {
-                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                                BannerModule(
-                                                    books = state.books,
-                                                    onClick = { book, sharedCoverKey ->
-                                                        viewModel.onBookClick(book, sharedCoverKey)
-                                                    },
-                                                    onLongClick = onBookLongClick,
-                                                    modifier = Modifier.fillMaxWidth(),
-                                                    sharedTransitionScope = sharedTransitionScope,
-                                                    animatedVisibilityScope = animatedVisibilityScope,
-                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
-                                                )
-                                            }
-                                        }
-                                    }
-
-                                    HomepageModuleType.Ranking -> {
-                                        item(
-                                            key = "content_${moduleUi.globalId}",
-                                            span = StaggeredGridItemSpan.FullLine
-                                        ) {
-                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                                RankingModule(
-                                                    books = state.books,
-                                                    onClick = { book, sharedCoverKey ->
-                                                        viewModel.onBookClick(book, sharedCoverKey)
-                                                    },
-                                                    onLongClick = onBookLongClick,
-                                                    modifier = Modifier.fillMaxWidth(),
-                                                    sharedTransitionScope = sharedTransitionScope,
-                                                    animatedVisibilityScope = animatedVisibilityScope,
-                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
-                                                )
-                                            }
-                                        }
-                                    }
-
-                                    HomepageModuleType.GridRanking -> {
-                                        item(
-                                            key = "content_${moduleUi.globalId}",
-                                            span = StaggeredGridItemSpan.FullLine
-                                        ) {
-                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                                GridRankingModule(
-                                                    books = state.books,
-                                                    onClick = { book, sharedCoverKey ->
-                                                        viewModel.onBookClick(book, sharedCoverKey)
-                                                    },
-                                                    onLongClick = onBookLongClick,
-                                                    modifier = Modifier.fillMaxWidth(),
-                                                    rows = config["layout_rows"]?.toIntOrNull() ?: 4,
-                                                    sharedTransitionScope = sharedTransitionScope,
-                                                    animatedVisibilityScope = animatedVisibilityScope,
-                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
-                                                )
-                                            }
-                                        }
-                                    }
-
-                                    HomepageModuleType.Card -> {
-                                        item(
-                                            key = "content_${moduleUi.globalId}",
-                                            span = StaggeredGridItemSpan.FullLine
-                                        ) {
-                                            AnimatedVisibility(visible = true, enter = fadeIn()) {
-                                                CardModule(
-                                                    books = state.books,
-                                                    onClick = { book, sharedCoverKey ->
-                                                        viewModel.onBookClick(book, sharedCoverKey)
-                                                    },
-                                                    onLongClick = onBookLongClick,
-                                                    modifier = Modifier.fillMaxWidth(),
-                                                    sharedTransitionScope = sharedTransitionScope,
-                                                    animatedVisibilityScope = animatedVisibilityScope,
-                                                    sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
-                                                )
-                                            }
-                                        }
-                                    }
-
-                                    else -> {}
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
-@Composable
-private fun ModuleHeader(
-    title: String,
-    onNavigate: (() -> Unit)? = null,
-) {
-    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),
-        )
-        if (onNavigate != null) {
-            SmallTonalButton(
-                onClick = onNavigate,
-                icon = Icons.AutoMirrored.Filled.ArrowForward
-            )
-        }
-    }
-}

+ 0 - 92
app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt

@@ -1,92 +0,0 @@
-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 = stringResource(R.string.homepage_filter_sources),
-    ) {
-        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))
-        }
-    }
-}

+ 9 - 12
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -97,7 +97,6 @@ class HomepageViewModel(
     // 1. 基础原始状态
     // 1. 基础原始状态
     private val _isRefreshing = MutableStateFlow(false)
     private val _isRefreshing = MutableStateFlow(false)
     private val _isManageMode = MutableStateFlow(false)
     private val _isManageMode = MutableStateFlow(false)
-    private val _isConfigMode = MutableStateFlow(false)
     private val _configVersion = MutableStateFlow(0L)
     private val _configVersion = MutableStateFlow(0L)
     private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
     private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
     private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
     private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
@@ -151,8 +150,8 @@ class HomepageViewModel(
 
 
     // 4. 聚合层
     // 4. 聚合层
     private val uiFlagsFlow =
     private val uiFlagsFlow =
-        combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config ->
-            HomepageUiFlags(refreshing, manage, config)
+        combine(_isRefreshing, _isManageMode) { refreshing, manage ->
+            HomepageUiFlags(refreshing, manage)
         }
         }
 
 
     private val manageStateFlow = combine(
     private val manageStateFlow = combine(
@@ -275,7 +274,6 @@ class HomepageViewModel(
             modules = modules,
             modules = modules,
             isRefreshing = flags.isRefreshing,
             isRefreshing = flags.isRefreshing,
             isManageMode = flags.isManageMode,
             isManageMode = flags.isManageMode,
-            isConfigMode = flags.isConfigMode,
             manageState = manageState
             manageState = manageState
         )
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
@@ -550,7 +548,6 @@ class HomepageViewModel(
     }
     }
 
 
     fun toggleManageMode() = _isManageMode.update { !it }
     fun toggleManageMode() = _isManageMode.update { !it }
-    fun toggleConfigMode() = _isConfigMode.update { !it }
 
 
     fun setModuleVisible(id: String, visible: Boolean) {
     fun setModuleVisible(id: String, visible: Boolean) {
         _pendingEnabled.update { it + (id to visible) }
         _pendingEnabled.update { it + (id to visible) }
@@ -579,11 +576,6 @@ class HomepageViewModel(
         notifyConfigChanged()
         notifyConfigChanged()
     }
     }
 
 
-    fun setLayoutMode(mode: Int) {
-        HomepageConfig.homepageLayoutMode = mode
-        notifyConfigChanged()
-    }
-
     private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
     private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
         val setId = "src_$sourceUrl"
         val setId = "src_$sourceUrl"
         if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet(
         if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet(
@@ -875,6 +867,12 @@ class HomepageViewModel(
         }
         }
     }
     }
 
 
+    fun saveSearchBook(book: SearchBook) {
+        viewModelScope.launch {
+            saveSearchBooksUseCase.save(book)
+        }
+    }
+
     fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
     fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
         viewModelScope.launch {
         viewModelScope.launch {
             saveSearchBooksUseCase.save(book)
             saveSearchBooksUseCase.save(book)
@@ -940,6 +938,5 @@ class HomepageViewModel(
 
 
 private data class HomepageUiFlags(
 private data class HomepageUiFlags(
     val isRefreshing: Boolean,
     val isRefreshing: Boolean,
-    val isManageMode: Boolean,
-    val isConfigMode: Boolean
+    val isManageMode: Boolean
 )
 )

+ 4 - 4
app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt

@@ -22,7 +22,6 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
-import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -37,7 +36,8 @@ fun ButtonGroupModule(
     kinds: List<ExploreKind>,
     kinds: List<ExploreKind>,
     sourceUrl: String,
     sourceUrl: String,
     globalId: String,
     globalId: String,
-    viewModel: HomepageViewModel,
+    onOpenKind: (sourceUrl: String, url: String, title: String) -> Unit,
+    onRefreshKinds: (globalId: String) -> Unit,
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
     icon: String? = null,
     icon: String? = null,
     layoutConfig: String? = null,
     layoutConfig: String? = null,
@@ -91,10 +91,10 @@ fun ButtonGroupModule(
                         sourceUrl = sourceUrl,
                         sourceUrl = sourceUrl,
                         activity = activity,
                         activity = activity,
                         onOpenUrl = { url ->
                         onOpenUrl = { url ->
-                            viewModel.onKindUrlClick(sourceUrl, url, kind.title)
+                            onOpenKind(sourceUrl, url, kind.title)
                         },
                         },
                         onRefreshKinds = {
                         onRefreshKinds = {
-                            viewModel.refreshButtonGroup(globalId)
+                            onRefreshKinds(globalId)
                         },
                         },
                         useCase = useCase,
                         useCase = useCase,
                         isMiuix = isMiuix,
                         isMiuix = isMiuix,

+ 7 - 10
app/src/main/res/values-zh-rCN/strings.xml

@@ -1022,6 +1022,10 @@
     <string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
     <string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
     <string name="reverse_toc">反转目录</string>
     <string name="reverse_toc">反转目录</string>
     <string name="show_discovery">显示发现</string>
     <string name="show_discovery">显示发现</string>
+    <string name="show_home">显示首页</string>
+    <string name="main_navigation_settings">首页与导航</string>
+    <string name="main_navigation_settings_summary">配置首页、发现、订阅和默认主页</string>
+    <string name="navigation_order">导航顺序</string>
     <string name="style">样式</string>
     <string name="style">样式</string>
     <string name="group_style">分组样式</string>
     <string name="group_style">分组样式</string>
     <string name="export_file_name">导出文件名</string>
     <string name="export_file_name">导出文件名</string>
@@ -1830,12 +1834,9 @@
     <string name="homepage_type_label">类型</string>
     <string name="homepage_type_label">类型</string>
     <string name="homepage_layout_config_label">布局配置</string>
     <string name="homepage_layout_config_label">布局配置</string>
     <string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
     <string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
-    <string name="homepage_layout_settings">布局设置</string>
-    <string name="discovery_modules">发现模块</string>
-    <string name="classic_discovery">经典发现</string>
-    <string name="switch_to_discovery_modules">切换至发现模块</string>
-    <string name="switch_to_classic_discovery">切换至经典发现</string>
     <string name="home_recent_reading">最近阅读</string>
     <string name="home_recent_reading">最近阅读</string>
+    <string name="home_recent_books">最近阅读记录</string>
+    <string name="home_dashboard_settings">首页组件</string>
     <string name="home_no_recent_reading">暂无阅读记录</string>
     <string name="home_no_recent_reading">暂无阅读记录</string>
     <string name="home_total_read_books">累计阅读</string>
     <string name="home_total_read_books">累计阅读</string>
     <string name="home_total_reading_time">阅读时长</string>
     <string name="home_total_reading_time">阅读时长</string>
@@ -1851,14 +1852,10 @@
     <string name="home_latest_backup_value">最近备份:%1$s</string>
     <string name="home_latest_backup_value">最近备份:%1$s</string>
     <string name="home_no_webdav_backup">暂无 WebDAV 备份</string>
     <string name="home_no_webdav_backup">暂无 WebDAV 备份</string>
     <string name="home_loading_webdav_backup">正在获取备份信息…</string>
     <string name="home_loading_webdav_backup">正在获取备份信息…</string>
+    <string name="home_webdav_backup_load_error">无法获取备份信息</string>
     <string name="home_webdav_backup_confirmation">立即将当前数据备份到 WebDAV?</string>
     <string name="home_webdav_backup_confirmation">立即将当前数据备份到 WebDAV?</string>
     <string name="home_webdav_restore_confirmation">恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。</string>
     <string name="home_webdav_restore_confirmation">恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。</string>
-    <string name="homepage_layout_mode">首页布局模式</string>
-    <string name="homepage_layout_mixed">混合列表</string>
-    <string name="homepage_layout_tabs">分源Tab</string>
-    <string name="homepage_filter_sources">筛选书源</string>
     <string name="homepage_no_source_sets_selected">未选择任何书源集</string>
     <string name="homepage_no_source_sets_selected">未选择任何书源集</string>
-    <string name="homepage_module_error">模块错误</string>
     <string name="homepage_add_module_definition">请在书源中添加首页模块定义</string>
     <string name="homepage_add_module_definition">请在书源中添加首页模块定义</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_show_all">显示全部</string>
     <string name="homepage_show_all">显示全部</string>

+ 7 - 10
app/src/main/res/values-zh-rHK/strings.xml

@@ -895,6 +895,10 @@
     <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
     <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="show_discovery">顯示發現</string>
     <string name="show_discovery">顯示發現</string>
+    <string name="show_home">顯示首頁</string>
+    <string name="main_navigation_settings">首頁與導覽</string>
+    <string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
+    <string name="navigation_order">導覽順序</string>
     <string name="style">樣式</string>
     <string name="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">導出文件名</string>
     <string name="export_file_name">導出文件名</string>
@@ -1364,12 +1368,9 @@
     <string name="homepage_type_label">類型</string>
     <string name="homepage_type_label">類型</string>
     <string name="homepage_layout_config_label">佈局配置</string>
     <string name="homepage_layout_config_label">佈局配置</string>
     <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
     <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
-    <string name="homepage_layout_settings">佈局設置</string>
-    <string name="discovery_modules">發現模塊</string>
-    <string name="classic_discovery">經典發現</string>
-    <string name="switch_to_discovery_modules">切換至發現模塊</string>
-    <string name="switch_to_classic_discovery">切換至經典發現</string>
     <string name="home_recent_reading">最近閱讀</string>
     <string name="home_recent_reading">最近閱讀</string>
+    <string name="home_recent_books">最近閱讀記錄</string>
+    <string name="home_dashboard_settings">首頁組件</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_reading_time">閱讀時長</string>
     <string name="home_total_reading_time">閱讀時長</string>
@@ -1385,14 +1386,10 @@
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
     <string name="home_loading_webdav_backup">正在獲取備份資訊…</string>
     <string name="home_loading_webdav_backup">正在獲取備份資訊…</string>
+    <string name="home_webdav_backup_load_error">無法獲取備份資訊</string>
     <string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
     <string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
     <string name="home_webdav_restore_confirmation">恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
     <string name="home_webdav_restore_confirmation">恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
-    <string name="homepage_layout_mode">首頁佈局模式</string>
-    <string name="homepage_layout_mixed">混合列表</string>
-    <string name="homepage_layout_tabs">分源Tab</string>
-    <string name="homepage_filter_sources">篩選書源</string>
     <string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
     <string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
-    <string name="homepage_module_error">模塊錯誤</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_show_all">顯示全部</string>
     <string name="homepage_show_all">顯示全部</string>

+ 7 - 10
app/src/main/res/values-zh-rTW/strings.xml

@@ -898,6 +898,10 @@
     <string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
     <string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="show_discovery">顯示發現</string>
     <string name="show_discovery">顯示發現</string>
+    <string name="show_home">顯示首頁</string>
+    <string name="main_navigation_settings">首頁與導覽</string>
+    <string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
+    <string name="navigation_order">導覽順序</string>
     <string name="style">樣式</string>
     <string name="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">匯出檔案名</string>
     <string name="export_file_name">匯出檔案名</string>
@@ -1368,12 +1372,9 @@
     <string name="homepage_type_label">類型</string>
     <string name="homepage_type_label">類型</string>
     <string name="homepage_layout_config_label">佈局配置</string>
     <string name="homepage_layout_config_label">佈局配置</string>
     <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
     <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
-    <string name="homepage_layout_settings">佈局設置</string>
-    <string name="discovery_modules">發現模組</string>
-    <string name="classic_discovery">經典發現</string>
-    <string name="switch_to_discovery_modules">切換至發現模組</string>
-    <string name="switch_to_classic_discovery">切換至經典發現</string>
     <string name="home_recent_reading">最近閱讀</string>
     <string name="home_recent_reading">最近閱讀</string>
+    <string name="home_recent_books">最近閱讀記錄</string>
+    <string name="home_dashboard_settings">首頁元件</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_reading_time">閱讀時長</string>
     <string name="home_total_reading_time">閱讀時長</string>
@@ -1389,14 +1390,10 @@
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
     <string name="home_loading_webdav_backup">正在取得備份資訊…</string>
     <string name="home_loading_webdav_backup">正在取得備份資訊…</string>
+    <string name="home_webdav_backup_load_error">無法取得備份資訊</string>
     <string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
     <string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
     <string name="home_webdav_restore_confirmation">還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
     <string name="home_webdav_restore_confirmation">還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
-    <string name="homepage_layout_mode">首頁佈局模式</string>
-    <string name="homepage_layout_mixed">混合列表</string>
-    <string name="homepage_layout_tabs">分源Tab</string>
-    <string name="homepage_filter_sources">篩選書源</string>
     <string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
     <string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
-    <string name="homepage_module_error">模組錯誤</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_collapse">收起</string>
     <string name="homepage_show_all">顯示全部</string>
     <string name="homepage_show_all">顯示全部</string>

+ 1 - 3
app/src/main/res/values/arrays.xml

@@ -260,8 +260,7 @@
 	<string-array name="default_home_page">
 	<string-array name="default_home_page">
 		<item>@string/home</item>
 		<item>@string/home</item>
 		<item>@string/bookshelf</item>
 		<item>@string/bookshelf</item>
-		<item>@string/discovery_modules</item>
-		<item>@string/classic_discovery</item>
+        <item>@string/discovery</item>
 		<item>@string/rss</item>
 		<item>@string/rss</item>
 		<item>@string/my</item>
 		<item>@string/my</item>
 	</string-array>
 	</string-array>
@@ -277,7 +276,6 @@
 	<string-array name="default_home_page_value">
 	<string-array name="default_home_page_value">
 		<item>home</item>
 		<item>home</item>
 		<item>bookshelf</item>
 		<item>bookshelf</item>
-		<item>explore_modules</item>
 		<item>explore</item>
 		<item>explore</item>
 		<item>rss</item>
 		<item>rss</item>
 		<item>my</item>
 		<item>my</item>

+ 7 - 10
app/src/main/res/values/strings.xml

@@ -1053,6 +1053,10 @@
     <string name="hide_when_status_bar_show">Hide when status bar show</string>
     <string name="hide_when_status_bar_show">Hide when status bar show</string>
     <string name="reverse_toc">Reverse toc</string>
     <string name="reverse_toc">Reverse toc</string>
     <string name="show_discovery">Show Discovery</string>
     <string name="show_discovery">Show Discovery</string>
+    <string name="show_home">Show Home</string>
+    <string name="main_navigation_settings">Home and navigation</string>
+    <string name="main_navigation_settings_summary">Configure Home, Discovery, Subscriptions, and the start page</string>
+    <string name="navigation_order">Navigation order</string>
     <string name="style">Style</string>
     <string name="style">Style</string>
     <string name="group_style">Group style</string>
     <string name="group_style">Group style</string>
     <string name="export_file_name">Export file name</string>
     <string name="export_file_name">Export file name</string>
@@ -1963,12 +1967,9 @@
     <string name="homepage_type_label">Type</string>
     <string name="homepage_type_label">Type</string>
     <string name="homepage_layout_config_label">Layout Config</string>
     <string name="homepage_layout_config_label">Layout Config</string>
     <string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string>
     <string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string>
-    <string name="homepage_layout_settings">Layout Settings</string>
-    <string name="discovery_modules">Discovery Modules</string>
-    <string name="classic_discovery">Classic Discovery</string>
-    <string name="switch_to_discovery_modules">Switch to Discovery Modules</string>
-    <string name="switch_to_classic_discovery">Switch to Classic Discovery</string>
     <string name="home_recent_reading">Recently Reading</string>
     <string name="home_recent_reading">Recently Reading</string>
+    <string name="home_recent_books">Recent reading history</string>
+    <string name="home_dashboard_settings">Home components</string>
     <string name="home_no_recent_reading">No reading history yet</string>
     <string name="home_no_recent_reading">No reading history yet</string>
     <string name="home_total_read_books">Books Read</string>
     <string name="home_total_read_books">Books Read</string>
     <string name="home_total_reading_time">Reading Time</string>
     <string name="home_total_reading_time">Reading Time</string>
@@ -1984,14 +1985,10 @@
     <string name="home_latest_backup_value">Latest backup: %1$s</string>
     <string name="home_latest_backup_value">Latest backup: %1$s</string>
     <string name="home_no_webdav_backup">No WebDAV backup found</string>
     <string name="home_no_webdav_backup">No WebDAV backup found</string>
     <string name="home_loading_webdav_backup">Loading backup information…</string>
     <string name="home_loading_webdav_backup">Loading backup information…</string>
+    <string name="home_webdav_backup_load_error">Couldn\'t load backup information</string>
     <string name="home_webdav_backup_confirmation">Back up current data to WebDAV now?</string>
     <string name="home_webdav_backup_confirmation">Back up current data to WebDAV now?</string>
     <string name="home_webdav_restore_confirmation">Restore WebDAV backup %1$s? Current data may be overwritten.</string>
     <string name="home_webdav_restore_confirmation">Restore WebDAV backup %1$s? Current data may be overwritten.</string>
-    <string name="homepage_layout_mode">Homepage Layout Mode</string>
-    <string name="homepage_layout_mixed">Mixed List</string>
-    <string name="homepage_layout_tabs">Tabs by Source</string>
-    <string name="homepage_filter_sources">Filter Sources</string>
     <string name="homepage_no_source_sets_selected">No source sets selected</string>
     <string name="homepage_no_source_sets_selected">No source sets selected</string>
-    <string name="homepage_module_error">Module Error</string>
     <string name="homepage_add_module_definition">Please add homepage module definitions in the book source</string>
     <string name="homepage_add_module_definition">Please add homepage module definitions in the book source</string>
     <string name="homepage_collapse">Collapse</string>
     <string name="homepage_collapse">Collapse</string>
     <string name="homepage_show_all">Show All</string>
     <string name="homepage_show_all">Show All</string>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio