Просмотр исходного кода

feat: add home dashboard and discovery modes

HapeLee 4 месяцев назад
Родитель
Сommit
5287fe6457
36 измененных файлов с 1803 добавлено и 210 удалено
  1. 4 0
      app/src/main/java/io/legado/app/App.kt
  2. 0 4
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 2 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  4. 24 0
      app/src/main/java/io/legado/app/data/repository/BackupRestoreRepository.kt
  5. 29 9
      app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt
  6. 7 0
      app/src/main/java/io/legado/app/di/appModule.kt
  7. 6 0
      app/src/main/java/io/legado/app/domain/gateway/BackupRestoreGateway.kt
  8. 1 0
      app/src/main/java/io/legado/app/domain/gateway/WebDavBackupGateway.kt
  9. 15 0
      app/src/main/java/io/legado/app/domain/usecase/BackupRestoreUseCase.kt
  10. 5 0
      app/src/main/java/io/legado/app/domain/usecase/WebDavBackupUseCase.kt
  11. 39 23
      app/src/main/java/io/legado/app/help/AppWebDav.kt
  12. 0 21
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  13. 0 3
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  14. 48 78
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt
  15. 6 7
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt
  16. 70 0
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupRestoreOptionSheets.kt
  17. 0 2
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  18. 0 5
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  19. 0 5
      app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt
  20. 2 0
      app/src/main/java/io/legado/app/ui/main/MainDestination.kt
  21. 3 0
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  22. 29 16
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  23. 31 6
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  24. 74 1
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  25. 12 0
      app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt
  26. 97 0
      app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt
  27. 837 0
      app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt
  28. 326 0
      app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt
  29. 42 8
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  30. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
  31. 0 14
      app/src/main/java/io/legado/app/utils/ContextExtensions.kt
  32. 24 3
      app/src/main/res/values-zh-rCN/strings.xml
  33. 22 1
      app/src/main/res/values-zh-rHK/strings.xml
  34. 22 1
      app/src/main/res/values-zh-rTW/strings.xml
  35. 3 1
      app/src/main/res/values/arrays.xml
  36. 22 1
      app/src/main/res/values/strings.xml

+ 4 - 0
app/src/main/java/io/legado/app/App.kt

@@ -133,6 +133,9 @@ class App : Application(), ImageLoaderFactory {
         oldConfig = Configuration(resources.configuration)
         registerActivityLifecycleCallbacks(LifecycleHelp)
         defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig)
