HapeLee 4 месяцев назад
Родитель
Сommit
9f897ddd27
39 измененных файлов с 2093 добавлено и 1512 удалено
  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 prevKeys = "prevKeyCodes"
     const val nextKeys = "nextKeyCodes"
+    const val showHome = "showHome"
     const val showDiscovery = "showDiscovery"
     const val enableReview = "enableReview"
     const val showRss = "showRss"
+    const val mainNavigationOrder = "mainNavigationOrder"
     const val showStatusBar = "showStatusBar"
     const val swipeAnimation = "swipeAnimation"
     const val bookshelfLayout = "bookshelfLayout"
@@ -364,9 +366,6 @@ object PreferKey {
     const val llmTemperature = "llmTemperature"
     const val llmPrompt = "llmPrompt"
 
-    const val homepageModuleOrder = "homepageModuleOrder"
-    const val homepageModuleHidden = "homepageModuleHidden"
-    const val homepageLayoutMode = "homepageLayoutMode"
     const val navIconHome = "navIconHome"
 
     // 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.Query
 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.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -25,6 +26,56 @@ interface ReadRecordDao {
     @Query("SELECT sum(readTime) FROM readRecord")
     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")
     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_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
     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 LAST_BACKUP = longPreferencesKey("last_backup")
     val PASSWORD = stringPreferencesKey("password")
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     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
     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
 
-import androidx.room.Transaction
+import androidx.room.withTransaction
 import cn.hutool.core.date.DatePattern
 import cn.hutool.core.date.DateUtil
+import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
@@ -15,7 +16,8 @@ import kotlin.math.max
 import kotlin.math.min
 
 class ReadRecordRepository(
-    private val dao: ReadRecordDao
+    private val dao: ReadRecordDao,
+    private val database: AppDatabase,
 ) {
     private fun getCurrentDeviceId(): String = ""
 
@@ -85,11 +87,15 @@ class ReadRecordRepository(
      * 保存一个完整的阅读会话.
      */
     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) {
@@ -151,58 +157,72 @@ class ReadRecordRepository(
     }
 
     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) {
-        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) {
@@ -214,27 +234,44 @@ class ReadRecordRepository(
             val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
             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) {
-        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>) {
-        sourceRecords.forEach { sourceRecord ->
-            mergeSingleReadRecordInto(targetRecord, sourceRecord)
+        database.withTransaction {
+            sourceRecords.forEach { sourceRecord ->
+                mergeSingleReadRecordInto(targetRecord, sourceRecord)
+            }
         }
     }
 
-    @Transaction
     private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
         if (targetRecord == sourceRecord) 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? {
         return withContext(IO) {
-            AppWebDav.lastBackUp().getOrNull()?.let {
+            AppWebDav.lastBackUp().getOrThrow()?.let {
                 WebDavBackup(
                     name = it.displayName,
                     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 io.legado.app.data.AppDatabase
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
-import io.legado.app.data.repository.AppStartupRepository
-import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiMemoryRepository
 import io.legado.app.data.repository.AiProfileRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 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.BookDomainRepositoryImpl
 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.ExploreRepositoryImpl
 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.LocalBookRepository
 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.WebDavBackupRepository
 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.AiChatGateway
 import io.legado.app.domain.gateway.AiMemoryGateway
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiTextGateway
 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.BookCacheDownloadGateway
 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.DictionaryGateway
 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.LocalBookGateway
 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.usecase.AddBookUseCase
 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.BackupRestoreUseCase
 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.ExploreKindUiUseCase
 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.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.RefreshTocUseCase
 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.okHttpClientManga
 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.cache.manage.BookCacheManageViewModel
 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.toc.TocViewModel
 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.AiModelEditViewModel
 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.coverConfig.CoverConfigViewModel
 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.rule.DictRuleViewModel
 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.MainViewModel
 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.source.manage.RssSourceViewModel
 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.viewModel
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
+import java.time.Clock
 
 val appModule = module {
 
@@ -170,6 +174,7 @@ val appModule = module {
     single { get<AppDatabase>().bookSourceDao }
 
     singleOf(::ReadRecordRepository)
+    single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
     singleOf(::BookRepository)
     singleOf(::BookGroupRepository)
     singleOf(::BookSourceRepository)
@@ -195,6 +200,7 @@ val appModule = module {
     singleOf(::ClearBookCacheUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::GetReadingProgressUseCase)
+    single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
     singleOf(::RemoveBookGroupAssignmentUseCase)
     singleOf(::UpdateBooksGroupUseCase)
     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
         }
 
+    val showHome: Boolean
+        get() = ThemeConfig.showHome
+
     val showDiscovery: Boolean
         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,
 
             // 主界面设置
+            showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
             showRss = ThemeConfig.showRss,
             showStatusBar = ThemeConfig.showStatusBar,
@@ -184,6 +185,7 @@ object ThemeImportExport {
             tabletInterface = ThemeConfig.tabletInterface,
             labelVisibilityMode = ThemeConfig.labelVisibilityMode,
             defaultHomePage = ThemeConfig.defaultHomePage,
+            mainNavigationOrder = ThemeConfig.mainNavigationOrder,
 
             // 导航栏图标
             navIconHome = ThemeConfig.navIconHome,
@@ -343,6 +345,7 @@ object ThemeImportExport {
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
         // 主界面设置
+        ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showRss = data.showRss
         ThemeConfig.showStatusBar = data.showStatusBar
@@ -353,6 +356,7 @@ object ThemeImportExport {
         ThemeConfig.tabletInterface = data.tabletInterface
         ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
         ThemeConfig.defaultHomePage = data.defaultHomePage
+        ThemeConfig.mainNavigationOrder = data.mainNavigationOrder
 
         // 导航栏图标
         ThemeConfig.navIconHome = data.navIconHome
@@ -579,6 +583,7 @@ data class ThemeExportData(
     val customTagColorsJson: String? = null,
 
     // 主界面设置
+    val showHome: Boolean = true,
     val showDiscovery: Boolean = true,
     val showRss: Boolean = true,
     val showStatusBar: Boolean = true,
@@ -589,6 +594,7 @@ data class ThemeExportData(
     val tabletInterface: String = "auto",
     val labelVisibilityMode: String = "auto",
     val defaultHomePage: String = "bookshelf",
+    val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
 
     // 导航栏图标
     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
 
 object MainConfig {
+    var showHome by prefDelegate(PreferKey.showHome, true)
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
     var showRSS by prefDelegate(PreferKey.showRss, true)
     var showBottomView by prefDelegate(PreferKey.showBottomView, true)
@@ -13,6 +14,10 @@ object MainConfig {
         false
     )
     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 labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
     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)
     }
 
+    var showHome by prefDelegate(PreferKey.showHome, true)
+
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
 
     var showRss by prefDelegate(PreferKey.showRss, true)
@@ -165,6 +167,11 @@ object ThemeConfig {
 
     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 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 showBorderColorPicker by remember { mutableStateOf(false) }
     var showNavIconSheet by remember { mutableStateOf(false) }
+    var showMainNavigationSheet by remember { mutableStateOf(false) }
     var showFontSheet by remember { mutableStateOf(false) }
     val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
 
@@ -375,15 +376,10 @@ fun ThemeConfigScreen(
                 }
 
                 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(
                         title = stringResource(R.string.show_status),
@@ -446,13 +442,6 @@ fun ThemeConfigScreen(
                         entryValues = stringArrayResource(R.array.label_vis_mode_value),
                         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)) {
@@ -841,6 +830,11 @@ fun ThemeConfigScreen(
         onDismissRequest = { showNavIconSheet = false }
     )
 
+    MainNavigationSettingsSheet(
+        show = showMainNavigationSheet,
+        onDismissRequest = { showMainNavigationSheet = false },
+    )
+
 
     LauncherIconPickerSheet(
         show = showLauncherIconPicker,

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

@@ -163,6 +163,11 @@ fun EditThemeSheet(
 
             // Interface 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(
                 title = stringResource(R.string.theme_manage_show_discovery),
                 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 {
-        const val DISCOVERY_MODULES_ROUTE = "explore_modules"
-
         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 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
     }
     val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
@@ -392,6 +387,17 @@ fun MainScreen(
                                 onOpenBook = { book ->
                                     context.startActivityForBook(book)
                                 },
+                                onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
+                                    onNavigateToBookInfo(
+                                        name ?: "",
+                                        author ?: "",
+                                        bookUrl,
+                                        origin,
+                                        coverPath,
+                                        sharedCoverKey,
+                                    )
+                                },
+                                onOpenExploreShow = onNavigateToExploreShow,
                                 onOpenBackupSettings = onNavigateToBackupSettings,
                                 onNavigateToReadRecord = onNavigateToReadRecord,
                                 onNavigateToReadRecordOverview = onNavigateToReadRecordOverview,
@@ -423,21 +429,7 @@ fun MainScreen(
                             )
 
                             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,
-                                sharedTransitionScope = sharedTransitionScope,
-                                animatedVisibilityScope = animatedVisibilityScope,
                             )
                             MainDestination.Rss -> RssScreen(
                                 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 io.legado.app.base.BaseViewModel
 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.WebDavBackupUseCase
 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.utils.eventBus.FlowEventBus
 import kotlinx.collections.immutable.ImmutableList
@@ -27,7 +24,6 @@ class MainViewModel(
     application: Application,
     private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
     private val webDavBackupUseCase: WebDavBackupUseCase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
 ) : BaseViewModel(application) {
 
     private val _uiState = MutableStateFlow(readMainUiState())
@@ -36,24 +32,12 @@ class MainViewModel(
     val effects = _effects.asSharedFlow()
 
     init {
-        // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State,全链路走 DS
+        // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State
         viewModelScope.launch {
             snapshotFlow {
                 readMainUiState()
             }.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()
@@ -89,17 +73,6 @@ class MainViewModel(
         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) {
         when (event) {
             is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
@@ -158,12 +131,12 @@ data class MainUiState(
     val useFloatingBottomBarLiquidGlass: Boolean = false,
     val labelVisibilityMode: String = "auto",
     val navExtended: Boolean = false,
-    val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
 )
 
 private fun MainViewModel.readMainUiState(): MainUiState {
-    val destinations = MainDestination.mainDestinations.filter {
+    val destinations = MainDestination.ordered(ThemeConfig.mainNavigationOrder).filter {
         when (it) {
+            MainDestination.Home -> ThemeConfig.showHome
             MainDestination.Explore -> ThemeConfig.showDiscovery
             MainDestination.Rss -> ThemeConfig.showRss
             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.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.core.FastOutSlowInEasing
 import androidx.compose.animation.core.animateFloatAsState
 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.combinedClickable
 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.Edit
 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.Search
 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.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveableStateHolder
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 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.source.edit.BookSourceEditActivity
 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.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -95,64 +86,19 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @Composable
 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,
-    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(
     ExperimentalMaterial3Api::class,
     ExperimentalFoundationApi::class,
-    ExperimentalSharedTransitionApi::class,
 )
 @Composable
 private fun ExploreDiscoveryScreen(
     viewModel: ExploreViewModel = koinViewModel(),
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
-    onSwitchToHomepage: () -> Unit,
 ) {
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
@@ -208,14 +154,6 @@ private fun ExploreDiscoveryScreen(
         onSearchToggle = { viewModel.toggleSearchVisible(it) },
         searchPlaceholder = stringResource(R.string.search),
         dropDownMenuContent = { dismiss ->
-            RoundDropdownMenuItem(
-                leadingIcon = { MenuItemIcon(Icons.Default.Home) },
-                text = stringResource(R.string.switch_to_discovery_modules),
-                onClick = {
-                    dismiss()
-                    onSwitchToHomepage()
-                },
-            )
             RoundDropdownMenuItem(
                 leadingIcon = { MenuItemIcon(Icons.Default.Group) },
                 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.compose.runtime.Stable
 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
 data class HomeUiState(
@@ -11,8 +18,13 @@ data class HomeUiState(
     val todayReadTimeMillis: Long = 0L,
     val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
     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 isBackupLoading: Boolean = true,
+    val isBackupLoadError: Boolean = false,
     val isBackupActionRunning: Boolean = false,
     val activeDialog: HomeDialog? = null,
     val activeSheet: HomeSheet? = null,
@@ -37,6 +49,13 @@ data class HomeBackupUi(
 
 sealed interface 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 class UpdateReadingGoal(val minutes: Int) : HomeIntent
     data object BackupClick : HomeIntent
@@ -52,6 +71,7 @@ sealed interface HomeIntent {
     data class RestoreLocalFileSelected(val uri: String) : HomeIntent
     data object ConfirmRestore : HomeIntent
     data object BackupSettingsClick : HomeIntent
+    data object RetryBackupInfo : HomeIntent
     data object DismissDialog : HomeIntent
     data object DismissSheet : HomeIntent
 }
@@ -83,6 +103,7 @@ sealed interface HomeDialog {
 
 @Stable
 sealed interface HomeSheet {
+    data object DashboardSettings : HomeSheet
     data object BackupOptions : HomeSheet
     data object RestoreOptions : HomeSheet
 }
@@ -92,6 +113,3 @@ enum class HomeBackupDestination(val mode: String) {
     WebDav("webdav"),
     LocalAndWebDav("both"),
 }
-
-const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
-const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60

Разница между файлами не показана из-за своего большого размера
+ 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.viewModelScope
 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.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.usecase.BackupRestoreUseCase
+import io.legado.app.domain.usecase.HomeDashboardUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.ui.config.backupConfig.BackupConfig
 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.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import java.time.LocalDate
+import kotlinx.coroutines.sync.Mutex
 
 class HomeViewModel(
-    private val readRecordRepository: ReadRecordRepository,
+    private val homeDashboardUseCase: HomeDashboardUseCase,
     private val bookRepository: BookRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository,
     private val webDavBackupUseCase: WebDavBackupUseCase,
     private val backupRestoreUseCase: BackupRestoreUseCase,
 ) : ViewModel() {
@@ -37,58 +41,15 @@ class HomeViewModel(
     private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
     private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
+    private var backupRefreshJob: Job? = null
+    private val backupActionMutex = Mutex()
 
     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(
@@ -96,15 +57,24 @@ class HomeViewModel(
         _backupState,
         _activeDialog,
         _activeSheet,
-    ) { dashboard, backup, dialog, sheet ->
+    ) { dashboardData, backup, dialog, sheet ->
+        val (dashboard, selectedSourceUrl, visibleSections) = dashboardData
         HomeUiState(
             totalReadBooks = dashboard.totalReadBooks,
             totalReadTimeMillis = dashboard.totalReadTimeMillis,
             todayReadTimeMillis = dashboard.todayReadTimeMillis,
             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(),
             isBackupLoading = backup.isLoading,
+            isBackupLoadError = backup.isLoadError,
             isBackupActionRunning = backup.isActionRunning,
             activeDialog = dialog,
             activeSheet = sheet,
@@ -116,12 +86,33 @@ class HomeViewModel(
     )
 
     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) {
         when (intent) {
             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 -> {
                 _activeDialog.value = HomeDialog.SetReadingGoal(
                     uiState.value.dailyGoalMinutes
@@ -159,6 +150,7 @@ class HomeViewModel(
                 _effects.tryEmit(HomeEffect.OpenBackupSettings)
             }
 
+            HomeIntent.RetryBackupInfo -> refreshLatestBackup()
             HomeIntent.DismissDialog -> _activeDialog.value = null
             HomeIntent.DismissSheet -> _activeSheet.value = null
         }
@@ -166,6 +158,10 @@ class HomeViewModel(
 
     private fun openRecentBook() {
         val bookUrl = uiState.value.recentBook?.bookUrl ?: return
+        openBook(bookUrl)
+    }
+
+    private fun openBook(bookUrl: String) {
         viewModelScope.launch {
             bookRepository.getBook(bookUrl)?.let {
                 _effects.emit(HomeEffect.OpenBook(it))
@@ -174,20 +170,42 @@ class HomeViewModel(
     }
 
     private fun updateReadingGoal(minutes: Int) {
-        val validMinutes = minutes.coerceIn(1, MAX_DAILY_READING_GOAL_MINUTES)
         _activeDialog.value = null
         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() {
         val backup = _backupState.value.latest
         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
         }
         _activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
@@ -221,101 +239,130 @@ class HomeViewModel(
         path: String?,
         savePath: Boolean = false,
     ) {
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         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) {
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         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() {
         val backup = _backupState.value.latest ?: return
         _activeDialog.value = null
-        if (_backupState.value.isActionRunning) return
+        if (!backupActionMutex.tryLock()) return
         _backupState.update { it.copy(isActionRunning = true) }
         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() {
-        viewModelScope.launch(Dispatchers.IO) {
+        backupRefreshJob?.cancel()
+        backupRefreshJob = viewModelScope.launch(Dispatchers.IO) {
             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(
         val latest: WebDavBackup? = null,
         val isLoading: Boolean = true,
+        val isLoadError: Boolean = false,
         val isActionRunning: Boolean = false,
     )
 
@@ -323,4 +370,14 @@ class HomeViewModel(
         name = name,
         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
 
-import androidx.compose.runtime.State
-import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.prefStateDelegate
 
 /**
@@ -10,17 +8,10 @@ import io.legado.app.ui.config.prefStateDelegate
  */
 object HomepageConfig {
 
-    /**
-     * 首页布局模式 0: 混合列表 1: 分源Tab
-     */
-    private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0)
-    var homepageLayoutMode by _homepageLayoutMode
-    val homepageLayoutModeState: State<Int> get() = _homepageLayoutMode.state
-
     /**
      * 首页书源隐藏
      */
     private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "")
     var homepageSourceHidden by _homepageSourceHidden
 
-}
+}

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

@@ -19,7 +19,6 @@ data class HomepageBookItemUi(
 data class HomepageUiState(
     val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
     val isManageMode: Boolean = false,
-    val isConfigMode: Boolean = false,
     val isRefreshing: Boolean = false,
     val manageState: HomepageManageUiState = HomepageManageUiState()
 )
@@ -53,6 +52,16 @@ data class HomepageManageActions(
     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
 data class HomepageSourceManageUi(
     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. 基础原始状态
     private val _isRefreshing = MutableStateFlow(false)
     private val _isManageMode = MutableStateFlow(false)
-    private val _isConfigMode = MutableStateFlow(false)
     private val _configVersion = MutableStateFlow(0L)
     private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
     private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
@@ -151,8 +150,8 @@ class HomepageViewModel(
 
     // 4. 聚合层
     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(
@@ -275,7 +274,6 @@ class HomepageViewModel(
             modules = modules,
             isRefreshing = flags.isRefreshing,
             isManageMode = flags.isManageMode,
-            isConfigMode = flags.isConfigMode,
             manageState = manageState
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
@@ -550,7 +548,6 @@ class HomepageViewModel(
     }
 
     fun toggleManageMode() = _isManageMode.update { !it }
-    fun toggleConfigMode() = _isConfigMode.update { !it }
 
     fun setModuleVisible(id: String, visible: Boolean) {
         _pendingEnabled.update { it + (id to visible) }
@@ -579,11 +576,6 @@ class HomepageViewModel(
         notifyConfigChanged()
     }
 
-    fun setLayoutMode(mode: Int) {
-        HomepageConfig.homepageLayoutMode = mode
-        notifyConfigChanged()
-    }
-
     private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
         val setId = "src_$sourceUrl"
         if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet(
@@ -875,6 +867,12 @@ class HomepageViewModel(
         }
     }
 
+    fun saveSearchBook(book: SearchBook) {
+        viewModelScope.launch {
+            saveSearchBooksUseCase.save(book)
+        }
+    }
+
     fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
         viewModelScope.launch {
             saveSearchBooksUseCase.save(book)
@@ -940,6 +938,5 @@ class HomepageViewModel(
 
 private data class HomepageUiFlags(
     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 io.legado.app.data.entities.rule.ExploreKind
 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.ThemeResolver
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -37,7 +36,8 @@ fun ButtonGroupModule(
     kinds: List<ExploreKind>,
     sourceUrl: String,
     globalId: String,
-    viewModel: HomepageViewModel,
+    onOpenKind: (sourceUrl: String, url: String, title: String) -> Unit,
+    onRefreshKinds: (globalId: String) -> Unit,
     modifier: Modifier = Modifier,
     icon: String? = null,
     layoutConfig: String? = null,
@@ -91,10 +91,10 @@ fun ButtonGroupModule(
                         sourceUrl = sourceUrl,
                         activity = activity,
                         onOpenUrl = { url ->
-                            viewModel.onKindUrlClick(sourceUrl, url, kind.title)
+                            onOpenKind(sourceUrl, url, kind.title)
                         },
                         onRefreshKinds = {
-                            viewModel.refreshButtonGroup(globalId)
+                            onRefreshKinds(globalId)
                         },
                         useCase = useCase,
                         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="reverse_toc">反转目录</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="group_style">分组样式</string>
     <string name="export_file_name">导出文件名</string>
@@ -1830,12 +1834,9 @@
     <string name="homepage_type_label">类型</string>
     <string name="homepage_layout_config_label">布局配置</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_books">最近阅读记录</string>
+    <string name="home_dashboard_settings">首页组件</string>
     <string name="home_no_recent_reading">暂无阅读记录</string>
     <string name="home_total_read_books">累计阅读</string>
     <string name="home_total_reading_time">阅读时长</string>
@@ -1851,14 +1852,10 @@
     <string name="home_latest_backup_value">最近备份:%1$s</string>
     <string name="home_no_webdav_backup">暂无 WebDAV 备份</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_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_module_error">模块错误</string>
     <string name="homepage_add_module_definition">请在书源中添加首页模块定义</string>
     <string name="homepage_collapse">收起</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="reverse_toc">反轉目錄</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="group_style">分組樣式</string>
     <string name="export_file_name">導出文件名</string>
@@ -1364,12 +1368,9 @@
     <string name="homepage_type_label">類型</string>
     <string name="homepage_layout_config_label">佈局配置</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_books">最近閱讀記錄</string>
+    <string name="home_dashboard_settings">首頁組件</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_reading_time">閱讀時長</string>
@@ -1385,14 +1386,10 @@
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</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_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_module_error">模塊錯誤</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string>
     <string name="homepage_collapse">收起</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="reverse_toc">反轉目錄</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="group_style">分組樣式</string>
     <string name="export_file_name">匯出檔案名</string>
@@ -1368,12 +1372,9 @@
     <string name="homepage_type_label">類型</string>
     <string name="homepage_layout_config_label">佈局配置</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_books">最近閱讀記錄</string>
+    <string name="home_dashboard_settings">首頁元件</string>
     <string name="home_no_recent_reading">暫無閱讀記錄</string>
     <string name="home_total_read_books">累計閱讀</string>
     <string name="home_total_reading_time">閱讀時長</string>
@@ -1389,14 +1390,10 @@
     <string name="home_latest_backup_value">最近備份:%1$s</string>
     <string name="home_no_webdav_backup">暫無 WebDAV 備份</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_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_module_error">模組錯誤</string>
     <string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string>
     <string name="homepage_collapse">收起</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">
 		<item>@string/home</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/my</item>
 	</string-array>
@@ -277,7 +276,6 @@
 	<string-array name="default_home_page_value">
 		<item>home</item>
 		<item>bookshelf</item>
-		<item>explore_modules</item>
 		<item>explore</item>
 		<item>rss</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="reverse_toc">Reverse toc</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="group_style">Group style</string>
     <string name="export_file_name">Export file name</string>
@@ -1963,12 +1967,9 @@
     <string name="homepage_type_label">Type</string>
     <string name="homepage_layout_config_label">Layout Config</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_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_total_read_books">Books Read</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_no_webdav_backup">No WebDAV backup found</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_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_module_error">Module Error</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_show_all">Show All</string>

Некоторые файлы не были показаны из-за большого количества измененных файлов