From 5287fe6457540f37af88a27af921597da935f539 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:04:43 +0800 Subject: [PATCH] feat: add home dashboard and discovery modes --- app/src/main/java/io/legado/app/App.kt | 4 + .../java/io/legado/app/constant/PreferKey.kt | 4 - .../local/preferences/LocalPreferences.kt | 2 + .../repository/BackupRestoreRepository.kt | 24 + .../data/repository/WebDavBackupRepository.kt | 38 +- .../main/java/io/legado/app/di/appModule.kt | 7 + .../domain/gateway/BackupRestoreGateway.kt | 6 + .../app/domain/gateway/WebDavBackupGateway.kt | 1 + .../domain/usecase/BackupRestoreUseCase.kt | 15 + .../app/domain/usecase/WebDavBackupUseCase.kt | 5 + .../main/java/io/legado/app/help/AppWebDav.kt | 62 +- .../io/legado/app/help/config/AppConfig.kt | 21 - .../app/help/config/ThemeImportExport.kt | 3 - .../config/backupConfig/BackupConfigScreen.kt | 126 +-- .../backupConfig/BackupConfigViewModel.kt | 13 +- .../backupConfig/BackupRestoreOptionSheets.kt | 70 ++ .../app/ui/config/themeConfig/ThemeConfig.kt | 2 - .../config/themeConfig/ThemeConfigScreen.kt | 5 - .../ui/config/themeManage/EditThemeSheet.kt | 5 - .../io/legado/app/ui/main/MainDestination.kt | 2 + .../io/legado/app/ui/main/MainNavGraph.kt | 3 + .../java/io/legado/app/ui/main/MainScreen.kt | 45 +- .../io/legado/app/ui/main/MainViewModel.kt | 37 +- .../app/ui/main/explore/ExploreScreen.kt | 75 +- .../app/ui/main/explore/ExploreStyle.kt | 12 + .../legado/app/ui/main/home/HomeContract.kt | 97 ++ .../io/legado/app/ui/main/home/HomeScreen.kt | 837 ++++++++++++++++++ .../legado/app/ui/main/home/HomeViewModel.kt | 326 +++++++ .../app/ui/main/homepage/HomepageScreen.kt | 50 +- .../app/ui/widget/components/icon/AppIcons.kt | 2 +- .../io/legado/app/utils/ContextExtensions.kt | 14 - app/src/main/res/values-zh-rCN/strings.xml | 27 +- app/src/main/res/values-zh-rHK/strings.xml | 23 +- app/src/main/res/values-zh-rTW/strings.xml | 23 +- app/src/main/res/values/arrays.xml | 4 +- app/src/main/res/values/strings.xml | 23 +- 36 files changed, 1803 insertions(+), 210 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/repository/BackupRestoreRepository.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/BackupRestoreGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/BackupRestoreUseCase.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/backupConfig/BackupRestoreOptionSheets.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt diff --git a/app/src/main/java/io/legado/app/App.kt b/app/src/main/java/io/legado/app/App.kt index 6e6ad5ea2..f939d4abd 100644 --- a/app/src/main/java/io/legado/app/App.kt +++ b/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() } } diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 2561eb72d..df18884ef 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/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" diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt index 76a7cc605..c650551aa 100644 --- a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt +++ b/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") diff --git a/app/src/main/java/io/legado/app/data/repository/BackupRestoreRepository.kt b/app/src/main/java/io/legado/app/data/repository/BackupRestoreRepository.kt new file mode 100644 index 000000000..d2a073499 --- /dev/null +++ b/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()) + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt b/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt index 568a5a59f..164c8f3fc 100644 --- a/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt +++ b/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 { - 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) + } } } diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 8a0324008..06652c662 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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 { AiTextRepositoryImpl() } single { AiToolRepository(get(), get(), get(), get(), get(), get()) } single { AppStartupRepository(get()) } + single { BackupRestoreRepository() } single { CacheBookDownloadRepository(get()) } single { BookCacheCleanupRepository(get()) } single { BookSourceCallbackRepository(get(), get()) } @@ -271,6 +277,7 @@ val appModule = module { viewModelOf(::MyViewModel) viewModelOf(::BookshelfViewModel) viewModelOf(::MainViewModel) + viewModelOf(::HomeViewModel) viewModelOf(::HomepageViewModel) viewModelOf(::AboutViewModel) viewModelOf(::GroupViewModel) diff --git a/app/src/main/java/io/legado/app/domain/gateway/BackupRestoreGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/BackupRestoreGateway.kt new file mode 100644 index 000000000..df739c222 --- /dev/null +++ b/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) +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/WebDavBackupGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/WebDavBackupGateway.kt index 1ecca2c41..3ff8c2626 100644 --- a/app/src/main/java/io/legado/app/domain/gateway/WebDavBackupGateway.kt +++ b/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 suspend fun getLatestBackup(): WebDavBackup? suspend fun restore(name: String) diff --git a/app/src/main/java/io/legado/app/domain/usecase/BackupRestoreUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/BackupRestoreUseCase.kt new file mode 100644 index 000000000..1c3ccda7c --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/WebDavBackupUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/WebDavBackupUseCase.kt index 9681c94d3..a98190a6a 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/WebDavBackupUseCase.kt +++ b/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 { webDavBackupGateway.syncConfig() return webDavBackupGateway.getBackupNames() diff --git a/app/src/main/java/io/legado/app/help/AppWebDav.kt b/app/src/main/java/io/legado/app/help/AppWebDav.kt index 8f5f89ce8..8114b39eb 100644 --- a/app/src/main/java/io/legado/app/help/AppWebDav.kt +++ b/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()) { diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 9c2b423ec..6aeded145 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/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) { diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 5daf557f2..0eb9a16c9 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt index 9f12146c2..9249a7788 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt +++ b/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(), diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt index 274338ba9..d7bc2b926 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt +++ b/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() } diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupRestoreOptionSheets.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupRestoreOptionSheets.kt new file mode 100644 index 000000000..c2f090663 --- /dev/null +++ b/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, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index ac53c8b4e..d89f55328 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/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) diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index ce87a4e48..699706947 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt index 6e9cbd6f1..500897893 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt index 01ee8a9a1..a151b1a77 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt +++ b/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(Home, Bookshelf, Explore, Rss, My) } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index a42af5868..ed1c62d37 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 67b1ad03d..ac01249b0 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/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 -> diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index db1bb56ef..3e0a47c07 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/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, ) } diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index c782d9eeb..6132b1b00 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/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), diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt new file mode 100644 index 000000000..1a9f0e836 --- /dev/null +++ b/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 + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt new file mode 100644 index 000000000..08a90154f --- /dev/null +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt new file mode 100644 index 000000000..02eceeec4 --- /dev/null +++ b/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(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) }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt new file mode 100644 index 000000000..e42f71634 --- /dev/null +++ b/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(null) + private val _activeSheet = MutableStateFlow(null) + private val _effects = MutableSharedFlow(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, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index a279b8556..86a41dab1 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/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(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()) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt b/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt index 93b704f1a..6523fa19c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt +++ b/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 } diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt index 90652ba7e..803686d9a 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/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) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0f1137518..3f756d404 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,4 +1,4 @@ - + 阅读 阅读·A @@ -1022,7 +1022,6 @@ 状态栏显示时隐藏 反转目录 显示发现 - 显示主页 样式 分组样式 导出文件名 @@ -1832,6 +1831,28 @@ 布局配置 编辑原始 JSON (LayoutConfig) 布局设置 + 发现模块 + 经典发现 + 切换至发现模块 + 切换至经典发现 + 最近阅读 + 暂无阅读记录 + 累计阅读 + 阅读时长 + + 小时 + 今日阅读目标 + 设定目标 + %1$d / %2$d + 分钟 + 设定阅读目标 + 目标(分钟) + WebDAV 备份 + 最近备份:%1$s + 暂无 WebDAV 备份 + 正在获取备份信息… + 立即将当前数据备份到 WebDAV? + 恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。 首页布局模式 混合列表 分源Tab @@ -2192,4 +2213,4 @@ EventListener callBackJs - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 240bded2e..1f12b4e3e 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -895,7 +895,6 @@ 輸入自訂源分組名稱 反轉目錄 顯示發現 - 顯示主頁 樣式 分組樣式 導出文件名 @@ -1366,6 +1365,28 @@ 佈局配置 編輯原始 JSON (LayoutConfig) 佈局設置 + 發現模塊 + 經典發現 + 切換至發現模塊 + 切換至經典發現 + 最近閱讀 + 暫無閱讀記錄 + 累計閱讀 + 閱讀時長 + + 小時 + 今日閱讀目標 + 設定目標 + %1$d / %2$d + 分鐘 + 設定閱讀目標 + 目標(分鐘) + WebDAV 備份 + 最近備份:%1$s + 暫無 WebDAV 備份 + 正在獲取備份資訊… + 立即將目前資料備份到 WebDAV? + 恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。 首頁佈局模式 混合列表 分源Tab diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 669047f94..fafa72a2d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -898,7 +898,6 @@ 狀態欄顯示時隱藏 反轉目錄 顯示發現 - 顯示主頁 樣式 分組樣式 匯出檔案名 @@ -1370,6 +1369,28 @@ 佈局配置 編輯原始 JSON (LayoutConfig) 佈局設置 + 發現模組 + 經典發現 + 切換至發現模組 + 切換至經典發現 + 最近閱讀 + 暫無閱讀記錄 + 累計閱讀 + 閱讀時長 + + 小時 + 今日閱讀目標 + 設定目標 + %1$d / %2$d + 分鐘 + 設定閱讀目標 + 目標(分鐘) + WebDAV 備份 + 最近備份:%1$s + 暫無 WebDAV 備份 + 正在取得備份資訊… + 立即將目前資料備份到 WebDAV? + 還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。 首頁佈局模式 混合列表 分源Tab diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index f29c23c4e..af8b169e7 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -260,7 +260,8 @@ @string/home @string/bookshelf - @string/discovery + @string/discovery_modules + @string/classic_discovery @string/rss @string/my @@ -276,6 +277,7 @@ home bookshelf + explore_modules explore rss my diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52ecb9118..beaccba6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1053,7 +1053,6 @@ Hide when status bar show Reverse toc Show Discovery - Show Homepage Style Group style Export file name @@ -1965,6 +1964,28 @@ Layout Config Edit Raw JSON (LayoutConfig) Layout Settings + Discovery Modules + Classic Discovery + Switch to Discovery Modules + Switch to Classic Discovery + Recently Reading + No reading history yet + Books Read + Reading Time + books + hours + Today\'s Reading Goal + Set goal + %1$d / %2$d + minutes + Set Reading Goal + Goal (minutes) + WebDAV Backup + Latest backup: %1$s + No WebDAV backup found + Loading backup information… + Back up current data to WebDAV now? + Restore WebDAV backup %1$s? Current data may be overwritten. Homepage Layout Mode Mixed List Tabs by Source