+        Coroutine.async {
+            AppWebDav.upConfig()
+        }
         Coroutine.async {
             LogUtils.init(this@App)
             LogUtils.d("App", "onCreate")
@@ -181,6 +184,7 @@ class App : Application(), ImageLoaderFactory {
             SourceHelp.adjustSortNumber()
             //同步阅读记录
             if (AppConfig.syncBookProgress) {
+                AppWebDav.upConfig()
                 AppWebDav.downloadAllBookProgress()
             }
         }

+ 0 - 4
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -45,7 +45,6 @@ object PreferKey {
     const val prevKeys = "prevKeyCodes"
     const val nextKeys = "nextKeyCodes"
     const val showDiscovery = "showDiscovery"
-    const val showHome = "showHome"
     const val enableReview = "enableReview"
     const val showRss = "showRss"
     const val showStatusBar = "showStatusBar"
@@ -275,9 +274,7 @@ object PreferKey {
     const val bookshelfListCoverWidth = "bookshelfListCoverWidth"
     const val bookshelfGridCoverWidth = "bookshelfGridCoverWidth"
 
-    const val exploreLayoutGridLandscape = "exploreLayoutGridLandscape"
 
-    const val exploreLayoutGridPortrait = "exploreLayoutGridPortrait"
     const val tabletInterface = "tabletInterface"
     const val pureBlack = "pure_black"
     const val labelVisibilityMode = "labelVisibilityMode"
@@ -332,7 +329,6 @@ object PreferKey {
 
     const val exploreFilterState = "explore_filter_state"
 
-    const val exploreLayoutState = "exploreLayoutState"
 
     const val defaultSourceChangeAll = "defaultSourceChangeAll"
 

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

@@ -19,11 +19,13 @@ 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")
 
     // Change source options
     val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")

+ 24 - 0
app/src/main/java/io/legado/app/data/repository/BackupRestoreRepository.kt

@@ -0,0 +1,24 @@
+package io.legado.app.data.repository
+
+import androidx.core.net.toUri
+import io.legado.app.domain.gateway.BackupRestoreGateway
+import io.legado.app.help.storage.Backup
+import io.legado.app.help.storage.Restore
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+class BackupRestoreRepository : BackupRestoreGateway {
+
+    override suspend fun backup(path: String?, mode: String) {
+        withContext(IO) {
+            Backup.backupLocked(appCtx, path, mode)
+        }
+    }
+
+    override suspend fun restoreLocal(uri: String) {
+        withContext(IO) {
+            Restore.restore(appCtx, uri.toUri())
+        }
+    }
+}

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

@@ -3,6 +3,10 @@ package io.legado.app.data.repository
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.model.WebDavBackup
 import io.legado.app.help.AppWebDav
+import io.legado.app.help.storage.Backup
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
 
 class WebDavBackupRepository : WebDavBackupGateway {
 
@@ -10,27 +14,43 @@ class WebDavBackupRepository : WebDavBackupGateway {
         get() = AppWebDav.isJianGuoYun
 
     override suspend fun syncConfig() {
-        AppWebDav.upConfig()
+        withContext(IO) {
+            AppWebDav.upConfig()
+        }
     }
 
     override suspend fun test(): Boolean {
-        return AppWebDav.testWebDav()
+        return withContext(IO) {
+            AppWebDav.testWebDav()
+        }
+    }
+
+    override suspend fun backup() {
+        withContext(IO) {
+            Backup.backupLocked(appCtx, path = null, mode = "webdav")
+        }
     }
 
     override suspend fun getBackupNames(): List<String> {
-        return AppWebDav.getBackupNames()
+        return withContext(IO) {
+            AppWebDav.getBackupNames()
+        }
     }
 
     override suspend fun getLatestBackup(): WebDavBackup? {
-        return AppWebDav.lastBackUp().getOrNull()?.let {
-            WebDavBackup(
-                name = it.displayName,
-                lastModify = it.lastModify
-            )
+        return withContext(IO) {
+            AppWebDav.lastBackUp().getOrNull()?.let {
+                WebDavBackup(
+                    name = it.displayName,
+                    lastModify = it.lastModify
+                )
+            }
         }
     }
 
     override suspend fun restore(name: String) {
-        AppWebDav.restoreWebDav(name)
+        withContext(IO) {
+            AppWebDav.restoreWebDav(name)
+        }
     }
 }

+ 7 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -8,6 +8,7 @@ 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
@@ -47,6 +48,7 @@ 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
@@ -69,6 +71,7 @@ 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.AppStartupMaintenanceUseCase
+import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
@@ -140,6 +143,7 @@ import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreViewModel
+import io.legado.app.ui.main.home.HomeViewModel
 import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.main.my.MyViewModel
 import io.legado.app.ui.main.rss.RssViewModel
@@ -184,6 +188,7 @@ val appModule = module {
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)
     singleOf(::AppStartupMaintenanceUseCase)
+    singleOf(::BackupRestoreUseCase)
     singleOf(::BatchCacheDownloadUseCase)
     singleOf(::CacheBookChaptersUseCase)
     singleOf(::ChangeBookSourceUseCase)
@@ -213,6 +218,7 @@ val appModule = module {
     single<AiTextGateway> { AiTextRepositoryImpl() }
     single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
     single<AppStartupGateway> { AppStartupRepository(get()) }
+    single<BackupRestoreGateway> { BackupRestoreRepository() }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
     single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
     single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
@@ -271,6 +277,7 @@ val appModule = module {
     viewModelOf(::MyViewModel)
     viewModelOf(::BookshelfViewModel)
     viewModelOf(::MainViewModel)
+    viewModelOf(::HomeViewModel)
     viewModelOf(::HomepageViewModel)
     viewModelOf(::AboutViewModel)
     viewModelOf(::GroupViewModel)

+ 6 - 0
app/src/main/java/io/legado/app/domain/gateway/BackupRestoreGateway.kt

@@ -0,0 +1,6 @@
+package io.legado.app.domain.gateway
+
+interface BackupRestoreGateway {
+    suspend fun backup(path: String?, mode: String)
+    suspend fun restoreLocal(uri: String)
+}

+ 1 - 0
app/src/main/java/io/legado/app/domain/gateway/WebDavBackupGateway.kt

@@ -7,6 +7,7 @@ interface WebDavBackupGateway {
 
     suspend fun syncConfig()
     suspend fun test(): Boolean
+    suspend fun backup()
     suspend fun getBackupNames(): List<String>
     suspend fun getLatestBackup(): WebDavBackup?
     suspend fun restore(name: String)

+ 15 - 0
app/src/main/java/io/legado/app/domain/usecase/BackupRestoreUseCase.kt

@@ -0,0 +1,15 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.BackupRestoreGateway
+
+class BackupRestoreUseCase(
+    private val gateway: BackupRestoreGateway,
+) {
+    suspend fun backup(path: String?, mode: String) {
+        gateway.backup(path, mode)
+    }
+
+    suspend fun restoreLocal(uri: String) {
+        gateway.restoreLocal(uri)
+    }
+}

+ 5 - 0
app/src/main/java/io/legado/app/domain/usecase/WebDavBackupUseCase.kt

@@ -19,6 +19,11 @@ class WebDavBackupUseCase(
         return webDavBackupGateway.test()
     }
 
+    suspend fun backup() {
+        webDavBackupGateway.syncConfig()
+        webDavBackupGateway.backup()
+    }
+
     suspend fun getBackupNames(): List<String> {
         webDavBackupGateway.syncConfig()
         return webDavBackupGateway.getBackupNames()

+ 39 - 23
app/src/main/java/io/legado/app/help/AppWebDav.kt

@@ -30,7 +30,8 @@ import io.legado.app.utils.normalizeFileName
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import splitties.init.appCtx
 import java.io.File
 
@@ -43,27 +44,26 @@ object AppWebDav {
     private val exportsWebDavUrl get() = "${rootWebDavUrl}books/"
     private val bgWebDavUrl get() = "${rootWebDavUrl}background/"
 
+    private val configMutex = Mutex()
+    private var appliedConfig: AppliedWebDavConfig? = null
+
+    @Volatile
     var authorization: Authorization? = null
         private set
 
+    @Volatile
     var defaultBookWebDav: RemoteBookWebDav? = null
 
     val isOk get() = authorization != null
 
     val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
 
-    init {
-        runBlocking {
-            upConfig()
-        }
-    }
-
     private val rootWebDavUrl: String
         get() {
             val configUrl = BackupConfig.webDavUrl.trim()
             var url = if (configUrl.isEmpty()) defaultWebDavUrl else configUrl
             if (!url.endsWith("/")) url = "${url}/"
-            AppConfig.webDavDir?.trim()?.let {
+            AppConfig.webDavDir.trim().let {
                 if (it.isNotEmpty()) {
                     url = "${url}${it}/"
                 }
@@ -72,25 +72,41 @@ object AppWebDav {
         }
 
     suspend fun upConfig() {
-        kotlin.runCatching {
-            authorization = null
-            defaultBookWebDav = null
-            val account = BackupConfig.webDavAccount
-            val password = BackupConfig.webDavPassword
-            if (!account.isNullOrEmpty() && !password.isNullOrEmpty()) {
-                val mAuthorization = Authorization(account, password)
-                checkAuthorization(mAuthorization)
-                WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
-                WebDav(bookProgressUrl, mAuthorization).makeAsDir()
-                WebDav(exportsWebDavUrl, mAuthorization).makeAsDir()
-                WebDav(bgWebDavUrl, mAuthorization).makeAsDir()
-                val rootBooksUrl = "${rootWebDavUrl}books/"
-                defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
-                authorization = mAuthorization
+        configMutex.withLock {
+            val config = AppliedWebDavConfig(
+                url = BackupConfig.webDavUrl,
+                account = BackupConfig.webDavAccount,
+                password = BackupConfig.webDavPassword,
+                dir = BackupConfig.webDavDir,
+            )
+            if (appliedConfig == config) return
+
+            kotlin.runCatching {
+                authorization = null
+                defaultBookWebDav = null
+                if (config.account.isNotEmpty() && config.password.isNotEmpty()) {
+                    val mAuthorization = Authorization(config.account, config.password)
+                    checkAuthorization(mAuthorization)
+                    WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
+                    WebDav(bookProgressUrl, mAuthorization).makeAsDir()
+                    WebDav(exportsWebDavUrl, mAuthorization).makeAsDir()
+                    WebDav(bgWebDavUrl, mAuthorization).makeAsDir()
+                    val rootBooksUrl = "${rootWebDavUrl}books/"
+                    defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
+                    authorization = mAuthorization
+                }
+                appliedConfig = config
             }
         }
     }
 
+    private data class AppliedWebDavConfig(
+        val url: String,
+        val account: String,
+        val password: String,
+        val dir: String,
+    )
+
     @Throws(WebDavException::class)
     private suspend fun checkAuthorization(authorization: Authorization) {
         if (!WebDav(rootWebDavUrl, authorization).check()) {

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

@@ -283,18 +283,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             BookshelfConfig.bookshelfLayoutGridPortrait = value
         }
 
-    var exploreLayoutGridLandscape: Int
-        get() = appCtx.getPrefInt(PreferKey.exploreLayoutGridLandscape, 7)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.exploreLayoutGridLandscape, value)
-        }
-
-    var exploreLayoutGridPortrait: Int
-        get() = appCtx.getPrefInt(PreferKey.exploreLayoutGridPortrait, 3)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.exploreLayoutGridPortrait, value)
-        }
-
     var bookshelfLayoutGridLandscape: Int
         get() = BookshelfConfig.bookshelfLayoutGridLandscape
         set(value) {
@@ -329,9 +317,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     val showDiscovery: Boolean
         get() = ThemeConfig.showDiscovery
 
-    val showHome: Boolean
-        get() = ThemeConfig.showHome
-
     val showRSS: Boolean
         get() = ThemeConfig.showRss
 
@@ -874,12 +859,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             BookshelfConfig.shouldShowExpandButton = value
         }
 
-    var exploreLayoutState: Int
-        get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.exploreLayoutState, value)
-        }
-
     var defaultSourceChangeAll: Boolean
         get() = ReadConfig.defaultSourceChangeAll
         set(value) {

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

@@ -174,7 +174,6 @@ object ThemeImportExport {
             customTagColorsJson = ThemeConfig.customTagColorsJson,
 
             // 主界面设置
-            showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
             showRss = ThemeConfig.showRss,
             showStatusBar = ThemeConfig.showStatusBar,
@@ -344,7 +343,6 @@ object ThemeImportExport {
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
         // 主界面设置
-        ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showRss = data.showRss
         ThemeConfig.showStatusBar = data.showStatusBar
@@ -581,7 +579,6 @@ data class ThemeExportData(
     val customTagColorsJson: String? = null,
 
     // 主界面设置
-    val showHome: Boolean = true,
     val showDiscovery: Boolean = true,
     val showRss: Boolean = true,
     val showStatusBar: Boolean = true,

+ 48 - 78
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt

@@ -14,9 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Cloud
-import androidx.compose.material.icons.filled.Lan
-import androidx.compose.material.icons.filled.PhoneAndroid
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material.icons.filled.VisibilityOff
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -58,8 +55,6 @@ import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
-import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
 import io.legado.app.ui.widget.components.settingItem.InputSettingItem
@@ -170,7 +165,6 @@ fun BackupConfigScreen(
             showLoadingDialog = true
             loadingText = context.getString(R.string.on_restore)
             viewModel.restore(
-                context = context,
                 uri = uri,
                 onSuccess = {
                     showLoadingDialog = false
@@ -451,86 +445,62 @@ fun BackupConfigScreen(
         }
     )
 
-    OptionSheet(
+    BackupOptionSheet(
         show = showBackupOptionSheet,
         onDismissRequest = { showBackupOptionSheet = false },
-        title = stringResource(R.string.backup)
-    ) {
-        OptionCard(
-            icon = Icons.Default.PhoneAndroid,
-            text = stringResource(R.string.backup_to_local),
-            onClick = {
-                showBackupOptionSheet = false
-                executeBackup("local", context, viewModel, scope, snackbarHostState, {
-                    showLoadingDialog = false
-                }, { showLoadingDialog = true; loadingText = it })
-            }
-        )
-        OptionCard(
-            icon = Icons.Default.Cloud,
-            text = stringResource(R.string.backup_to_network),
-            onClick = {
-                showBackupOptionSheet = false
-                executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
-                    showLoadingDialog = false
-                }, { showLoadingDialog = true; loadingText = it })
-            }
-        )
-        OptionCard(
-            icon = Icons.Default.Lan,
-            text = stringResource(R.string.backup_to_local_and_network),
-            onClick = {
-                showBackupOptionSheet = false
-                executeBackup("both", context, viewModel, scope, snackbarHostState, {
-                    showLoadingDialog = false
-                }, { showLoadingDialog = true; loadingText = it })
-            }
-        )
-    }
+        onBackupToLocal = {
+            showBackupOptionSheet = false
+            executeBackup("local", context, viewModel, scope, snackbarHostState, {
+                showLoadingDialog = false
+            }, { showLoadingDialog = true; loadingText = it })
+        },
+        onBackupToNetwork = {
+            showBackupOptionSheet = false
+            executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
+                showLoadingDialog = false
+            }, { showLoadingDialog = true; loadingText = it })
+        },
+        onBackupToLocalAndNetwork = {
+            showBackupOptionSheet = false
+            executeBackup("both", context, viewModel, scope, snackbarHostState, {
+                showLoadingDialog = false
+            }, { showLoadingDialog = true; loadingText = it })
+        },
+    )
 
-    OptionSheet(
+    RestoreOptionSheet(
         show = showRestoreOptionSheet,
         onDismissRequest = { showRestoreOptionSheet = false },
-        title = stringResource(R.string.restore)
-    ) {
-        OptionCard(
-            icon = Icons.Default.PhoneAndroid,
-            text = stringResource(R.string.restore_from_local),
-            onClick = {
-                showRestoreOptionSheet = false
-                restoreFileLauncher.launch(arrayOf("application/zip"))
-            }
-        )
-        OptionCard(
-            icon = Icons.Default.Cloud,
-            text = stringResource(R.string.restore_from_network),
-            onClick = {
-                showRestoreOptionSheet = false
-                scope.launch {
-                    showLoadingDialog = true
-                    loadingText = context.getString(R.string.loading)
-                    try {
-                        val names = viewModel.getBackupNames()
-                        backupNames = names
-                        showRestoreSheet = true
-                    } catch (e: Exception) {
-                        confirmDialogTitle = context.getString(R.string.restore)
-                        confirmDialogText =
-                            context.getString(
-                                R.string.webdav_restore_fallback_message,
-                                e.localizedMessage
-                            )
-                        onConfirmAction = {
-                            restoreFileLauncher.launch(arrayOf("application/zip"))
-                        }
-                        showConfirmDialog = true
-                    } finally {
-                        showLoadingDialog = false
+        onRestoreFromLocal = {
+            showRestoreOptionSheet = false
+            restoreFileLauncher.launch(arrayOf("application/zip"))
+        },
+        onRestoreFromNetwork = {
+            showRestoreOptionSheet = false
+            scope.launch {
+                showLoadingDialog = true
+                loadingText = context.getString(R.string.loading)
+                try {
+                    val names = viewModel.getBackupNames()
+                    backupNames = names
+                    showRestoreSheet = true
+                } catch (e: Exception) {
+                    confirmDialogTitle = context.getString(R.string.restore)
+                    confirmDialogText =
+                        context.getString(
+                            R.string.webdav_restore_fallback_message,
+                            e.localizedMessage
+                        )
+                    onConfirmAction = {
+                        restoreFileLauncher.launch(arrayOf("application/zip"))
                     }
+                    showConfirmDialog = true
+                } finally {
+                    showLoadingDialog = false
                 }
             }
-        )
-    }
+        },
+    )
 
     AppModalBottomSheet(
         show = showRestoreSheet && backupNames.isNotEmpty(),

+ 6 - 7
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt

@@ -1,20 +1,19 @@
 package io.legado.app.ui.config.backupConfig
 
-import android.content.Context
 import android.net.Uri
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
+import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
-import io.legado.app.help.storage.Backup
-import io.legado.app.help.storage.Restore
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 
 class BackupConfigViewModel(
-    private val webDavBackupUseCase: WebDavBackupUseCase
+    private val webDavBackupUseCase: WebDavBackupUseCase,
+    private val backupRestoreUseCase: BackupRestoreUseCase,
 ) : ViewModel() {
 
     private suspend fun syncWebDavConfig() {
@@ -53,7 +52,7 @@ class BackupConfigViewModel(
     fun backup(backupPath: String, mode: String = "both", onSuccess: () -> Unit, onError: (String) -> Unit) {
         viewModelScope.launch(Dispatchers.IO) {
             try {
-                Backup.backupLocked(appCtx, backupPath, mode)
+                backupRestoreUseCase.backup(backupPath, mode)
                 withContext(Dispatchers.Main) {
                     onSuccess()
                 }
@@ -86,10 +85,10 @@ class BackupConfigViewModel(
         }
     }
 
-    fun restore(context: Context, uri: Uri, onSuccess: () -> Unit, onError: (String) -> Unit) {
+    fun restore(uri: Uri, onSuccess: () -> Unit, onError: (String) -> Unit) {
         viewModelScope.launch(Dispatchers.IO) {
             try {
-                Restore.restore(context, uri)
+                backupRestoreUseCase.restoreLocal(uri.toString())
                 withContext(Dispatchers.Main) {
                     onSuccess()
                 }

+ 70 - 0
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupRestoreOptionSheets.kt

@@ -0,0 +1,70 @@
+package io.legado.app.ui.config.backupConfig
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.Lan
+import androidx.compose.material.icons.filled.PhoneAndroid
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import io.legado.app.R
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BackupOptionSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    onBackupToLocal: () -> Unit,
+    onBackupToNetwork: () -> Unit,
+    onBackupToLocalAndNetwork: () -> Unit,
+) {
+    OptionSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.backup),
+    ) {
+        OptionCard(
+            icon = Icons.Default.PhoneAndroid,
+            text = stringResource(R.string.backup_to_local),
+            onClick = onBackupToLocal,
+        )
+        OptionCard(
+            icon = Icons.Default.Cloud,
+            text = stringResource(R.string.backup_to_network),
+            onClick = onBackupToNetwork,
+        )
+        OptionCard(
+            icon = Icons.Default.Lan,
+            text = stringResource(R.string.backup_to_local_and_network),
+            onClick = onBackupToLocalAndNetwork,
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RestoreOptionSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    onRestoreFromLocal: () -> Unit,
+    onRestoreFromNetwork: () -> Unit,
+) {
+    OptionSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.restore),
+    ) {
+        OptionCard(
+            icon = Icons.Default.PhoneAndroid,
+            text = stringResource(R.string.restore_from_local),
+            onClick = onRestoreFromLocal,
+        )
+        OptionCard(
+            icon = Icons.Default.Cloud,
+            text = stringResource(R.string.restore_from_network),
+            onClick = onRestoreFromNetwork,
+        )
+    }
+}

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

@@ -144,8 +144,6 @@ object ThemeConfig {
 
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
 
-    var showHome by prefDelegate(PreferKey.showHome, true)
-
     var showRss by prefDelegate(PreferKey.showRss, true)
 
     var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)

+ 0 - 5
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -375,11 +375,6 @@ fun ThemeConfigScreen(
                 }
 
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
-                    SwitchSettingItem(
-                        title = stringResource(R.string.show_home),
-                        checked = ThemeConfig.showHome,
-                        onCheckedChange = { ThemeConfig.showHome = it }
-                    )
                     SwitchSettingItem(
                         title = stringResource(R.string.show_discovery),
                         checked = ThemeConfig.showDiscovery,

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

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

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

@@ -35,6 +35,8 @@ sealed class MainDestination(
     )
 
     companion object {
+        const val DISCOVERY_MODULES_ROUTE = "explore_modules"
+
         val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
     }
 }

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -117,6 +117,9 @@ fun MainActivity.mainEntryProvider(
             onNavigateToBookCacheManage = {
                 onNavigateToRoute(MainRouteBookCacheManage)
             },
+            onNavigateToBackupSettings = {
+                onNavigateToRoute(MainRouteSettingsBackup)
+            },
             onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
                 onNavigateToRoute(
                     MainRouteBookInfo(

+ 29 - 16
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -71,7 +71,7 @@ import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.bookshelf.BookshelfScreen
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreScreen
-import io.legado.app.ui.main.homepage.HomepageScreen
+import io.legado.app.ui.main.home.HomeRouteScreen
 import io.legado.app.ui.main.my.MyScreen
 import io.legado.app.ui.main.my.PrefClickEvent
 import io.legado.app.ui.main.rss.RssScreen
@@ -111,6 +111,7 @@ fun MainScreen(
     onNavigateToLocalImport: () -> Unit,
     onNavigateToCache: (Long) -> Unit,
     onNavigateToBookCacheManage: () -> Unit,
+    onNavigateToBackupSettings: () -> Unit,
     onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
     onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
     onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
@@ -177,7 +178,14 @@ fun MainScreen(
     val destinations = mainUiState.destinations
 
     val initialPage = remember(destinations, mainUiState.defaultHomePage) {
-        val index = destinations.indexOfFirst { it.route == mainUiState.defaultHomePage }
+        val defaultRoute = if (
+            mainUiState.defaultHomePage == MainDestination.DISCOVERY_MODULES_ROUTE
+        ) {
+            MainDestination.Explore.route
+        } else {
+            mainUiState.defaultHomePage
+        }
+        val index = destinations.indexOfFirst { it.route == defaultRoute }
         if (index != -1) index else 0
     }
     val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
@@ -370,20 +378,11 @@ fun MainScreen(
                     ) { page ->
                         val destination = destinations.getOrNull(page) ?: return@HorizontalPager
                         when (destination) {
-                            MainDestination.Home -> HomepageScreen(
-                                onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
-                                    onNavigateToBookInfo(
-                                        name ?: "",
-                                        author ?: "",
-                                        bookUrl,
-                                        origin,
-                                        coverPath,
-                                        sharedCoverKey
-                                    )
-                                },
-                                onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
-                                    onNavigateToExploreShow(title, sourceUrl, exploreUrl)
+                            MainDestination.Home -> HomeRouteScreen(
+                                onOpenBook = { book ->
+                                    context.startActivityForBook(book)
                                 },
+                                onOpenBackupSettings = onNavigateToBackupSettings,
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
                             )
@@ -411,7 +410,21 @@ fun MainScreen(
                             )
 
                             MainDestination.Explore -> ExploreScreen(
-                                onOpenExploreShow = onNavigateToExploreShow
+                                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 ->

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

@@ -6,9 +6,12 @@ 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
@@ -23,7 +26,8 @@ import kotlinx.coroutines.launch
 class MainViewModel(
     application: Application,
     private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
-    private val webDavBackupUseCase: WebDavBackupUseCase
+    private val webDavBackupUseCase: WebDavBackupUseCase,
+    private val localPreferencesRepository: LocalPreferencesRepository,
 ) : BaseViewModel(application) {
 
     private val _uiState = MutableStateFlow(readMainUiState())
@@ -37,8 +41,18 @@ class MainViewModel(
             snapshotFlow {
                 readMainUiState()
             }.collect { newState ->
-                if (_uiState.value != newState) {
-                    _uiState.value = 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))
                 }
             }
         }
@@ -75,6 +89,17 @@ 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))
@@ -132,14 +157,14 @@ data class MainUiState(
     val useFloatingBottomBar: Boolean = false,
     val useFloatingBottomBarLiquidGlass: Boolean = false,
     val labelVisibilityMode: String = "auto",
-    val navExtended: Boolean = false
+    val navExtended: Boolean = false,
+    val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
 )
 
 private fun MainViewModel.readMainUiState(): MainUiState {
     val destinations = MainDestination.mainDestinations.filter {
         when (it) {
             MainDestination.Explore -> ThemeConfig.showDiscovery
-            MainDestination.Home -> ThemeConfig.showHome
             MainDestination.Rss -> ThemeConfig.showRss
             else -> true
         }
@@ -151,6 +176,6 @@ private fun MainViewModel.readMainUiState(): MainUiState {
         useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
         useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
         labelVisibilityMode = ThemeConfig.labelVisibilityMode,
-        navExtended = ThemeConfig.navExtended
+        navExtended = ThemeConfig.navExtended,
     )
 }

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

@@ -2,10 +2,16 @@ 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
@@ -26,6 +32,7 @@ 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
@@ -40,6 +47,7 @@ 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
@@ -59,6 +67,7 @@ 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
@@ -86,8 +95,64 @@ 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)
+                    },
+                )
+            }
+        }
+    }
+}
+
+@OptIn(
+    ExperimentalMaterial3Api::class,
+    ExperimentalFoundationApi::class,
+    ExperimentalSharedTransitionApi::class,
+)
+@Composable
+private fun ExploreDiscoveryScreen(
     viewModel: ExploreViewModel = koinViewModel(),
-    onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit
+    onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
+    onSwitchToHomepage: () -> Unit,
 ) {
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
@@ -143,6 +208,14 @@ fun ExploreScreen(
         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),

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

@@ -0,0 +1,12 @@
+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
+        }
+    }
+}

+ 97 - 0
app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt

@@ -0,0 +1,97 @@
+package io.legado.app.ui.main.home
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.Book
+
+@Stable
+data class HomeUiState(
+    val totalReadBooks: Int = 0,
+    val totalReadTimeMillis: Long = 0L,
+    val todayReadTimeMillis: Long = 0L,
+    val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
+    val recentBook: HomeRecentBookUi? = null,
+    val latestBackup: HomeBackupUi? = null,
+    val isBackupLoading: Boolean = true,
+    val isBackupActionRunning: Boolean = false,
+    val activeDialog: HomeDialog? = null,
+    val activeSheet: HomeSheet? = null,
+)
+
+@Stable
+data class HomeRecentBookUi(
+    val bookUrl: String?,
+    val name: String,
+    val author: String,
+    val origin: String?,
+    val coverPath: String?,
+    val chapterTitle: String?,
+    val chapterProgress: Float?,
+)
+
+@Stable
+data class HomeBackupUi(
+    val name: String,
+    val lastModify: Long,
+)
+
+sealed interface HomeIntent {
+    data object RecentBookClick : HomeIntent
+    data object ReadingGoalClick : HomeIntent
+    data class UpdateReadingGoal(val minutes: Int) : HomeIntent
+    data object BackupClick : HomeIntent
+    data class BackupDestinationSelected(val destination: HomeBackupDestination) : HomeIntent
+    data class BackupDirectorySelected(
+        val destination: HomeBackupDestination,
+        val path: String,
+    ) : HomeIntent
+
+    data object RestoreClick : HomeIntent
+    data object RestoreFromLocal : HomeIntent
+    data object RestoreFromNetwork : HomeIntent
+    data class RestoreLocalFileSelected(val uri: String) : HomeIntent
+    data object ConfirmRestore : HomeIntent
+    data object BackupSettingsClick : HomeIntent
+    data object DismissDialog : HomeIntent
+    data object DismissSheet : HomeIntent
+}
+
+sealed interface HomeEffect {
+    data class OpenBook(val book: Book) : HomeEffect
+    data object OpenBackupSettings : HomeEffect
+    data class SelectBackupDirectory(
+        val destination: HomeBackupDestination,
+    ) : HomeEffect
+
+    data class RequestBackupStoragePermission(
+        val destination: HomeBackupDestination,
+        val path: String,
+    ) : HomeEffect
+
+    data object SelectRestoreFile : HomeEffect
+    data class ShowMessage(
+        @param:StringRes val messageRes: Int,
+        val detail: String? = null,
+    ) : HomeEffect
+}
+
+@Stable
+sealed interface HomeDialog {
+    data class SetReadingGoal(val currentMinutes: Int) : HomeDialog
+    data class ConfirmRestore(val backupName: String) : HomeDialog
+}
+
+@Stable
+sealed interface HomeSheet {
+    data object BackupOptions : HomeSheet
+    data object RestoreOptions : HomeSheet
+}
+
+enum class HomeBackupDestination(val mode: String) {
+    Local("local"),
+    WebDav("webdav"),
+    LocalAndWebDav("both"),
+}
+
+const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
+const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60

+ 837 - 0
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt

@@ -0,0 +1,837 @@
+package io.legado.app.ui.main.home
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.CloudSync
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.TrackChanges
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.FirstBaseline
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.vector.ImageVector
+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.input.KeyboardType
+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.Book
+import io.legado.app.lib.permission.Permissions
+import io.legado.app.lib.permission.PermissionsCompat
+import io.legado.app.ui.config.backupConfig.BackupOptionSheet
+import io.legado.app.ui.config.backupConfig.RestoreOptionSheet
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.image.cover.BookshelfCover
+import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.takePersistablePermissionSafely
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+import java.text.DateFormat
+import java.util.Date
+import kotlin.math.roundToInt
+
+@Composable
+fun HomeRouteScreen(
+    onOpenBook: (Book) -> Unit,
+    onOpenBackupSettings: () -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    viewModel: HomeViewModel = koinViewModel(),
+) {
+    val context = LocalContext.current
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    var pendingBackupDestination by remember {
+        mutableStateOf<HomeBackupDestination?>(null)
+    }
+    val backupDirectoryLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocumentTree(),
+    ) { uri ->
+        val destination = pendingBackupDestination
+        pendingBackupDestination = null
+        if (uri != null && destination != null) {
+            uri.takePersistablePermissionSafely(context)
+            val path = if (uri.isContentScheme()) {
+                uri.toString()
+            } else {
+                uri.path.orEmpty()
+            }
+            if (path.isNotEmpty()) {
+                viewModel.onIntent(
+                    HomeIntent.BackupDirectorySelected(
+                        destination = destination,
+                        path = path,
+                    )
+                )
+            }
+        }
+    }
+    val restoreFileLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocument(),
+    ) { uri ->
+        uri?.let {
+            viewModel.onIntent(HomeIntent.RestoreLocalFileSelected(it.toString()))
+        }
+    }
+
+    LaunchedEffect(viewModel, context) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is HomeEffect.OpenBook -> onOpenBook(effect.book)
+                HomeEffect.OpenBackupSettings -> onOpenBackupSettings()
+                is HomeEffect.SelectBackupDirectory -> {
+                    pendingBackupDestination = effect.destination
+                    runCatching { backupDirectoryLauncher.launch(null) }
+                }
+
+                is HomeEffect.RequestBackupStoragePermission -> {
+                    PermissionsCompat.Builder()
+                        .addPermissions(*Permissions.Group.STORAGE)
+                        .rationale(R.string.tip_perm_request_storage)
+                        .onGranted {
+                            viewModel.onIntent(
+                                HomeIntent.BackupDirectorySelected(
+                                    destination = effect.destination,
+                                    path = effect.path,
+                                )
+                            )
+                        }
+                        .request()
+                }
+
+                HomeEffect.SelectRestoreFile -> {
+                    restoreFileLauncher.launch(arrayOf("application/zip"))
+                }
+
+                is HomeEffect.ShowMessage -> {
+                    val message = buildString {
+                        append(context.getString(effect.messageRes))
+                        effect.detail?.takeIf { it.isNotBlank() }?.let {
+                            append('\n')
+                            append(it)
+                        }
+                    }
+                    context.toastOnUi(message)
+                }
+            }
+        }
+    }
+
+    HomeScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        sharedTransitionScope = sharedTransitionScope,
+        animatedVisibilityScope = animatedVisibilityScope,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
+@Composable
+fun HomeScreen(
+    state: HomeUiState,
+    onIntent: (HomeIntent) -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        contentWindowInsets = WindowInsets(0),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.home),
+                scrollBehavior = scrollBehavior,
+            )
+        },
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding() + 8.dp,
+                bottom = 120.dp,
+            ),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            item(key = "recent_book") {
+                RecentBookCard(
+                    book = state.recentBook,
+                    onClick = { onIntent(HomeIntent.RecentBookClick) },
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                )
+            }
+
+            item(key = "statistics") {
+                Row(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.spacedBy(12.dp),
+                ) {
+                    StatisticCard(
+                        modifier = Modifier.weight(1f),
+                        icon = Icons.AutoMirrored.Filled.MenuBook,
+                        title = stringResource(R.string.home_total_read_books),
+                        value = state.totalReadBooks.toString(),
+                        unit = stringResource(R.string.unit_books),
+                    )
+                    StatisticCard(
+                        modifier = Modifier.weight(1f),
+                        icon = Icons.Default.AccessTime,
+                        title = stringResource(R.string.home_total_reading_time),
+                        value = String.format("%.1f", state.totalReadTimeMillis / 3_600_000.0),
+                        unit = stringResource(R.string.unit_hours),
+                    )
+                }
+            }
+
+            item(key = "daily_goal") {
+                ReadingGoalCard(
+                    todayReadTimeMillis = state.todayReadTimeMillis,
+                    goalMinutes = state.dailyGoalMinutes,
+                    onClick = { onIntent(HomeIntent.ReadingGoalClick) },
+                )
+            }
+
+            item(key = "webdav") {
+                WebDavBackupCard(
+                    latestBackup = state.latestBackup,
+                    isLoading = state.isBackupLoading,
+                    isActionRunning = state.isBackupActionRunning,
+                    onBackup = { onIntent(HomeIntent.BackupClick) },
+                    onRestore = { onIntent(HomeIntent.RestoreClick) },
+                    onOpenSettings = { onIntent(HomeIntent.BackupSettingsClick) },
+                )
+            }
+        }
+    }
+
+    HomeDialogs(
+        dialog = state.activeDialog,
+        onIntent = onIntent,
+    )
+    HomeSheets(
+        sheet = state.activeSheet,
+        onIntent = onIntent,
+    )
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun RecentBookCard(
+    book: HomeRecentBookUi?,
+    onClick: () -> Unit,
+    sharedTransitionScope: SharedTransitionScope?,
+    animatedVisibilityScope: AnimatedVisibilityScope?,
+) {
+    NormalCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(20.dp)),
+        onClick = if (book?.bookUrl != null) onClick else null,
+        cornerRadius = 20.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+    ) {
+        Box(modifier = Modifier.fillMaxWidth()) {
+            book?.chapterProgress?.let { progress ->
+                RecentReadingProgress(
+                    progress = progress,
+                    modifier = Modifier
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth(),
+                )
+            }
+
+            Column(
+                modifier = Modifier.padding(16.dp),
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.spacedBy(10.dp),
+                ) {
+                    AppIcon(
+                        imageVector = Icons.AutoMirrored.Filled.MenuBook,
+                        contentDescription = null,
+                        tint = LegadoTheme.colorScheme.primary,
+                    )
+                    AppText(
+                        text = stringResource(R.string.home_recent_reading),
+                        style = LegadoTheme.typography.titleMediumEmphasized,
+                    )
+                }
+
+                if (book == null) {
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .height(96.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                        horizontalArrangement = Arrangement.Center,
+                    ) {
+                        AppText(
+                            text = stringResource(R.string.home_no_recent_reading),
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                    }
+                } else {
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.spacedBy(16.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        BookshelfCover(
+                            name = book.name,
+                            author = book.author,
+                            path = book.coverPath,
+                            sourceOrigin = book.origin,
+                            modifier = Modifier
+                                .width(72.dp)
+                                .aspectRatio(5f / 7f),
+                            coverModifier = Modifier.fillMaxSize(),
+                            sharedTransitionScope = sharedTransitionScope,
+                            animatedVisibilityScope = animatedVisibilityScope,
+                            sharedCoverKey = book.bookUrl?.let { "home_recent_$it" },
+                        )
+                        Column(
+                            modifier = Modifier.weight(1f),
+                            verticalArrangement = Arrangement.spacedBy(6.dp),
+                        ) {
+                            AppText(
+                                text = book.name,
+                                style = LegadoTheme.typography.titleMediumEmphasized,
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                            AppText(
+                                text = book.author,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                maxLines = 1,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                            val chapterTitle = book.chapterTitle?.takeIf { it.isNotBlank() }
+                            if (chapterTitle != null || book.chapterProgress != null) {
+                                Row(
+                                    modifier = Modifier.fillMaxWidth(),
+                                    verticalAlignment = Alignment.CenterVertically,
+                                    horizontalArrangement = Arrangement.spacedBy(6.dp),
+                                ) {
+                                    chapterTitle?.let { chapter ->
+                                        AppText(
+                                            text = chapter,
+                                            modifier = Modifier
+                                                .weight(1f)
+                                                .basicMarquee(),
+                                            style = LegadoTheme.typography.bodySmall,
+                                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                            maxLines = 1,
+                                            softWrap = false,
+                                            overflow = TextOverflow.Clip,
+                                        )
+                                    } ?: Spacer(modifier = Modifier.weight(1f))
+                                    book.chapterProgress?.let { progress ->
+                                        val percent = remember(progress) {
+                                            (progress * 100f)
+                                                .roundToInt()
+                                                .coerceIn(0, 100)
+                                        }
+                                        TextCard(
+                                            text = "$percent%",
+                                            backgroundColor = LegadoTheme.colorScheme.secondaryContainer,
+                                            contentColor = LegadoTheme.colorScheme.onSecondaryContainer,
+                                            cornerRadius = 6.dp,
+                                            horizontalPadding = 6.dp,
+                                            verticalPadding = 2.dp,
+                                        )
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun RecentReadingProgress(
+    progress: Float,
+    modifier: Modifier = Modifier,
+) {
+    val animatedProgress by animateFloatAsState(
+        targetValue = progress.coerceIn(0f, 1f),
+        label = "RecentReadingProgress",
+    )
+    val progressColor = LegadoTheme.colorScheme.primary
+    val trackColor = LegadoTheme.colorScheme.onSurface.copy(alpha = 0.10f)
+
+    Canvas(
+        modifier = modifier.height(48.dp),
+    ) {
+        val progressWidth = size.width * animatedProgress
+        val barHeight = 3.dp.toPx()
+        val glowHeight = size.height - barHeight
+        val stripHeight = 1.dp.toPx().coerceAtLeast(1f)
+        val maxDiffusion = 48.dp.toPx()
+
+        if (progressWidth > 0f) {
+            var y = 0f
+            while (y < glowHeight) {
+                val distanceFromTop = (y / glowHeight).coerceIn(0f, 1f)
+                val alpha = 0.18f * distanceFromTop * distanceFromTop
+                val diffusion = maxDiffusion * (1f - distanceFromTop)
+                val fadeStart = (progressWidth - diffusion * 0.15f)
+                    .coerceAtLeast(0f)
+                val fadeEnd = (progressWidth + diffusion)
+                    .coerceAtMost(size.width)
+                val height = stripHeight.coerceAtMost(glowHeight - y)
+
+                if (alpha > 0.001f && fadeEnd > fadeStart) {
+                    drawRect(
+                        brush = Brush.horizontalGradient(
+                            colors = listOf(
+                                progressColor.copy(alpha = alpha),
+                                progressColor.copy(alpha = 0f),
+                            ),
+                            startX = fadeStart,
+                            endX = fadeEnd,
+                        ),
+                        topLeft = Offset(0f, y),
+                        size = Size(fadeEnd, height),
+                    )
+                }
+                y += stripHeight
+            }
+        }
+        drawRect(
+            color = trackColor,
+            topLeft = Offset(0f, size.height - barHeight),
+            size = Size(size.width, barHeight),
+        )
+        drawRect(
+            color = progressColor,
+            topLeft = Offset(0f, size.height - barHeight),
+            size = Size(progressWidth, barHeight),
+        )
+    }
+}
+
+@Composable
+private fun StatisticCard(
+    modifier: Modifier,
+    icon: ImageVector,
+    title: String,
+    value: String,
+    unit: String,
+) {
+    NormalCard(
+        modifier = modifier,
+        cornerRadius = 20.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(
+                    start = 16.dp,
+                    top = 10.dp,
+                    end = 16.dp,
+                    bottom = 8.dp,
+                ),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            AppIcon(
+                imageVector = icon,
+                contentDescription = null,
+                tint = LegadoTheme.colorScheme.primary,
+                modifier = Modifier.size(20.dp),
+            )
+            Column {
+                AppText(
+                    text = title,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                Row {
+                    AppText(
+                        text = value,
+                        style = LegadoTheme.typography.titleMediumEmphasized,
+                        maxLines = 1,
+                        modifier = Modifier
+                            .alignBy(FirstBaseline)
+                            .basicMarquee()
+                    )
+                    AppText(
+                        text = unit,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        modifier = Modifier
+                            .weight(1f)
+                            .alignBy(FirstBaseline)
+                            .padding(start = 2.dp)
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ReadingGoalCard(
+    todayReadTimeMillis: Long,
+    goalMinutes: Int,
+    onClick: () -> Unit,
+) {
+    val todayMinutes = (todayReadTimeMillis / 60_000L).toInt()
+    val progress = (todayReadTimeMillis / 60_000f / goalMinutes)
+        .coerceIn(0f, 1f)
+
+    NormalCard(
+        modifier = Modifier.fillMaxWidth(),
+        onClick = onClick,
+        cornerRadius = 20.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(10.dp),
+            ) {
+                AppIcon(
+                    imageVector = Icons.Default.TrackChanges,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                )
+                AppText(
+                    text = stringResource(R.string.home_today_reading_goal),
+                    style = LegadoTheme.typography.titleMediumEmphasized,
+                )
+                Spacer(modifier = Modifier.weight(1f))
+                MediumTonalButton(
+                    onClick = onClick,
+                    icon = Icons.Default.Edit,
+                    contentDescription = stringResource(R.string.home_set_goal),
+                )
+            }
+
+            SemiCircleProgress(
+                progress = progress,
+                modifier = Modifier
+                    .width(240.dp)
+                    .padding(vertical = 8.dp)
+                    .aspectRatio(2f),
+            ) {
+                Column(
+                    horizontalAlignment = Alignment.CenterHorizontally,
+                ) {
+                    AppText(
+                        text = stringResource(
+                            R.string.home_today_goal_value,
+                            todayMinutes,
+                            goalMinutes,
+                        ),
+                        style = LegadoTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                    )
+                    AppText(
+                        text = stringResource(R.string.home_minutes),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun SemiCircleProgress(
+    progress: Float,
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) {
+    val animatedProgress by animateFloatAsState(
+        targetValue = progress,
+        label = "DailyReadingProgress",
+    )
+    val trackColor = LegadoTheme.colorScheme.surfaceContainerHighest
+    val progressColor = LegadoTheme.colorScheme.primary
+
+    Box(
+        modifier = modifier,
+        contentAlignment = Alignment.BottomCenter,
+    ) {
+        Canvas(modifier = Modifier.fillMaxSize()) {
+            val strokeWidth = 14.dp.toPx()
+            val arcSize = size.width - strokeWidth
+            val topLeft = Offset(strokeWidth / 2f, strokeWidth / 2f)
+            val boundingSize = Size(arcSize, arcSize)
+            drawArc(
+                color = trackColor,
+                startAngle = 180f,
+                sweepAngle = 180f,
+                useCenter = false,
+                topLeft = topLeft,
+                size = boundingSize,
+                style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+            )
+            drawArc(
+                color = progressColor,
+                startAngle = 180f,
+                sweepAngle = 180f * animatedProgress,
+                useCenter = false,
+                topLeft = topLeft,
+                size = boundingSize,
+                style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+            )
+        }
+        content()
+    }
+}
+
+@Composable
+private fun WebDavBackupCard(
+    latestBackup: HomeBackupUi?,
+    isLoading: Boolean,
+    isActionRunning: Boolean,
+    onBackup: () -> Unit,
+    onRestore: () -> Unit,
+    onOpenSettings: () -> Unit,
+) {
+    val lastBackupText = when {
+        isLoading -> stringResource(R.string.home_loading_webdav_backup)
+        latestBackup != null -> {
+            val date = remember(latestBackup.lastModify) {
+                DateFormat.getDateTimeInstance(
+                    DateFormat.MEDIUM,
+                    DateFormat.SHORT,
+                ).format(Date(latestBackup.lastModify))
+            }
+            stringResource(R.string.home_latest_backup_value, date)
+        }
+
+        else -> stringResource(R.string.home_no_webdav_backup)
+    }
+
+    NormalCard(
+        modifier = Modifier.fillMaxWidth(),
+        cornerRadius = 20.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+    ) {
+        Column(
+            modifier = Modifier.padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(10.dp),
+            ) {
+                AppIcon(
+                    imageVector = Icons.Default.CloudSync,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.offset(y = 1.dp),
+                )
+                Column(modifier = Modifier.weight(1f)) {
+                    AppText(
+                        text = stringResource(R.string.home_webdav_backup),
+                        style = LegadoTheme.typography.titleMediumEmphasized,
+                    )
+                    AppText(
+                        text = lastBackupText,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+                if (isLoading || isActionRunning) {
+                    AppContainedLoadingIndicator(
+                        modifier = Modifier.size(24.dp)
+                    )
+                }
+            }
+
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                SmallTonalButton(
+                    modifier = Modifier.weight(1f),
+                    onClick = onBackup,
+                    enabled = !isActionRunning,
+                    icon = Icons.Default.CloudUpload,
+                    text = stringResource(R.string.backup),
+                )
+                SmallTonalButton(
+                    modifier = Modifier.weight(1f),
+                    onClick = onRestore,
+                    enabled = !isActionRunning,
+                    icon = Icons.Default.CloudDownload,
+                    text = stringResource(R.string.restore),
+                )
+                SmallTonalButton(
+                    modifier = Modifier.weight(1f),
+                    onClick = onOpenSettings,
+                    enabled = !isActionRunning,
+                    icon = Icons.Default.Settings,
+                    text = stringResource(R.string.setting),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun HomeDialogs(
+    dialog: HomeDialog?,
+    onIntent: (HomeIntent) -> Unit,
+) {
+    val goalDialog = dialog as? HomeDialog.SetReadingGoal
+    var goalInput by remember(goalDialog?.currentMinutes) {
+        mutableStateOf(goalDialog?.currentMinutes?.toString().orEmpty())
+    }
+    val goalMinutes = goalInput.toIntOrNull()
+    val isGoalValid = goalMinutes != null &&
+            goalMinutes in 1..MAX_DAILY_READING_GOAL_MINUTES
+
+    AppAlertDialog(
+        show = goalDialog != null,
+        onDismissRequest = { onIntent(HomeIntent.DismissDialog) },
+        title = stringResource(R.string.home_set_reading_goal),
+        content = {
+            OutlinedTextField(
+                value = goalInput,
+                onValueChange = { value ->
+                    goalInput = value.filter(Char::isDigit)
+                },
+                modifier = Modifier.fillMaxWidth(),
+                label = {
+                    AppText(stringResource(R.string.home_goal_minutes_label))
+                },
+                singleLine = true,
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+            )
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = if (isGoalValid) {
+            {
+                onIntent(HomeIntent.UpdateReadingGoal(goalMinutes))
+            }
+        } else {
+            null
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(HomeIntent.DismissDialog) },
+    )
+
+    val restoreDialog = dialog as? HomeDialog.ConfirmRestore
+    AppAlertDialog(
+        show = restoreDialog != null,
+        onDismissRequest = { onIntent(HomeIntent.DismissDialog) },
+        title = stringResource(R.string.restore_confirmation),
+        text = restoreDialog?.let {
+            stringResource(R.string.home_webdav_restore_confirmation, it.backupName)
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { onIntent(HomeIntent.ConfirmRestore) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(HomeIntent.DismissDialog) },
+    )
+}
+
+@Composable
+private fun HomeSheets(
+    sheet: HomeSheet?,
+    onIntent: (HomeIntent) -> Unit,
+) {
+    BackupOptionSheet(
+        show = sheet is HomeSheet.BackupOptions,
+        onDismissRequest = { onIntent(HomeIntent.DismissSheet) },
+        onBackupToLocal = {
+            onIntent(
+                HomeIntent.BackupDestinationSelected(HomeBackupDestination.Local)
+            )
+        },
+        onBackupToNetwork = {
+            onIntent(
+                HomeIntent.BackupDestinationSelected(HomeBackupDestination.WebDav)
+            )
+        },
+        onBackupToLocalAndNetwork = {
+            onIntent(
+                HomeIntent.BackupDestinationSelected(HomeBackupDestination.LocalAndWebDav)
+            )
+        },
+    )
+    RestoreOptionSheet(
+        show = sheet is HomeSheet.RestoreOptions,
+        onDismissRequest = { onIntent(HomeIntent.DismissSheet) },
+        onRestoreFromLocal = { onIntent(HomeIntent.RestoreFromLocal) },
+        onRestoreFromNetwork = { onIntent(HomeIntent.RestoreFromNetwork) },
+    )
+}

+ 326 - 0
app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt

@@ -0,0 +1,326 @@
+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.WebDavBackup
+import io.legado.app.domain.usecase.BackupRestoreUseCase
+import io.legado.app.domain.usecase.WebDavBackupUseCase
+import io.legado.app.ui.config.backupConfig.BackupConfig
+import io.legado.app.utils.isContentScheme
+import kotlinx.coroutines.Dispatchers
+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.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+
+class HomeViewModel(
+    private val readRecordRepository: ReadRecordRepository,
+    private val bookRepository: BookRepository,
+    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val webDavBackupUseCase: WebDavBackupUseCase,
+    private val backupRestoreUseCase: BackupRestoreUseCase,
+) : ViewModel() {
+
+    private val _backupState = MutableStateFlow(HomeBackupState())
+    private val _activeDialog = MutableStateFlow<HomeDialog?>(null)
+    private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
+    private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    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
+                        }
+                    },
+                )
+            },
+        )
+    }
+
+    val uiState = combine(
+        dashboardData,
+        _backupState,
+        _activeDialog,
+        _activeSheet,
+    ) { dashboard, backup, dialog, sheet ->
+        HomeUiState(
+            totalReadBooks = dashboard.totalReadBooks,
+            totalReadTimeMillis = dashboard.totalReadTimeMillis,
+            todayReadTimeMillis = dashboard.todayReadTimeMillis,
+            dailyGoalMinutes = dashboard.dailyGoalMinutes,
+            recentBook = dashboard.recentBook,
+            latestBackup = backup.latest?.toUi(),
+            isBackupLoading = backup.isLoading,
+            isBackupActionRunning = backup.isActionRunning,
+            activeDialog = dialog,
+            activeSheet = sheet,
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000),
+        initialValue = HomeUiState(),
+    )
+
+    init {
+        refreshLatestBackup()
+    }
+
+    fun onIntent(intent: HomeIntent) {
+        when (intent) {
+            HomeIntent.RecentBookClick -> openRecentBook()
+            HomeIntent.ReadingGoalClick -> {
+                _activeDialog.value = HomeDialog.SetReadingGoal(
+                    uiState.value.dailyGoalMinutes
+                )
+            }
+
+            is HomeIntent.UpdateReadingGoal -> updateReadingGoal(intent.minutes)
+            HomeIntent.BackupClick -> _activeSheet.value = HomeSheet.BackupOptions
+            is HomeIntent.BackupDestinationSelected -> {
+                requestBackup(intent.destination)
+            }
+
+            is HomeIntent.BackupDirectorySelected -> {
+                backup(
+                    destination = intent.destination,
+                    path = intent.path,
+                    savePath = true,
+                )
+            }
+
+            HomeIntent.RestoreClick -> _activeSheet.value = HomeSheet.RestoreOptions
+            HomeIntent.RestoreFromLocal -> {
+                _activeSheet.value = null
+                _effects.tryEmit(HomeEffect.SelectRestoreFile)
+            }
+
+            HomeIntent.RestoreFromNetwork -> {
+                _activeSheet.value = null
+                requestRestore()
+            }
+
+            is HomeIntent.RestoreLocalFileSelected -> restoreLocal(intent.uri)
+            HomeIntent.ConfirmRestore -> restore()
+            HomeIntent.BackupSettingsClick -> {
+                _effects.tryEmit(HomeEffect.OpenBackupSettings)
+            }
+
+            HomeIntent.DismissDialog -> _activeDialog.value = null
+            HomeIntent.DismissSheet -> _activeSheet.value = null
+        }
+    }
+
+    private fun openRecentBook() {
+        val bookUrl = uiState.value.recentBook?.bookUrl ?: return
+        viewModelScope.launch {
+            bookRepository.getBook(bookUrl)?.let {
+                _effects.emit(HomeEffect.OpenBook(it))
+            }
+        }
+    }
+
+    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,
+            )
+        }
+    }
+
+    private fun requestRestore() {
+        val backup = _backupState.value.latest
+        if (backup == null) {
+            _effects.tryEmit(HomeEffect.ShowMessage(R.string.home_no_webdav_backup))
+            return
+        }
+        _activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
+    }
+
+    private fun requestBackup(destination: HomeBackupDestination) {
+        _activeSheet.value = null
+        if (destination == HomeBackupDestination.WebDav) {
+            backup(destination = destination, path = null)
+            return
+        }
+        viewModelScope.launch(Dispatchers.IO) {
+            val path = BackupConfig.backupPath
+            if (path.isNullOrBlank()) {
+                _effects.emit(HomeEffect.SelectBackupDirectory(destination))
+            } else if (!path.isContentScheme()) {
+                _effects.emit(
+                    HomeEffect.RequestBackupStoragePermission(
+                        destination = destination,
+                        path = path,
+                    )
+                )
+            } else {
+                backup(destination = destination, path = path)
+            }
+        }
+    }
+
+    private fun backup(
+        destination: HomeBackupDestination,
+        path: String?,
+        savePath: Boolean = false,
+    ) {
+        if (_backupState.value.isActionRunning) 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,
+                    )
+                )
+            }
+            _backupState.update { it.copy(isActionRunning = false) }
+        }
+    }
+
+    private fun restoreLocal(uri: String) {
+        if (_backupState.value.isActionRunning) 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,
+                    )
+                )
+            }
+            _backupState.update { it.copy(isActionRunning = false) }
+        }
+    }
+
+    private fun restore() {
+        val backup = _backupState.value.latest ?: return
+        _activeDialog.value = null
+        if (_backupState.value.isActionRunning) 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,
+                    )
+                )
+            }
+            _backupState.update { it.copy(isActionRunning = false) }
+        }
+    }
+
+    private fun refreshLatestBackup() {
+        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) }
+    }
+
+    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 isActionRunning: Boolean = false,
+    )
+
+    private fun WebDavBackup.toUi() = HomeBackupUi(
+        name = name,
+        lastModify = lastModify,
+    )
+}

+ 42 - 8
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -36,6 +36,8 @@ 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
@@ -88,6 +90,9 @@ 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
@@ -107,6 +112,7 @@ 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,
 ) {
@@ -117,6 +123,7 @@ fun HomepageScreen(
     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
 
@@ -127,13 +134,13 @@ fun HomepageScreen(
         selectedSets.size.coerceAtLeast(1)
     })
 
-    val homeString = stringResource(R.string.home)
+    val discoveryString = stringResource(R.string.discovery)
     val currentTitle by remember(layoutMode, selectedSets) {
         derivedStateOf {
             if (layoutMode == 1) {
-                homeString
+                discoveryString
             } else {
-                selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: homeString
+                selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: discoveryString
             }
         }
     }
@@ -173,16 +180,43 @@ fun HomepageScreen(
                 title = currentTitle,
                 scrollBehavior = scrollBehavior,
                 actions = {
-                    TopBarActionButton(
-                        onClick = { viewModel.toggleConfigMode() },
-                        imageVector = AppIcons.MoreCircle,
-                        contentDescription = "Layout Settings",
-                    )
                     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()) {

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt

@@ -121,7 +121,7 @@ object AppIcons {
     fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
         return when (destination) {
             MainDestination.Home -> if (isMiuix) {
-                if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook
+                MiuixIcons.Regular.ContactsBook
             } else {
                 if (selected) Icons.Default.Home else Icons.Outlined.Home
             }

+ 0 - 14
app/src/main/java/io/legado/app/utils/ContextExtensions.kt

@@ -478,20 +478,6 @@ val Context.bookshelfLayoutGrid: Int
         AppConfig.bookshelfLayoutGridPortrait
     }
 
-var Context.exploreLayoutGrid: Int
-    get() = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
-        AppConfig.exploreLayoutGridLandscape
-    } else {
-        AppConfig.exploreLayoutGridPortrait
-    }
-    set(value) {
-        if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
-            AppConfig.exploreLayoutGridLandscape = value
-        } else {
-            AppConfig.exploreLayoutGridPortrait = value
-        }
-    }
-
 fun Context.themeColor(attr: Int): Int {
     val typedValue = TypedValue()
     theme.resolveAttribute(attr, typedValue, true)

+ 24 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -1,4 +1,4 @@
-<resources>
+<resources>
     <!--App-->
     <string name="app_name">阅读</string>
     <string name="app_name_a">阅读·A</string>
@@ -1022,7 +1022,6 @@
     <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="style">样式</string>
     <string name="group_style">分组样式</string>
     <string name="export_file_name">导出文件名</string>
@@ -1832,6 +1831,28 @@
     <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_no_recent_reading">暂无阅读记录</string>
+    <string name="home_total_read_books">累计阅读</string>
+    <string name="home_total_reading_time">阅读时长</string>
+    <string name="unit_books">本</string>
+    <string name="unit_hours">小时</string>
+    <string name="home_today_reading_goal">今日阅读目标</string>
+    <string name="home_set_goal">设定目标</string>
+    <string name="home_today_goal_value">%1$d / %2$d</string>
+    <string name="home_minutes">分钟</string>
+    <string name="home_set_reading_goal">设定阅读目标</string>
+    <string name="home_goal_minutes_label">目标(分钟)</string>
+    <string name="home_webdav_backup">WebDAV 备份</string>
+    <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_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>
@@ -2192,4 +2213,4 @@
     <string name="is_event_listener">EventListener</string>
     <string name="rule_call_back">callBackJs</string>
     <!-- Missing zh-rTW/HK translations (683 keys) -->
-</resources>
+</resources>

+ 22 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -895,7 +895,6 @@
     <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
     <string name="reverse_toc">反轉目錄</string>
     <string name="show_discovery">顯示發現</string>
-    <string name="show_home">顯示主頁</string>
     <string name="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">導出文件名</string>
@@ -1366,6 +1365,28 @@
     <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_no_recent_reading">暫無閱讀記錄</string>
+    <string name="home_total_read_books">累計閱讀</string>
+    <string name="home_total_reading_time">閱讀時長</string>
+    <string name="unit_books">本</string>
+    <string name="unit_hours">小時</string>
+    <string name="home_today_reading_goal">今日閱讀目標</string>
+    <string name="home_set_goal">設定目標</string>
+    <string name="home_today_goal_value">%1$d / %2$d</string>
+    <string name="home_minutes">分鐘</string>
+    <string name="home_set_reading_goal">設定閱讀目標</string>
+    <string name="home_goal_minutes_label">目標(分鐘)</string>
+    <string name="home_webdav_backup">WebDAV 備份</string>
+    <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_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>

+ 22 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -898,7 +898,6 @@
     <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="style">樣式</string>
     <string name="group_style">分組樣式</string>
     <string name="export_file_name">匯出檔案名</string>
@@ -1370,6 +1369,28 @@
     <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_no_recent_reading">暫無閱讀記錄</string>
+    <string name="home_total_read_books">累計閱讀</string>
+    <string name="home_total_reading_time">閱讀時長</string>
+    <string name="unit_books">本</string>
+    <string name="unit_hours">小時</string>
+    <string name="home_today_reading_goal">今日閱讀目標</string>
+    <string name="home_set_goal">設定目標</string>
+    <string name="home_today_goal_value">%1$d / %2$d</string>
+    <string name="home_minutes">分鐘</string>
+    <string name="home_set_reading_goal">設定閱讀目標</string>
+    <string name="home_goal_minutes_label">目標(分鐘)</string>
+    <string name="home_webdav_backup">WebDAV 備份</string>
+    <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_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>

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

@@ -260,7 +260,8 @@
 	<string-array name="default_home_page">
 		<item>@string/home</item>
 		<item>@string/bookshelf</item>
-		<item>@string/discovery</item>
+		<item>@string/discovery_modules</item>
+		<item>@string/classic_discovery</item>
 		<item>@string/rss</item>
 		<item>@string/my</item>
 	</string-array>
@@ -276,6 +277,7 @@
 	<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>

+ 22 - 1
app/src/main/res/values/strings.xml

@@ -1053,7 +1053,6 @@
     <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 Homepage</string>
     <string name="style">Style</string>
     <string name="group_style">Group style</string>
     <string name="export_file_name">Export file name</string>
@@ -1965,6 +1964,28 @@
     <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_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>
+    <string name="unit_books">books</string>
+    <string name="unit_hours">hours</string>
+    <string name="home_today_reading_goal">Today\'s Reading Goal</string>
+    <string name="home_set_goal">Set goal</string>
+    <string name="home_today_goal_value">%1$d / %2$d</string>
+    <string name="home_minutes">minutes</string>
+    <string name="home_set_reading_goal">Set Reading Goal</string>
+    <string name="home_goal_minutes_label">Goal (minutes)</string>
+    <string name="home_webdav_backup">WebDAV Backup</string>
+    <string name="home_latest_backup_value">Latest backup: %1$s</string>
+    <string name="home_no_webdav_backup">No WebDAV backup found</string>
+    <string name="home_loading_webdav_backup">Loading 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>