feat: add home dashboard and discovery modes
This commit is contained in:
@@ -133,6 +133,9 @@ class App : Application(), ImageLoaderFactory {
|
|||||||
oldConfig = Configuration(resources.configuration)
|
oldConfig = Configuration(resources.configuration)
|
||||||
registerActivityLifecycleCallbacks(LifecycleHelp)
|
registerActivityLifecycleCallbacks(LifecycleHelp)
|
||||||
defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig)
|
defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig)
|
||||||
|
Coroutine.async {
|
||||||
|
AppWebDav.upConfig()
|
||||||
|
}
|
||||||
Coroutine.async {
|
Coroutine.async {
|
||||||
LogUtils.init(this@App)
|
LogUtils.init(this@App)
|
||||||
LogUtils.d("App", "onCreate")
|
LogUtils.d("App", "onCreate")
|
||||||
@@ -181,6 +184,7 @@ class App : Application(), ImageLoaderFactory {
|
|||||||
SourceHelp.adjustSortNumber()
|
SourceHelp.adjustSortNumber()
|
||||||
//同步阅读记录
|
//同步阅读记录
|
||||||
if (AppConfig.syncBookProgress) {
|
if (AppConfig.syncBookProgress) {
|
||||||
|
AppWebDav.upConfig()
|
||||||
AppWebDav.downloadAllBookProgress()
|
AppWebDav.downloadAllBookProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ object PreferKey {
|
|||||||
const val prevKeys = "prevKeyCodes"
|
const val prevKeys = "prevKeyCodes"
|
||||||
const val nextKeys = "nextKeyCodes"
|
const val nextKeys = "nextKeyCodes"
|
||||||
const val showDiscovery = "showDiscovery"
|
const val showDiscovery = "showDiscovery"
|
||||||
const val showHome = "showHome"
|
|
||||||
const val enableReview = "enableReview"
|
const val enableReview = "enableReview"
|
||||||
const val showRss = "showRss"
|
const val showRss = "showRss"
|
||||||
const val showStatusBar = "showStatusBar"
|
const val showStatusBar = "showStatusBar"
|
||||||
@@ -275,9 +274,7 @@ object PreferKey {
|
|||||||
const val bookshelfListCoverWidth = "bookshelfListCoverWidth"
|
const val bookshelfListCoverWidth = "bookshelfListCoverWidth"
|
||||||
const val bookshelfGridCoverWidth = "bookshelfGridCoverWidth"
|
const val bookshelfGridCoverWidth = "bookshelfGridCoverWidth"
|
||||||
|
|
||||||
const val exploreLayoutGridLandscape = "exploreLayoutGridLandscape"
|
|
||||||
|
|
||||||
const val exploreLayoutGridPortrait = "exploreLayoutGridPortrait"
|
|
||||||
const val tabletInterface = "tabletInterface"
|
const val tabletInterface = "tabletInterface"
|
||||||
const val pureBlack = "pure_black"
|
const val pureBlack = "pure_black"
|
||||||
const val labelVisibilityMode = "labelVisibilityMode"
|
const val labelVisibilityMode = "labelVisibilityMode"
|
||||||
@@ -332,7 +329,6 @@ object PreferKey {
|
|||||||
|
|
||||||
const val exploreFilterState = "explore_filter_state"
|
const val exploreFilterState = "explore_filter_state"
|
||||||
|
|
||||||
const val exploreLayoutState = "exploreLayoutState"
|
|
||||||
|
|
||||||
const val defaultSourceChangeAll = "defaultSourceChangeAll"
|
const val defaultSourceChangeAll = "defaultSourceChangeAll"
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ object LocalPreferencesKeys {
|
|||||||
val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
|
val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode")
|
||||||
val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
|
val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
|
||||||
val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape")
|
val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape")
|
||||||
|
val EXPLORE_STYLE = stringPreferencesKey("explore_style")
|
||||||
val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
|
val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
|
||||||
val LAST_BACKUP = longPreferencesKey("last_backup")
|
val LAST_BACKUP = longPreferencesKey("last_backup")
|
||||||
val PASSWORD = stringPreferencesKey("password")
|
val PASSWORD = stringPreferencesKey("password")
|
||||||
val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
|
val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
|
||||||
val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
|
val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
|
||||||
|
val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
|
||||||
|
|
||||||
// Change source options
|
// Change source options
|
||||||
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
|
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ package io.legado.app.data.repository
|
|||||||
import io.legado.app.domain.gateway.WebDavBackupGateway
|
import io.legado.app.domain.gateway.WebDavBackupGateway
|
||||||
import io.legado.app.domain.model.WebDavBackup
|
import io.legado.app.domain.model.WebDavBackup
|
||||||
import io.legado.app.help.AppWebDav
|
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 {
|
class WebDavBackupRepository : WebDavBackupGateway {
|
||||||
|
|
||||||
@@ -10,27 +14,43 @@ class WebDavBackupRepository : WebDavBackupGateway {
|
|||||||
get() = AppWebDav.isJianGuoYun
|
get() = AppWebDav.isJianGuoYun
|
||||||
|
|
||||||
override suspend fun syncConfig() {
|
override suspend fun syncConfig() {
|
||||||
|
withContext(IO) {
|
||||||
AppWebDav.upConfig()
|
AppWebDav.upConfig()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun test(): Boolean {
|
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> {
|
override suspend fun getBackupNames(): List<String> {
|
||||||
return AppWebDav.getBackupNames()
|
return withContext(IO) {
|
||||||
|
AppWebDav.getBackupNames()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLatestBackup(): WebDavBackup? {
|
override suspend fun getLatestBackup(): WebDavBackup? {
|
||||||
return AppWebDav.lastBackUp().getOrNull()?.let {
|
return withContext(IO) {
|
||||||
|
AppWebDav.lastBackUp().getOrNull()?.let {
|
||||||
WebDavBackup(
|
WebDavBackup(
|
||||||
name = it.displayName,
|
name = it.displayName,
|
||||||
lastModify = it.lastModify
|
lastModify = it.lastModify
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun restore(name: String) {
|
override suspend fun restore(name: String) {
|
||||||
|
withContext(IO) {
|
||||||
AppWebDav.restoreWebDav(name)
|
AppWebDav.restoreWebDav(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import coil.decode.SvgDecoder
|
|||||||
import io.legado.app.data.AppDatabase
|
import io.legado.app.data.AppDatabase
|
||||||
import io.legado.app.data.local.preferences.LocalPreferencesRepository
|
import io.legado.app.data.local.preferences.LocalPreferencesRepository
|
||||||
import io.legado.app.data.repository.AppStartupRepository
|
import io.legado.app.data.repository.AppStartupRepository
|
||||||
|
import io.legado.app.data.repository.BackupRestoreRepository
|
||||||
import io.legado.app.data.repository.AiArtifactRepository
|
import io.legado.app.data.repository.AiArtifactRepository
|
||||||
import io.legado.app.data.repository.AiChatRepository
|
import io.legado.app.data.repository.AiChatRepository
|
||||||
import io.legado.app.data.repository.AiMemoryRepository
|
import io.legado.app.data.repository.AiMemoryRepository
|
||||||
@@ -47,6 +48,7 @@ import io.legado.app.data.repository.UploadRepository
|
|||||||
import io.legado.app.data.repository.WebDavBackupRepository
|
import io.legado.app.data.repository.WebDavBackupRepository
|
||||||
import io.legado.app.data.repository.WebDavReadingProgressRepository
|
import io.legado.app.data.repository.WebDavReadingProgressRepository
|
||||||
import io.legado.app.domain.gateway.AppStartupGateway
|
import io.legado.app.domain.gateway.AppStartupGateway
|
||||||
|
import io.legado.app.domain.gateway.BackupRestoreGateway
|
||||||
import io.legado.app.domain.gateway.AiArtifactGateway
|
import io.legado.app.domain.gateway.AiArtifactGateway
|
||||||
import io.legado.app.domain.gateway.AiChatGateway
|
import io.legado.app.domain.gateway.AiChatGateway
|
||||||
import io.legado.app.domain.gateway.AiMemoryGateway
|
import io.legado.app.domain.gateway.AiMemoryGateway
|
||||||
@@ -69,6 +71,7 @@ import io.legado.app.domain.repository.BookDomainRepository
|
|||||||
import io.legado.app.domain.usecase.AddBookUseCase
|
import io.legado.app.domain.usecase.AddBookUseCase
|
||||||
import io.legado.app.domain.usecase.AddToBookshelfUseCase
|
import io.legado.app.domain.usecase.AddToBookshelfUseCase
|
||||||
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
|
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.BatchCacheDownloadUseCase
|
||||||
import io.legado.app.domain.usecase.CacheBookChaptersUseCase
|
import io.legado.app.domain.usecase.CacheBookChaptersUseCase
|
||||||
import io.legado.app.domain.usecase.ChangeBookSourceUseCase
|
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.MainViewModel
|
||||||
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
||||||
import io.legado.app.ui.main.explore.ExploreViewModel
|
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.homepage.HomepageViewModel
|
||||||
import io.legado.app.ui.main.my.MyViewModel
|
import io.legado.app.ui.main.my.MyViewModel
|
||||||
import io.legado.app.ui.main.rss.RssViewModel
|
import io.legado.app.ui.main.rss.RssViewModel
|
||||||
@@ -184,6 +188,7 @@ val appModule = module {
|
|||||||
singleOf(::ExploreKindUiUseCase)
|
singleOf(::ExploreKindUiUseCase)
|
||||||
singleOf(::SaveSearchBooksUseCase)
|
singleOf(::SaveSearchBooksUseCase)
|
||||||
singleOf(::AppStartupMaintenanceUseCase)
|
singleOf(::AppStartupMaintenanceUseCase)
|
||||||
|
singleOf(::BackupRestoreUseCase)
|
||||||
singleOf(::BatchCacheDownloadUseCase)
|
singleOf(::BatchCacheDownloadUseCase)
|
||||||
singleOf(::CacheBookChaptersUseCase)
|
singleOf(::CacheBookChaptersUseCase)
|
||||||
singleOf(::ChangeBookSourceUseCase)
|
singleOf(::ChangeBookSourceUseCase)
|
||||||
@@ -213,6 +218,7 @@ val appModule = module {
|
|||||||
single<AiTextGateway> { AiTextRepositoryImpl() }
|
single<AiTextGateway> { AiTextRepositoryImpl() }
|
||||||
single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
|
single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
|
||||||
single<AppStartupGateway> { AppStartupRepository(get()) }
|
single<AppStartupGateway> { AppStartupRepository(get()) }
|
||||||
|
single<BackupRestoreGateway> { BackupRestoreRepository() }
|
||||||
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
|
||||||
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
|
||||||
single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
|
single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
|
||||||
@@ -271,6 +277,7 @@ val appModule = module {
|
|||||||
viewModelOf(::MyViewModel)
|
viewModelOf(::MyViewModel)
|
||||||
viewModelOf(::BookshelfViewModel)
|
viewModelOf(::BookshelfViewModel)
|
||||||
viewModelOf(::MainViewModel)
|
viewModelOf(::MainViewModel)
|
||||||
|
viewModelOf(::HomeViewModel)
|
||||||
viewModelOf(::HomepageViewModel)
|
viewModelOf(::HomepageViewModel)
|
||||||
viewModelOf(::AboutViewModel)
|
viewModelOf(::AboutViewModel)
|
||||||
viewModelOf(::GroupViewModel)
|
viewModelOf(::GroupViewModel)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package io.legado.app.domain.gateway
|
||||||
|
|
||||||
|
interface BackupRestoreGateway {
|
||||||
|
suspend fun backup(path: String?, mode: String)
|
||||||
|
suspend fun restoreLocal(uri: String)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ interface WebDavBackupGateway {
|
|||||||
|
|
||||||
suspend fun syncConfig()
|
suspend fun syncConfig()
|
||||||
suspend fun test(): Boolean
|
suspend fun test(): Boolean
|
||||||
|
suspend fun backup()
|
||||||
suspend fun getBackupNames(): List<String>
|
suspend fun getBackupNames(): List<String>
|
||||||
suspend fun getLatestBackup(): WebDavBackup?
|
suspend fun getLatestBackup(): WebDavBackup?
|
||||||
suspend fun restore(name: String)
|
suspend fun restore(name: String)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ class WebDavBackupUseCase(
|
|||||||
return webDavBackupGateway.test()
|
return webDavBackupGateway.test()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun backup() {
|
||||||
|
webDavBackupGateway.syncConfig()
|
||||||
|
webDavBackupGateway.backup()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getBackupNames(): List<String> {
|
suspend fun getBackupNames(): List<String> {
|
||||||
webDavBackupGateway.syncConfig()
|
webDavBackupGateway.syncConfig()
|
||||||
return webDavBackupGateway.getBackupNames()
|
return webDavBackupGateway.getBackupNames()
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import io.legado.app.utils.normalizeFileName
|
|||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -43,27 +44,26 @@ object AppWebDav {
|
|||||||
private val exportsWebDavUrl get() = "${rootWebDavUrl}books/"
|
private val exportsWebDavUrl get() = "${rootWebDavUrl}books/"
|
||||||
private val bgWebDavUrl get() = "${rootWebDavUrl}background/"
|
private val bgWebDavUrl get() = "${rootWebDavUrl}background/"
|
||||||
|
|
||||||
|
private val configMutex = Mutex()
|
||||||
|
private var appliedConfig: AppliedWebDavConfig? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var authorization: Authorization? = null
|
var authorization: Authorization? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var defaultBookWebDav: RemoteBookWebDav? = null
|
var defaultBookWebDav: RemoteBookWebDav? = null
|
||||||
|
|
||||||
val isOk get() = authorization != null
|
val isOk get() = authorization != null
|
||||||
|
|
||||||
val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
|
val isJianGuoYun get() = rootWebDavUrl.startsWith(defaultWebDavUrl, true)
|
||||||
|
|
||||||
init {
|
|
||||||
runBlocking {
|
|
||||||
upConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val rootWebDavUrl: String
|
private val rootWebDavUrl: String
|
||||||
get() {
|
get() {
|
||||||
val configUrl = BackupConfig.webDavUrl.trim()
|
val configUrl = BackupConfig.webDavUrl.trim()
|
||||||
var url = if (configUrl.isEmpty()) defaultWebDavUrl else configUrl
|
var url = if (configUrl.isEmpty()) defaultWebDavUrl else configUrl
|
||||||
if (!url.endsWith("/")) url = "${url}/"
|
if (!url.endsWith("/")) url = "${url}/"
|
||||||
AppConfig.webDavDir?.trim()?.let {
|
AppConfig.webDavDir.trim().let {
|
||||||
if (it.isNotEmpty()) {
|
if (it.isNotEmpty()) {
|
||||||
url = "${url}${it}/"
|
url = "${url}${it}/"
|
||||||
}
|
}
|
||||||
@@ -72,13 +72,20 @@ object AppWebDav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun upConfig() {
|
suspend fun upConfig() {
|
||||||
|
configMutex.withLock {
|
||||||
|
val config = AppliedWebDavConfig(
|
||||||
|
url = BackupConfig.webDavUrl,
|
||||||
|
account = BackupConfig.webDavAccount,
|
||||||
|
password = BackupConfig.webDavPassword,
|
||||||
|
dir = BackupConfig.webDavDir,
|
||||||
|
)
|
||||||
|
if (appliedConfig == config) return
|
||||||
|
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
authorization = null
|
authorization = null
|
||||||
defaultBookWebDav = null
|
defaultBookWebDav = null
|
||||||
val account = BackupConfig.webDavAccount
|
if (config.account.isNotEmpty() && config.password.isNotEmpty()) {
|
||||||
val password = BackupConfig.webDavPassword
|
val mAuthorization = Authorization(config.account, config.password)
|
||||||
if (!account.isNullOrEmpty() && !password.isNullOrEmpty()) {
|
|
||||||
val mAuthorization = Authorization(account, password)
|
|
||||||
checkAuthorization(mAuthorization)
|
checkAuthorization(mAuthorization)
|
||||||
WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
|
WebDav(rootWebDavUrl, mAuthorization).makeAsDir()
|
||||||
WebDav(bookProgressUrl, mAuthorization).makeAsDir()
|
WebDav(bookProgressUrl, mAuthorization).makeAsDir()
|
||||||
@@ -88,8 +95,17 @@ object AppWebDav {
|
|||||||
defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
|
defaultBookWebDav = RemoteBookWebDav(rootBooksUrl, mAuthorization)
|
||||||
authorization = mAuthorization
|
authorization = mAuthorization
|
||||||
}
|
}
|
||||||
|
appliedConfig = config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AppliedWebDavConfig(
|
||||||
|
val url: String,
|
||||||
|
val account: String,
|
||||||
|
val password: String,
|
||||||
|
val dir: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Throws(WebDavException::class)
|
@Throws(WebDavException::class)
|
||||||
private suspend fun checkAuthorization(authorization: Authorization) {
|
private suspend fun checkAuthorization(authorization: Authorization) {
|
||||||
|
|||||||
@@ -283,18 +283,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
|
|||||||
BookshelfConfig.bookshelfLayoutGridPortrait = value
|
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
|
var bookshelfLayoutGridLandscape: Int
|
||||||
get() = BookshelfConfig.bookshelfLayoutGridLandscape
|
get() = BookshelfConfig.bookshelfLayoutGridLandscape
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -329,9 +317,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
|
|||||||
val showDiscovery: Boolean
|
val showDiscovery: Boolean
|
||||||
get() = ThemeConfig.showDiscovery
|
get() = ThemeConfig.showDiscovery
|
||||||
|
|
||||||
val showHome: Boolean
|
|
||||||
get() = ThemeConfig.showHome
|
|
||||||
|
|
||||||
val showRSS: Boolean
|
val showRSS: Boolean
|
||||||
get() = ThemeConfig.showRss
|
get() = ThemeConfig.showRss
|
||||||
|
|
||||||
@@ -874,12 +859,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
|
|||||||
BookshelfConfig.shouldShowExpandButton = value
|
BookshelfConfig.shouldShowExpandButton = value
|
||||||
}
|
}
|
||||||
|
|
||||||
var exploreLayoutState: Int
|
|
||||||
get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0)
|
|
||||||
set(value) {
|
|
||||||
appCtx.putPrefInt(PreferKey.exploreLayoutState, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultSourceChangeAll: Boolean
|
var defaultSourceChangeAll: Boolean
|
||||||
get() = ReadConfig.defaultSourceChangeAll
|
get() = ReadConfig.defaultSourceChangeAll
|
||||||
set(value) {
|
set(value) {
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ object ThemeImportExport {
|
|||||||
customTagColorsJson = ThemeConfig.customTagColorsJson,
|
customTagColorsJson = ThemeConfig.customTagColorsJson,
|
||||||
|
|
||||||
// 主界面设置
|
// 主界面设置
|
||||||
showHome = ThemeConfig.showHome,
|
|
||||||
showDiscovery = ThemeConfig.showDiscovery,
|
showDiscovery = ThemeConfig.showDiscovery,
|
||||||
showRss = ThemeConfig.showRss,
|
showRss = ThemeConfig.showRss,
|
||||||
showStatusBar = ThemeConfig.showStatusBar,
|
showStatusBar = ThemeConfig.showStatusBar,
|
||||||
@@ -344,7 +343,6 @@ object ThemeImportExport {
|
|||||||
ThemeConfig.customTagColorsJson = data.customTagColorsJson
|
ThemeConfig.customTagColorsJson = data.customTagColorsJson
|
||||||
|
|
||||||
// 主界面设置
|
// 主界面设置
|
||||||
ThemeConfig.showHome = data.showHome
|
|
||||||
ThemeConfig.showDiscovery = data.showDiscovery
|
ThemeConfig.showDiscovery = data.showDiscovery
|
||||||
ThemeConfig.showRss = data.showRss
|
ThemeConfig.showRss = data.showRss
|
||||||
ThemeConfig.showStatusBar = data.showStatusBar
|
ThemeConfig.showStatusBar = data.showStatusBar
|
||||||
@@ -581,7 +579,6 @@ data class ThemeExportData(
|
|||||||
val customTagColorsJson: String? = null,
|
val customTagColorsJson: String? = null,
|
||||||
|
|
||||||
// 主界面设置
|
// 主界面设置
|
||||||
val showHome: Boolean = true,
|
|
||||||
val showDiscovery: Boolean = true,
|
val showDiscovery: Boolean = true,
|
||||||
val showRss: Boolean = true,
|
val showRss: Boolean = true,
|
||||||
val showStatusBar: Boolean = true,
|
val showStatusBar: Boolean = true,
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.checkBox.CheckboxItem
|
||||||
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
|
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.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.ClickableSettingItem
|
||||||
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
|
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
|
||||||
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
|
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
|
||||||
@@ -170,7 +165,6 @@ fun BackupConfigScreen(
|
|||||||
showLoadingDialog = true
|
showLoadingDialog = true
|
||||||
loadingText = context.getString(R.string.on_restore)
|
loadingText = context.getString(R.string.on_restore)
|
||||||
viewModel.restore(
|
viewModel.restore(
|
||||||
context = context,
|
|
||||||
uri = uri,
|
uri = uri,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
showLoadingDialog = false
|
showLoadingDialog = false
|
||||||
@@ -451,60 +445,37 @@ fun BackupConfigScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
OptionSheet(
|
BackupOptionSheet(
|
||||||
show = showBackupOptionSheet,
|
show = showBackupOptionSheet,
|
||||||
onDismissRequest = { showBackupOptionSheet = false },
|
onDismissRequest = { showBackupOptionSheet = false },
|
||||||
title = stringResource(R.string.backup)
|
onBackupToLocal = {
|
||||||
) {
|
|
||||||
OptionCard(
|
|
||||||
icon = Icons.Default.PhoneAndroid,
|
|
||||||
text = stringResource(R.string.backup_to_local),
|
|
||||||
onClick = {
|
|
||||||
showBackupOptionSheet = false
|
showBackupOptionSheet = false
|
||||||
executeBackup("local", context, viewModel, scope, snackbarHostState, {
|
executeBackup("local", context, viewModel, scope, snackbarHostState, {
|
||||||
showLoadingDialog = false
|
showLoadingDialog = false
|
||||||
}, { showLoadingDialog = true; loadingText = it })
|
}, { showLoadingDialog = true; loadingText = it })
|
||||||
}
|
},
|
||||||
)
|
onBackupToNetwork = {
|
||||||
OptionCard(
|
|
||||||
icon = Icons.Default.Cloud,
|
|
||||||
text = stringResource(R.string.backup_to_network),
|
|
||||||
onClick = {
|
|
||||||
showBackupOptionSheet = false
|
showBackupOptionSheet = false
|
||||||
executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
|
executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
|
||||||
showLoadingDialog = false
|
showLoadingDialog = false
|
||||||
}, { showLoadingDialog = true; loadingText = it })
|
}, { showLoadingDialog = true; loadingText = it })
|
||||||
}
|
},
|
||||||
)
|
onBackupToLocalAndNetwork = {
|
||||||
OptionCard(
|
|
||||||
icon = Icons.Default.Lan,
|
|
||||||
text = stringResource(R.string.backup_to_local_and_network),
|
|
||||||
onClick = {
|
|
||||||
showBackupOptionSheet = false
|
showBackupOptionSheet = false
|
||||||
executeBackup("both", context, viewModel, scope, snackbarHostState, {
|
executeBackup("both", context, viewModel, scope, snackbarHostState, {
|
||||||
showLoadingDialog = false
|
showLoadingDialog = false
|
||||||
}, { showLoadingDialog = true; loadingText = it })
|
}, { showLoadingDialog = true; loadingText = it })
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
OptionSheet(
|
RestoreOptionSheet(
|
||||||
show = showRestoreOptionSheet,
|
show = showRestoreOptionSheet,
|
||||||
onDismissRequest = { showRestoreOptionSheet = false },
|
onDismissRequest = { showRestoreOptionSheet = false },
|
||||||
title = stringResource(R.string.restore)
|
onRestoreFromLocal = {
|
||||||
) {
|
|
||||||
OptionCard(
|
|
||||||
icon = Icons.Default.PhoneAndroid,
|
|
||||||
text = stringResource(R.string.restore_from_local),
|
|
||||||
onClick = {
|
|
||||||
showRestoreOptionSheet = false
|
showRestoreOptionSheet = false
|
||||||
restoreFileLauncher.launch(arrayOf("application/zip"))
|
restoreFileLauncher.launch(arrayOf("application/zip"))
|
||||||
}
|
},
|
||||||
)
|
onRestoreFromNetwork = {
|
||||||
OptionCard(
|
|
||||||
icon = Icons.Default.Cloud,
|
|
||||||
text = stringResource(R.string.restore_from_network),
|
|
||||||
onClick = {
|
|
||||||
showRestoreOptionSheet = false
|
showRestoreOptionSheet = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
showLoadingDialog = true
|
showLoadingDialog = true
|
||||||
@@ -528,9 +499,8 @@ fun BackupConfigScreen(
|
|||||||
showLoadingDialog = false
|
showLoadingDialog = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
AppModalBottomSheet(
|
AppModalBottomSheet(
|
||||||
show = showRestoreSheet && backupNames.isNotEmpty(),
|
show = showRestoreSheet && backupNames.isNotEmpty(),
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
package io.legado.app.ui.config.backupConfig
|
package io.legado.app.ui.config.backupConfig
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
|
import io.legado.app.domain.usecase.BackupRestoreUseCase
|
||||||
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
|
|
||||||
class BackupConfigViewModel(
|
class BackupConfigViewModel(
|
||||||
private val webDavBackupUseCase: WebDavBackupUseCase
|
private val webDavBackupUseCase: WebDavBackupUseCase,
|
||||||
|
private val backupRestoreUseCase: BackupRestoreUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private suspend fun syncWebDavConfig() {
|
private suspend fun syncWebDavConfig() {
|
||||||
@@ -53,7 +52,7 @@ class BackupConfigViewModel(
|
|||||||
fun backup(backupPath: String, mode: String = "both", onSuccess: () -> Unit, onError: (String) -> Unit) {
|
fun backup(backupPath: String, mode: String = "both", onSuccess: () -> Unit, onError: (String) -> Unit) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Backup.backupLocked(appCtx, backupPath, mode)
|
backupRestoreUseCase.backup(backupPath, mode)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
onSuccess()
|
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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Restore.restore(context, uri)
|
backupRestoreUseCase.restoreLocal(uri.toString())
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,8 +144,6 @@ object ThemeConfig {
|
|||||||
|
|
||||||
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
|
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
|
||||||
|
|
||||||
var showHome by prefDelegate(PreferKey.showHome, true)
|
|
||||||
|
|
||||||
var showRss by prefDelegate(PreferKey.showRss, true)
|
var showRss by prefDelegate(PreferKey.showRss, true)
|
||||||
|
|
||||||
var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
|
var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
|
||||||
|
|||||||
@@ -375,11 +375,6 @@ fun ThemeConfigScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
|
SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
|
||||||
SwitchSettingItem(
|
|
||||||
title = stringResource(R.string.show_home),
|
|
||||||
checked = ThemeConfig.showHome,
|
|
||||||
onCheckedChange = { ThemeConfig.showHome = it }
|
|
||||||
)
|
|
||||||
SwitchSettingItem(
|
SwitchSettingItem(
|
||||||
title = stringResource(R.string.show_discovery),
|
title = stringResource(R.string.show_discovery),
|
||||||
checked = ThemeConfig.showDiscovery,
|
checked = ThemeConfig.showDiscovery,
|
||||||
|
|||||||
@@ -163,11 +163,6 @@ fun EditThemeSheet(
|
|||||||
|
|
||||||
// Interface layout
|
// Interface layout
|
||||||
SectionTitle(stringResource(R.string.theme_manage_section_layout))
|
SectionTitle(stringResource(R.string.theme_manage_section_layout))
|
||||||
CompactSwitchSettingItem(
|
|
||||||
title = "首页",
|
|
||||||
checked = data.showHome,
|
|
||||||
onCheckedChange = { data = data.copy(showHome = it) }
|
|
||||||
)
|
|
||||||
CompactSwitchSettingItem(
|
CompactSwitchSettingItem(
|
||||||
title = stringResource(R.string.theme_manage_show_discovery),
|
title = stringResource(R.string.theme_manage_show_discovery),
|
||||||
checked = data.showDiscovery,
|
checked = data.showDiscovery,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ sealed class MainDestination(
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val DISCOVERY_MODULES_ROUTE = "explore_modules"
|
||||||
|
|
||||||
val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
|
val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ fun MainActivity.mainEntryProvider(
|
|||||||
onNavigateToBookCacheManage = {
|
onNavigateToBookCacheManage = {
|
||||||
onNavigateToRoute(MainRouteBookCacheManage)
|
onNavigateToRoute(MainRouteBookCacheManage)
|
||||||
},
|
},
|
||||||
|
onNavigateToBackupSettings = {
|
||||||
|
onNavigateToRoute(MainRouteSettingsBackup)
|
||||||
|
},
|
||||||
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
|
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
|
||||||
onNavigateToRoute(
|
onNavigateToRoute(
|
||||||
MainRouteBookInfo(
|
MainRouteBookInfo(
|
||||||
|
|||||||
@@ -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.BookshelfScreen
|
||||||
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
||||||
import io.legado.app.ui.main.explore.ExploreScreen
|
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.MyScreen
|
||||||
import io.legado.app.ui.main.my.PrefClickEvent
|
import io.legado.app.ui.main.my.PrefClickEvent
|
||||||
import io.legado.app.ui.main.rss.RssScreen
|
import io.legado.app.ui.main.rss.RssScreen
|
||||||
@@ -111,6 +111,7 @@ fun MainScreen(
|
|||||||
onNavigateToLocalImport: () -> Unit,
|
onNavigateToLocalImport: () -> Unit,
|
||||||
onNavigateToCache: (Long) -> Unit,
|
onNavigateToCache: (Long) -> Unit,
|
||||||
onNavigateToBookCacheManage: () -> Unit,
|
onNavigateToBookCacheManage: () -> Unit,
|
||||||
|
onNavigateToBackupSettings: () -> Unit,
|
||||||
onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
|
onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
|
||||||
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
||||||
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
|
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
|
||||||
@@ -177,7 +178,14 @@ fun MainScreen(
|
|||||||
val destinations = mainUiState.destinations
|
val destinations = mainUiState.destinations
|
||||||
|
|
||||||
val initialPage = remember(destinations, mainUiState.defaultHomePage) {
|
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
|
if (index != -1) index else 0
|
||||||
}
|
}
|
||||||
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
|
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
|
||||||
@@ -370,20 +378,11 @@ fun MainScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val destination = destinations.getOrNull(page) ?: return@HorizontalPager
|
val destination = destinations.getOrNull(page) ?: return@HorizontalPager
|
||||||
when (destination) {
|
when (destination) {
|
||||||
MainDestination.Home -> HomepageScreen(
|
MainDestination.Home -> HomeRouteScreen(
|
||||||
onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
|
onOpenBook = { book ->
|
||||||
onNavigateToBookInfo(
|
context.startActivityForBook(book)
|
||||||
name ?: "",
|
|
||||||
author ?: "",
|
|
||||||
bookUrl,
|
|
||||||
origin,
|
|
||||||
coverPath,
|
|
||||||
sharedCoverKey
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
|
|
||||||
onNavigateToExploreShow(title, sourceUrl, exploreUrl)
|
|
||||||
},
|
},
|
||||||
|
onOpenBackupSettings = onNavigateToBackupSettings,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
)
|
)
|
||||||
@@ -411,7 +410,21 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
MainDestination.Explore -> ExploreScreen(
|
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(
|
MainDestination.Rss -> RssScreen(
|
||||||
onOpenSort = { sourceUrl, sortUrl, key ->
|
onOpenSort = { sourceUrl, sortUrl, key ->
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.constant.EventBus
|
import io.legado.app.constant.EventBus
|
||||||
|
import io.legado.app.data.local.preferences.LocalPreferencesKeys
|
||||||
|
import io.legado.app.data.local.preferences.LocalPreferencesRepository
|
||||||
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
|
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
|
||||||
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
||||||
|
import io.legado.app.ui.main.explore.ExploreStyle
|
||||||
import io.legado.app.ui.main.my.PrefClickEvent
|
import io.legado.app.ui.main.my.PrefClickEvent
|
||||||
import io.legado.app.utils.eventBus.FlowEventBus
|
import io.legado.app.utils.eventBus.FlowEventBus
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
@@ -23,7 +26,8 @@ import kotlinx.coroutines.launch
|
|||||||
class MainViewModel(
|
class MainViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
|
private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
|
||||||
private val webDavBackupUseCase: WebDavBackupUseCase
|
private val webDavBackupUseCase: WebDavBackupUseCase,
|
||||||
|
private val localPreferencesRepository: LocalPreferencesRepository,
|
||||||
) : BaseViewModel(application) {
|
) : BaseViewModel(application) {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(readMainUiState())
|
private val _uiState = MutableStateFlow(readMainUiState())
|
||||||
@@ -37,8 +41,18 @@ class MainViewModel(
|
|||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
readMainUiState()
|
readMainUiState()
|
||||||
}.collect { newState ->
|
}.collect { newState ->
|
||||||
if (_uiState.value != newState) {
|
_uiState.update { currentState ->
|
||||||
_uiState.value = newState
|
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
|
ThemeConfig.navExtended = expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setExploreStyle(style: ExploreStyle) {
|
||||||
|
if (_uiState.value.exploreStyle == style) return
|
||||||
|
_uiState.update { it.copy(exploreStyle = style) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
localPreferencesRepository.updatePreference(
|
||||||
|
LocalPreferencesKeys.EXPLORE_STYLE,
|
||||||
|
style.storageValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onPrefClickEvent(event: PrefClickEvent) {
|
fun onPrefClickEvent(event: PrefClickEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
|
is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
|
||||||
@@ -132,14 +157,14 @@ data class MainUiState(
|
|||||||
val useFloatingBottomBar: Boolean = false,
|
val useFloatingBottomBar: Boolean = false,
|
||||||
val useFloatingBottomBarLiquidGlass: Boolean = false,
|
val useFloatingBottomBarLiquidGlass: Boolean = false,
|
||||||
val labelVisibilityMode: String = "auto",
|
val labelVisibilityMode: String = "auto",
|
||||||
val navExtended: Boolean = false
|
val navExtended: Boolean = false,
|
||||||
|
val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun MainViewModel.readMainUiState(): MainUiState {
|
private fun MainViewModel.readMainUiState(): MainUiState {
|
||||||
val destinations = MainDestination.mainDestinations.filter {
|
val destinations = MainDestination.mainDestinations.filter {
|
||||||
when (it) {
|
when (it) {
|
||||||
MainDestination.Explore -> ThemeConfig.showDiscovery
|
MainDestination.Explore -> ThemeConfig.showDiscovery
|
||||||
MainDestination.Home -> ThemeConfig.showHome
|
|
||||||
MainDestination.Rss -> ThemeConfig.showRss
|
MainDestination.Rss -> ThemeConfig.showRss
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
@@ -151,6 +176,6 @@ private fun MainViewModel.readMainUiState(): MainUiState {
|
|||||||
useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
|
useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
|
||||||
useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
|
useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
|
||||||
labelVisibilityMode = ThemeConfig.labelVisibilityMode,
|
labelVisibilityMode = ThemeConfig.labelVisibilityMode,
|
||||||
navExtended = ThemeConfig.navExtended
|
navExtended = ThemeConfig.navExtended,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package io.legado.app.ui.main.explore
|
|||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -26,6 +32,7 @@ import androidx.compose.material.icons.filled.ChevronRight
|
|||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Group
|
import androidx.compose.material.icons.filled.Group
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.VerticalAlignTop
|
import androidx.compose.material.icons.filled.VerticalAlignTop
|
||||||
@@ -40,6 +47,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -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.search.SearchScope
|
||||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||||
import io.legado.app.ui.login.SourceLoginActivity
|
import io.legado.app.ui.login.SourceLoginActivity
|
||||||
|
import io.legado.app.ui.main.homepage.HomepageScreen
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
import io.legado.app.ui.theme.LegadoTheme.composeEngine
|
import io.legado.app.ui.theme.LegadoTheme.composeEngine
|
||||||
import io.legado.app.ui.theme.ThemeResolver
|
import io.legado.app.ui.theme.ThemeResolver
|
||||||
@@ -86,8 +95,64 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
|
|||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ExploreScreen(
|
fun ExploreScreen(
|
||||||
|
style: ExploreStyle,
|
||||||
|
onStyleChange: (ExploreStyle) -> Unit,
|
||||||
|
onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
|
||||||
|
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
||||||
|
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(),
|
viewModel: ExploreViewModel = koinViewModel(),
|
||||||
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit
|
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
||||||
|
onSwitchToHomepage: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? AppCompatActivity
|
val activity = context as? AppCompatActivity
|
||||||
@@ -143,6 +208,14 @@ fun ExploreScreen(
|
|||||||
onSearchToggle = { viewModel.toggleSearchVisible(it) },
|
onSearchToggle = { viewModel.toggleSearchVisible(it) },
|
||||||
searchPlaceholder = stringResource(R.string.search),
|
searchPlaceholder = stringResource(R.string.search),
|
||||||
dropDownMenuContent = { dismiss ->
|
dropDownMenuContent = { dismiss ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.Home) },
|
||||||
|
text = stringResource(R.string.switch_to_discovery_modules),
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
onSwitchToHomepage()
|
||||||
|
},
|
||||||
|
)
|
||||||
RoundDropdownMenuItem(
|
RoundDropdownMenuItem(
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Group) },
|
leadingIcon = { MenuItemIcon(Icons.Default.Group) },
|
||||||
text = stringResource(R.string.all),
|
text = stringResource(R.string.all),
|
||||||
|
|||||||
@@ -0,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.GridView
|
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.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.outlined.Info
|
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.tabRow.AppTabRow
|
||||||
import io.legado.app.ui.widget.components.EmptyMessage
|
import io.legado.app.ui.widget.components.EmptyMessage
|
||||||
import io.legado.app.ui.widget.components.icon.AppIcons
|
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.text.AppText
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
||||||
@@ -107,6 +112,7 @@ fun HomepageScreen(
|
|||||||
viewModel: HomepageViewModel = koinViewModel(),
|
viewModel: HomepageViewModel = koinViewModel(),
|
||||||
onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
|
onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
|
||||||
onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
||||||
|
onSwitchToDiscovery: () -> Unit,
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
) {
|
) {
|
||||||
@@ -117,6 +123,7 @@ fun HomepageScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var errorMsg by remember { mutableStateOf<String?>(null) }
|
var errorMsg by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showPageMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val layoutMode = HomepageConfig.homepageLayoutModeState.value
|
val layoutMode = HomepageConfig.homepageLayoutModeState.value
|
||||||
|
|
||||||
@@ -127,13 +134,13 @@ fun HomepageScreen(
|
|||||||
selectedSets.size.coerceAtLeast(1)
|
selectedSets.size.coerceAtLeast(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
val homeString = stringResource(R.string.home)
|
val discoveryString = stringResource(R.string.discovery)
|
||||||
val currentTitle by remember(layoutMode, selectedSets) {
|
val currentTitle by remember(layoutMode, selectedSets) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (layoutMode == 1) {
|
if (layoutMode == 1) {
|
||||||
homeString
|
discoveryString
|
||||||
} else {
|
} else {
|
||||||
selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: homeString
|
selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: discoveryString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,16 +180,43 @@ fun HomepageScreen(
|
|||||||
title = currentTitle,
|
title = currentTitle,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
actions = {
|
actions = {
|
||||||
TopBarActionButton(
|
|
||||||
onClick = { viewModel.toggleConfigMode() },
|
|
||||||
imageVector = AppIcons.MoreCircle,
|
|
||||||
contentDescription = "Layout Settings",
|
|
||||||
)
|
|
||||||
TopBarActionButton(
|
TopBarActionButton(
|
||||||
onClick = { viewModel.toggleManageMode() },
|
onClick = { viewModel.toggleManageMode() },
|
||||||
imageVector = AppIcons.Settings,
|
imageVector = AppIcons.Settings,
|
||||||
contentDescription = "Manage Modules",
|
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 = {
|
bottomContent = {
|
||||||
if (layoutMode == 1 && selectedSets.isNotEmpty()) {
|
if (layoutMode == 1 && selectedSets.isNotEmpty()) {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ object AppIcons {
|
|||||||
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
|
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
|
||||||
return when (destination) {
|
return when (destination) {
|
||||||
MainDestination.Home -> if (isMiuix) {
|
MainDestination.Home -> if (isMiuix) {
|
||||||
if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook
|
MiuixIcons.Regular.ContactsBook
|
||||||
} else {
|
} else {
|
||||||
if (selected) Icons.Default.Home else Icons.Outlined.Home
|
if (selected) Icons.Default.Home else Icons.Outlined.Home
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,20 +478,6 @@ val Context.bookshelfLayoutGrid: Int
|
|||||||
AppConfig.bookshelfLayoutGridPortrait
|
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 {
|
fun Context.themeColor(attr: Int): Int {
|
||||||
val typedValue = TypedValue()
|
val typedValue = TypedValue()
|
||||||
theme.resolveAttribute(attr, typedValue, true)
|
theme.resolveAttribute(attr, typedValue, true)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<!--App-->
|
<!--App-->
|
||||||
<string name="app_name">阅读</string>
|
<string name="app_name">阅读</string>
|
||||||
<string name="app_name_a">阅读·A</string>
|
<string name="app_name_a">阅读·A</string>
|
||||||
@@ -1022,7 +1022,6 @@
|
|||||||
<string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
|
<string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
|
||||||
<string name="reverse_toc">反转目录</string>
|
<string name="reverse_toc">反转目录</string>
|
||||||
<string name="show_discovery">显示发现</string>
|
<string name="show_discovery">显示发现</string>
|
||||||
<string name="show_home">显示主页</string>
|
|
||||||
<string name="style">样式</string>
|
<string name="style">样式</string>
|
||||||
<string name="group_style">分组样式</string>
|
<string name="group_style">分组样式</string>
|
||||||
<string name="export_file_name">导出文件名</string>
|
<string name="export_file_name">导出文件名</string>
|
||||||
@@ -1832,6 +1831,28 @@
|
|||||||
<string name="homepage_layout_config_label">布局配置</string>
|
<string name="homepage_layout_config_label">布局配置</string>
|
||||||
<string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
|
<string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
|
||||||
<string name="homepage_layout_settings">布局设置</string>
|
<string name="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_mode">首页布局模式</string>
|
||||||
<string name="homepage_layout_mixed">混合列表</string>
|
<string name="homepage_layout_mixed">混合列表</string>
|
||||||
<string name="homepage_layout_tabs">分源Tab</string>
|
<string name="homepage_layout_tabs">分源Tab</string>
|
||||||
|
|||||||
@@ -895,7 +895,6 @@
|
|||||||
<string name="diy_edit_source_group">輸入自訂源分組名稱</string>
|
<string name="diy_edit_source_group">輸入自訂源分組名稱</string>
|
||||||
<string name="reverse_toc">反轉目錄</string>
|
<string name="reverse_toc">反轉目錄</string>
|
||||||
<string name="show_discovery">顯示發現</string>
|
<string name="show_discovery">顯示發現</string>
|
||||||
<string name="show_home">顯示主頁</string>
|
|
||||||
<string name="style">樣式</string>
|
<string name="style">樣式</string>
|
||||||
<string name="group_style">分組樣式</string>
|
<string name="group_style">分組樣式</string>
|
||||||
<string name="export_file_name">導出文件名</string>
|
<string name="export_file_name">導出文件名</string>
|
||||||
@@ -1366,6 +1365,28 @@
|
|||||||
<string name="homepage_layout_config_label">佈局配置</string>
|
<string name="homepage_layout_config_label">佈局配置</string>
|
||||||
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
|
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
|
||||||
<string name="homepage_layout_settings">佈局設置</string>
|
<string name="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_mode">首頁佈局模式</string>
|
||||||
<string name="homepage_layout_mixed">混合列表</string>
|
<string name="homepage_layout_mixed">混合列表</string>
|
||||||
<string name="homepage_layout_tabs">分源Tab</string>
|
<string name="homepage_layout_tabs">分源Tab</string>
|
||||||
|
|||||||
@@ -898,7 +898,6 @@
|
|||||||
<string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
|
<string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
|
||||||
<string name="reverse_toc">反轉目錄</string>
|
<string name="reverse_toc">反轉目錄</string>
|
||||||
<string name="show_discovery">顯示發現</string>
|
<string name="show_discovery">顯示發現</string>
|
||||||
<string name="show_home">顯示主頁</string>
|
|
||||||
<string name="style">樣式</string>
|
<string name="style">樣式</string>
|
||||||
<string name="group_style">分組樣式</string>
|
<string name="group_style">分組樣式</string>
|
||||||
<string name="export_file_name">匯出檔案名</string>
|
<string name="export_file_name">匯出檔案名</string>
|
||||||
@@ -1370,6 +1369,28 @@
|
|||||||
<string name="homepage_layout_config_label">佈局配置</string>
|
<string name="homepage_layout_config_label">佈局配置</string>
|
||||||
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
|
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
|
||||||
<string name="homepage_layout_settings">佈局設置</string>
|
<string name="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_mode">首頁佈局模式</string>
|
||||||
<string name="homepage_layout_mixed">混合列表</string>
|
<string name="homepage_layout_mixed">混合列表</string>
|
||||||
<string name="homepage_layout_tabs">分源Tab</string>
|
<string name="homepage_layout_tabs">分源Tab</string>
|
||||||
|
|||||||
@@ -260,7 +260,8 @@
|
|||||||
<string-array name="default_home_page">
|
<string-array name="default_home_page">
|
||||||
<item>@string/home</item>
|
<item>@string/home</item>
|
||||||
<item>@string/bookshelf</item>
|
<item>@string/bookshelf</item>
|
||||||
<item>@string/discovery</item>
|
<item>@string/discovery_modules</item>
|
||||||
|
<item>@string/classic_discovery</item>
|
||||||
<item>@string/rss</item>
|
<item>@string/rss</item>
|
||||||
<item>@string/my</item>
|
<item>@string/my</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
<string-array name="default_home_page_value">
|
<string-array name="default_home_page_value">
|
||||||
<item>home</item>
|
<item>home</item>
|
||||||
<item>bookshelf</item>
|
<item>bookshelf</item>
|
||||||
|
<item>explore_modules</item>
|
||||||
<item>explore</item>
|
<item>explore</item>
|
||||||
<item>rss</item>
|
<item>rss</item>
|
||||||
<item>my</item>
|
<item>my</item>
|
||||||
|
|||||||
@@ -1053,7 +1053,6 @@
|
|||||||
<string name="hide_when_status_bar_show">Hide when status bar show</string>
|
<string name="hide_when_status_bar_show">Hide when status bar show</string>
|
||||||
<string name="reverse_toc">Reverse toc</string>
|
<string name="reverse_toc">Reverse toc</string>
|
||||||
<string name="show_discovery">Show Discovery</string>
|
<string name="show_discovery">Show Discovery</string>
|
||||||
<string name="show_home">Show Homepage</string>
|
|
||||||
<string name="style">Style</string>
|
<string name="style">Style</string>
|
||||||
<string name="group_style">Group style</string>
|
<string name="group_style">Group style</string>
|
||||||
<string name="export_file_name">Export file name</string>
|
<string name="export_file_name">Export file name</string>
|
||||||
@@ -1965,6 +1964,28 @@
|
|||||||
<string name="homepage_layout_config_label">Layout Config</string>
|
<string name="homepage_layout_config_label">Layout Config</string>
|
||||||
<string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string>
|
<string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string>
|
||||||
<string name="homepage_layout_settings">Layout Settings</string>
|
<string name="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_mode">Homepage Layout Mode</string>
|
||||||
<string name="homepage_layout_mixed">Mixed List</string>
|
<string name="homepage_layout_mixed">Mixed List</string>
|
||||||
<string name="homepage_layout_tabs">Tabs by Source</string>
|
<string name="homepage_layout_tabs">Tabs by Source</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user