feat: add home dashboard and discovery modes

This commit is contained in:
HapeLee
2026-06-29 02:28:17 +08:00
parent 933bded9d8
commit 5287fe6457
36 changed files with 1803 additions and 210 deletions
+4
View File
@@ -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)
+24 -3
View File
@@ -1,4 +1,4 @@
<resources>
<resources>
<!--App-->
<string name="app_name">阅读</string>
<string name="app_name_a">阅读·A</string>
@@ -1022,7 +1022,6 @@
<string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
<string name="reverse_toc">反转目录</string>
<string name="show_discovery">显示发现</string>
<string name="show_home">显示主页</string>
<string name="style">样式</string>
<string name="group_style">分组样式</string>
<string name="export_file_name">导出文件名</string>
@@ -1832,6 +1831,28 @@
<string name="homepage_layout_config_label">布局配置</string>
<string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">布局设置</string>
<string name="discovery_modules">发现模块</string>
<string name="classic_discovery">经典发现</string>
<string name="switch_to_discovery_modules">切换至发现模块</string>
<string name="switch_to_classic_discovery">切换至经典发现</string>
<string name="home_recent_reading">最近阅读</string>
<string name="home_no_recent_reading">暂无阅读记录</string>
<string name="home_total_read_books">累计阅读</string>
<string name="home_total_reading_time">阅读时长</string>
<string name="unit_books"></string>
<string name="unit_hours">小时</string>
<string name="home_today_reading_goal">今日阅读目标</string>
<string name="home_set_goal">设定目标</string>
<string name="home_today_goal_value">%1$d / %2$d</string>
<string name="home_minutes">分钟</string>
<string name="home_set_reading_goal">设定阅读目标</string>
<string name="home_goal_minutes_label">目标(分钟)</string>
<string name="home_webdav_backup">WebDAV 备份</string>
<string name="home_latest_backup_value">最近备份:%1$s</string>
<string name="home_no_webdav_backup">暂无 WebDAV 备份</string>
<string name="home_loading_webdav_backup">正在获取备份信息…</string>
<string name="home_webdav_backup_confirmation">立即将当前数据备份到 WebDAV</string>
<string name="home_webdav_restore_confirmation">恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。</string>
<string name="homepage_layout_mode">首页布局模式</string>
<string name="homepage_layout_mixed">混合列表</string>
<string name="homepage_layout_tabs">分源Tab</string>
@@ -2192,4 +2213,4 @@
<string name="is_event_listener">EventListener</string>
<string name="rule_call_back">callBackJs</string>
<!-- Missing zh-rTW/HK translations (683 keys) -->
</resources>
</resources>
+22 -1
View File
@@ -895,7 +895,6 @@
<string name="diy_edit_source_group">輸入自訂源分組名稱</string>
<string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string>
<string name="show_home">顯示主頁</string>
<string name="style">樣式</string>
<string name="group_style">分組樣式</string>
<string name="export_file_name">導出文件名</string>
@@ -1366,6 +1365,28 @@
<string name="homepage_layout_config_label">佈局配置</string>
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">佈局設置</string>
<string name="discovery_modules">發現模塊</string>
<string name="classic_discovery">經典發現</string>
<string name="switch_to_discovery_modules">切換至發現模塊</string>
<string name="switch_to_classic_discovery">切換至經典發現</string>
<string name="home_recent_reading">最近閱讀</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string>
<string name="unit_books"></string>
<string name="unit_hours">小時</string>
<string name="home_today_reading_goal">今日閱讀目標</string>
<string name="home_set_goal">設定目標</string>
<string name="home_today_goal_value">%1$d / %2$d</string>
<string name="home_minutes">分鐘</string>
<string name="home_set_reading_goal">設定閱讀目標</string>
<string name="home_goal_minutes_label">目標(分鐘)</string>
<string name="home_webdav_backup">WebDAV 備份</string>
<string name="home_latest_backup_value">最近備份:%1$s</string>
<string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
<string name="home_loading_webdav_backup">正在獲取備份資訊…</string>
<string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV</string>
<string name="home_webdav_restore_confirmation">恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
<string name="homepage_layout_mode">首頁佈局模式</string>
<string name="homepage_layout_mixed">混合列表</string>
<string name="homepage_layout_tabs">分源Tab</string>
+22 -1
View File
@@ -898,7 +898,6 @@
<string name="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
<string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string>
<string name="show_home">顯示主頁</string>
<string name="style">樣式</string>
<string name="group_style">分組樣式</string>
<string name="export_file_name">匯出檔案名</string>
@@ -1370,6 +1369,28 @@
<string name="homepage_layout_config_label">佈局配置</string>
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">佈局設置</string>
<string name="discovery_modules">發現模組</string>
<string name="classic_discovery">經典發現</string>
<string name="switch_to_discovery_modules">切換至發現模組</string>
<string name="switch_to_classic_discovery">切換至經典發現</string>
<string name="home_recent_reading">最近閱讀</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string>
<string name="unit_books"></string>
<string name="unit_hours">小時</string>
<string name="home_today_reading_goal">今日閱讀目標</string>
<string name="home_set_goal">設定目標</string>
<string name="home_today_goal_value">%1$d / %2$d</string>
<string name="home_minutes">分鐘</string>
<string name="home_set_reading_goal">設定閱讀目標</string>
<string name="home_goal_minutes_label">目標(分鐘)</string>
<string name="home_webdav_backup">WebDAV 備份</string>
<string name="home_latest_backup_value">最近備份:%1$s</string>
<string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
<string name="home_loading_webdav_backup">正在取得備份資訊…</string>
<string name="home_webdav_backup_confirmation">立即將目前資料備份到 WebDAV</string>
<string name="home_webdav_restore_confirmation">還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。</string>
<string name="homepage_layout_mode">首頁佈局模式</string>
<string name="homepage_layout_mixed">混合列表</string>
<string name="homepage_layout_tabs">分源Tab</string>
+3 -1
View File
@@ -260,7 +260,8 @@
<string-array name="default_home_page">
<item>@string/home</item>
<item>@string/bookshelf</item>
<item>@string/discovery</item>
<item>@string/discovery_modules</item>
<item>@string/classic_discovery</item>
<item>@string/rss</item>
<item>@string/my</item>
</string-array>
@@ -276,6 +277,7 @@
<string-array name="default_home_page_value">
<item>home</item>
<item>bookshelf</item>
<item>explore_modules</item>
<item>explore</item>
<item>rss</item>
<item>my</item>
+22 -1
View File
@@ -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>