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