油画首页

This commit is contained in:
HapeLee
2026-06-30 01:52:13 +08:00
parent abbb90d06a
commit 9f897ddd27
39 changed files with 2124 additions and 1510 deletions
@@ -44,9 +44,11 @@ object PreferKey {
const val ttsSpeechRate = "ttsSpeechRate"
const val prevKeys = "prevKeyCodes"
const val nextKeys = "nextKeyCodes"
const val showHome = "showHome"
const val showDiscovery = "showDiscovery"
const val enableReview = "enableReview"
const val showRss = "showRss"
const val mainNavigationOrder = "mainNavigationOrder"
const val showStatusBar = "showStatusBar"
const val swipeAnimation = "swipeAnimation"
const val bookshelfLayout = "bookshelfLayout"
@@ -364,9 +366,6 @@ object PreferKey {
const val llmTemperature = "llmTemperature"
const val llmPrompt = "llmPrompt"
const val homepageModuleOrder = "homepageModuleOrder"
const val homepageModuleHidden = "homepageModuleHidden"
const val homepageLayoutMode = "homepageLayoutMode"
const val navIconHome = "navIconHome"
// Lab
@@ -6,6 +6,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import io.legado.app.data.entities.readRecord.HomeRecentBookRow
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -25,6 +26,56 @@ interface ReadRecordDao {
@Query("SELECT sum(readTime) FROM readRecord")
fun getTotalReadTime(): Flow<Long?>
@Query(
"""
SELECT COUNT(*) FROM (
SELECT 1
FROM readRecord
GROUP BY bookName, bookAuthor
)
"""
)
fun observeTotalReadBookCount(): Flow<Int>
@Query("SELECT SUM(readTime) FROM readRecordDetail WHERE date = :date")
fun observeReadTimeByDate(date: String): Flow<Long?>
@Query(
"""
WITH recent AS (
SELECT
bookName,
bookAuthor,
MAX(lastRead) AS lastRead
FROM readRecord
GROUP BY bookName, bookAuthor
ORDER BY lastRead DESC
LIMIT :limit
)
SELECT
recent.bookName AS recordName,
recent.bookAuthor AS recordAuthor,
book.bookUrl AS bookUrl,
book.origin AS origin,
book.coverUrl AS coverUrl,
book.customCoverUrl AS customCoverUrl,
book.durChapterTitle AS chapterTitle,
book.totalChapterNum AS totalChapterNum,
book.durChapterIndex AS chapterIndex
FROM recent
LEFT JOIN books AS book ON book.bookUrl = (
SELECT candidate.bookUrl
FROM books AS candidate
WHERE candidate.name = recent.bookName
AND candidate.author = recent.bookAuthor
ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC
LIMIT 1
)
ORDER BY recent.lastRead DESC
"""
)
fun observeRecentHomeBooks(limit: Int): Flow<List<HomeRecentBookRow>>
@Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long?
@@ -0,0 +1,14 @@
package io.legado.app.data.entities.readRecord
data class HomeRecentBookRow(
val recordName: String,
val recordAuthor: String,
val bookUrl: String?,
val origin: String?,
val coverUrl: String?,
val customCoverUrl: String?,
val chapterTitle: String?,
val totalChapterNum: Int?,
val chapterIndex: Int?,
)
@@ -19,13 +19,14 @@ 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")
val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
// Change source options
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
@@ -0,0 +1,96 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.domain.gateway.HomeDashboardGateway
import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
import io.legado.app.domain.model.HomeDashboardSection
import io.legado.app.domain.model.HomeReadingBook
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class HomeDashboardRepository(
private val readRecordDao: ReadRecordDao,
private val localPreferencesRepository: LocalPreferencesRepository,
) : HomeDashboardGateway {
override fun observeTotalReadBooks(): Flow<Int> =
readRecordDao.observeTotalReadBookCount()
override fun observeTotalReadTime(): Flow<Long> =
readRecordDao.getTotalReadTime().map { it ?: 0L }
override fun observeReadTime(date: String): Flow<Long> =
readRecordDao.observeReadTimeByDate(date).map { it ?: 0L }
override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> =
readRecordDao.observeRecentHomeBooks(limit).map { rows ->
rows.map { row ->
HomeReadingBook(
bookUrl = row.bookUrl,
name = row.recordName,
author = row.recordAuthor,
origin = row.origin,
coverPath = if (row.customCoverUrl.isNullOrEmpty()) {
row.coverUrl
} else {
row.customCoverUrl
},
chapterTitle = row.chapterTitle,
chapterProgress = if (
row.totalChapterNum != null &&
row.totalChapterNum > 0 &&
row.chapterIndex != null
) {
(row.chapterIndex + 1)
.coerceIn(0, row.totalChapterNum)
.toFloat() / row.totalChapterNum
} else {
null
},
)
}
}
override fun observeDailyGoal(defaultValue: Int): Flow<Int> =
localPreferencesRepository.getPreference(
LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
defaultValue,
)
override fun observeSelectedSourceSetUrl(): Flow<String?> =
localPreferencesRepository.getPreference(
LocalPreferencesKeys.HOME_SOURCE_SET_URL,
"",
).map { it.takeIf(String::isNotBlank) }
override fun observeVisibleSections(): Flow<Set<HomeDashboardSection>> =
localPreferencesRepository.getPreference(
LocalPreferencesKeys.HOME_DASHBOARD_SECTIONS,
DEFAULT_HOME_DASHBOARD_SECTIONS.joinToString(",") { it.storageValue },
).map(HomeDashboardSection::fromStorage)
override suspend fun updateDailyGoal(minutes: Int) {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
minutes,
)
}
override suspend fun updateSelectedSourceSetUrl(sourceUrl: String) {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.HOME_SOURCE_SET_URL,
sourceUrl,
)
}
override suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>) {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.HOME_DASHBOARD_SECTIONS,
HomeDashboardSection.entries
.filter(sections::contains)
.joinToString(",") { it.storageValue },
)
}
}
@@ -1,8 +1,9 @@
package io.legado.app.data.repository
import androidx.room.Transaction
import androidx.room.withTransaction
import cn.hutool.core.date.DatePattern
import cn.hutool.core.date.DateUtil
import io.legado.app.data.AppDatabase
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
@@ -15,7 +16,8 @@ import kotlin.math.max
import kotlin.math.min
class ReadRecordRepository(
private val dao: ReadRecordDao
private val dao: ReadRecordDao,
private val database: AppDatabase,
) {
private fun getCurrentDeviceId(): String = ""
@@ -85,11 +87,15 @@ class ReadRecordRepository(
* 保存一个完整的阅读会话.
*/
suspend fun saveReadSession(newSession: ReadRecordSession) {
val segmentDuration = newSession.endTime - newSession.startTime
dao.insertSession(newSession)
val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
updateReadRecord(newSession, segmentDuration)
if (newSession.endTime <= newSession.startTime) return
database.withTransaction {
val segmentDuration = newSession.endTime - newSession.startTime
dao.insertSession(newSession)
val dateString =
DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString)
updateReadRecord(newSession, segmentDuration)
}
}
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
@@ -151,58 +157,72 @@ class ReadRecordRepository(
}
suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(
detail.deviceId,
detail.bookName,
detail.bookAuthor,
detail.date
)
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
database.withTransaction {
dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(
detail.deviceId,
detail.bookName,
detail.bookAuthor,
detail.date
)
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
}
}
@Transaction
suspend fun deleteSession(session: ReadRecordSession) {
dao.deleteSession(session)
database.withTransaction {
dao.deleteSession(session)
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions =
dao.getSessionsByBookAndDate(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions =
dao.getSessionsByBookAndDate(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (remainingSessions.isEmpty()) {
val detail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
detail?.let { dao.deleteDetail(it) }
} else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
val totalWords = remainingSessions.sumOf { it.words }
val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime }
if (remainingSessions.isEmpty()) {
val detail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
detail?.let { dao.deleteDetail(it) }
} else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
val totalWords = remainingSessions.sumOf { it.words }
val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime }
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
existingDetail?.copy(
readTime = totalTime,
readWords = totalWords,
firstReadTime = firstRead,
lastReadTime = lastRead
)?.let { dao.insertDetail(it) }
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
dao.insertDetail(
existingDetail?.copy(
readTime = totalTime,
readWords = totalWords,
firstReadTime = firstRead,
lastReadTime = lastRead
) ?: ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
date = dateString,
readTime = totalTime,
readWords = totalWords,
firstReadTime = firstRead,
lastReadTime = lastRead,
)
)
}
updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
}
updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
}
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
@@ -214,27 +234,44 @@ class ReadRecordRepository(
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
readTime = totalTime,
lastRead = lastRead
)?.let { dao.update(it) }
val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
if (existingRecord == null) {
dao.insert(
ReadRecord(
deviceId = deviceId,
bookName = bookName,
bookAuthor = bookAuthor,
readTime = totalTime,
lastRead = lastRead,
)
)
} else {
dao.update(
existingRecord.copy(
readTime = totalTime,
lastRead = lastRead
)
)
}
}
}
suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
database.withTransaction {
dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
}
}
@Transaction
suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
sourceRecords.forEach { sourceRecord ->
mergeSingleReadRecordInto(targetRecord, sourceRecord)
database.withTransaction {
sourceRecords.forEach { sourceRecord ->
mergeSingleReadRecordInto(targetRecord, sourceRecord)
}
}
}
@Transaction
private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
if (targetRecord == sourceRecord) return
if (targetRecord.deviceId != sourceRecord.deviceId) return
@@ -39,7 +39,7 @@ class WebDavBackupRepository : WebDavBackupGateway {
override suspend fun getLatestBackup(): WebDavBackup? {
return withContext(IO) {
AppWebDav.lastBackUp().getOrNull()?.let {
AppWebDav.lastBackUp().getOrThrow()?.let {
WebDavBackup(
name = it.displayName,
lastModify = it.lastModify
@@ -7,14 +7,14 @@ import coil.decode.ImageDecoderDecoder
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
import io.legado.app.data.repository.AiProfileRepository
import io.legado.app.data.repository.AiTextRepositoryImpl
import io.legado.app.data.repository.AiToolRepository
import io.legado.app.data.repository.AppStartupRepository
import io.legado.app.data.repository.BackupRestoreRepository
import io.legado.app.data.repository.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl
import io.legado.app.data.repository.BookGroupRepository
@@ -30,6 +30,7 @@ import io.legado.app.data.repository.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.data.repository.HomeDashboardRepository
import io.legado.app.data.repository.HomepageModulesRepository
import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository
@@ -47,14 +48,14 @@ import io.legado.app.data.repository.TranslationCacheRepositoryImpl
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
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.AiToolGateway
import io.legado.app.domain.gateway.AppStartupGateway
import io.legado.app.domain.gateway.BackupRestoreGateway
import io.legado.app.domain.gateway.BookCacheCleanupGateway
import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.gateway.BookSearchGateway
@@ -62,6 +63,7 @@ import io.legado.app.domain.gateway.BookSourceCallbackGateway
import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
import io.legado.app.domain.gateway.DictionaryGateway
import io.legado.app.domain.gateway.ExploreBooksGateway
import io.legado.app.domain.gateway.HomeDashboardGateway
import io.legado.app.domain.gateway.HomepageModulesGateway
import io.legado.app.domain.gateway.LocalBookGateway
import io.legado.app.domain.gateway.ReadingProgressGateway
@@ -70,6 +72,7 @@ import io.legado.app.domain.gateway.WebDavBackupGateway
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.AiChatGenerationUseCase
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.BackupRestoreUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -81,10 +84,10 @@ import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase
import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
import io.legado.app.domain.usecase.GetChapterContentUseCase
import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.AiChatGenerationUseCase
import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
import io.legado.app.domain.usecase.HomeDashboardUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
@@ -102,6 +105,7 @@ import io.legado.app.help.coil.CoverInterceptor
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.ai.chat.AiChatViewModel
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -124,11 +128,10 @@ import io.legado.app.ui.book.search.SearchViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.ai.AiConfigViewModel
import io.legado.app.ui.config.ai.AiModelEditViewModel
import io.legado.app.ui.config.ai.AiProviderEditViewModel
import io.legado.app.ui.ai.chat.AiChatViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
@@ -138,7 +141,6 @@ import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
import io.legado.app.ui.dict.DictViewModel
import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
import io.legado.app.ui.main.MainRouteSearchContent
import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -156,10 +158,12 @@ import io.legado.app.ui.rss.favorites.RssFavoritesViewModel
import io.legado.app.ui.rss.read.ReadRssViewModel
import io.legado.app.ui.rss.source.manage.RssSourceViewModel
import io.legado.app.ui.rss.subscription.RuleSubViewModel
import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import java.time.Clock
val appModule = module {
@@ -170,6 +174,7 @@ val appModule = module {
single { get<AppDatabase>().bookSourceDao }
singleOf(::ReadRecordRepository)
single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
singleOf(::BookRepository)
singleOf(::BookGroupRepository)
singleOf(::BookSourceRepository)
@@ -195,6 +200,7 @@ val appModule = module {
singleOf(::ClearBookCacheUseCase)
singleOf(::DeleteBooksUseCase)
singleOf(::GetReadingProgressUseCase)
single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
singleOf(::RemoveBookGroupAssignmentUseCase)
singleOf(::UpdateBooksGroupUseCase)
singleOf(::UploadReadingProgressUseCase)
@@ -0,0 +1,27 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.HomeDashboardSection
import io.legado.app.domain.model.HomeReadingBook
import kotlinx.coroutines.flow.Flow
interface HomeDashboardGateway {
fun observeTotalReadBooks(): Flow<Int>
fun observeTotalReadTime(): Flow<Long>
fun observeReadTime(date: String): Flow<Long>
fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>>
fun observeDailyGoal(defaultValue: Int): Flow<Int>
fun observeSelectedSourceSetUrl(): Flow<String?>
fun observeVisibleSections(): Flow<Set<HomeDashboardSection>>
suspend fun updateDailyGoal(minutes: Int)
suspend fun updateSelectedSourceSetUrl(sourceUrl: String)
suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>)
}
@@ -0,0 +1,48 @@
package io.legado.app.domain.model
data class HomeDashboard(
val totalReadBooks: Int,
val totalReadTimeMillis: Long,
val todayReadTimeMillis: Long,
val dailyGoalMinutes: Int,
val recentBooks: List<HomeReadingBook>,
)
data class HomeReadingBook(
val bookUrl: String?,
val name: String,
val author: String,
val origin: String?,
val coverPath: String?,
val chapterTitle: String?,
val chapterProgress: Float?,
)
enum class HomeDashboardSection(val storageValue: String) {
RecentBook("recent_book"),
TotalReadBooks("total_read_books"),
TotalReadTime("total_read_time"),
RecentBooks("recent_books"),
DailyGoal("daily_goal"),
WebDavBackup("webdav_backup");
companion object {
fun fromStorage(value: String): Set<HomeDashboardSection> {
if (value.isBlank()) return emptySet()
val sections = value
.split(',')
.map(String::trim)
.mapNotNull { stored ->
entries.firstOrNull { it.storageValue == stored }
}
.toSet()
return sections.ifEmpty { entries.toSet() }
}
}
}
val DEFAULT_HOME_DASHBOARD_SECTIONS: Set<HomeDashboardSection> =
HomeDashboardSection.entries.toSet()
const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60
@@ -0,0 +1,88 @@
package io.legado.app.domain.usecase
import io.legado.app.domain.gateway.HomeDashboardGateway
import io.legado.app.domain.model.DEFAULT_DAILY_READING_GOAL_MINUTES
import io.legado.app.domain.model.HomeDashboard
import io.legado.app.domain.model.HomeDashboardSection
import io.legado.app.domain.model.MAX_DAILY_READING_GOAL_MINUTES
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import java.time.Clock
import java.time.Duration
import java.time.ZonedDateTime
class HomeDashboardUseCase(
private val gateway: HomeDashboardGateway,
private val clock: Clock,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun observe(): Flow<HomeDashboard> {
val todayReadTime = currentDate()
.flatMapLatest(gateway::observeReadTime)
return combine(
gateway.observeTotalReadBooks(),
gateway.observeTotalReadTime(),
todayReadTime,
gateway.observeRecentBooks(RECENT_BOOK_LIMIT),
gateway.observeDailyGoal(DEFAULT_DAILY_READING_GOAL_MINUTES),
) { totalBooks, totalTime, todayTime, recentBooks, dailyGoal ->
HomeDashboard(
totalReadBooks = totalBooks,
totalReadTimeMillis = totalTime,
todayReadTimeMillis = todayTime,
dailyGoalMinutes = dailyGoal.coerceIn(
1,
MAX_DAILY_READING_GOAL_MINUTES,
),
recentBooks = recentBooks,
)
}
}
suspend fun updateDailyGoal(minutes: Int) {
gateway.updateDailyGoal(
minutes.coerceIn(1, MAX_DAILY_READING_GOAL_MINUTES)
)
}
fun observeSelectedSourceSetUrl(): Flow<String?> =
gateway.observeSelectedSourceSetUrl()
fun observeVisibleSections(): Flow<Set<HomeDashboardSection>> =
gateway.observeVisibleSections()
suspend fun updateSelectedSourceSetUrl(sourceUrl: String) {
gateway.updateSelectedSourceSetUrl(sourceUrl)
}
suspend fun updateVisibleSections(sections: Set<HomeDashboardSection>) {
gateway.updateVisibleSections(sections)
}
private fun currentDate(): Flow<String> = flow {
while (true) {
val now = ZonedDateTime.now(clock)
emit(now.toLocalDate().toString())
val nextDay = now.toLocalDate()
.plusDays(1)
.atStartOfDay(clock.zone)
delay(
Duration.between(now, nextDay)
.toMillis()
.coerceAtLeast(MIN_DATE_REFRESH_DELAY_MILLIS)
)
}
}.distinctUntilChanged()
private companion object {
const val RECENT_BOOK_LIMIT = 7
const val MIN_DATE_REFRESH_DELAY_MILLIS = 1_000L
}
}
@@ -314,6 +314,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
BackupConfig.backupPath = value
}
val showHome: Boolean
get() = ThemeConfig.showHome
val showDiscovery: Boolean
get() = ThemeConfig.showDiscovery
@@ -174,6 +174,7 @@ object ThemeImportExport {
customTagColorsJson = ThemeConfig.customTagColorsJson,
// 主界面设置
showHome = ThemeConfig.showHome,
showDiscovery = ThemeConfig.showDiscovery,
showRss = ThemeConfig.showRss,
showStatusBar = ThemeConfig.showStatusBar,
@@ -184,6 +185,7 @@ object ThemeImportExport {
tabletInterface = ThemeConfig.tabletInterface,
labelVisibilityMode = ThemeConfig.labelVisibilityMode,
defaultHomePage = ThemeConfig.defaultHomePage,
mainNavigationOrder = ThemeConfig.mainNavigationOrder,
// 导航栏图标
navIconHome = ThemeConfig.navIconHome,
@@ -343,6 +345,7 @@ object ThemeImportExport {
ThemeConfig.customTagColorsJson = data.customTagColorsJson
// 主界面设置
ThemeConfig.showHome = data.showHome
ThemeConfig.showDiscovery = data.showDiscovery
ThemeConfig.showRss = data.showRss
ThemeConfig.showStatusBar = data.showStatusBar
@@ -353,6 +356,7 @@ object ThemeImportExport {
ThemeConfig.tabletInterface = data.tabletInterface
ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
ThemeConfig.defaultHomePage = data.defaultHomePage
ThemeConfig.mainNavigationOrder = data.mainNavigationOrder
// 导航栏图标
ThemeConfig.navIconHome = data.navIconHome
@@ -579,6 +583,7 @@ data class ThemeExportData(
val customTagColorsJson: String? = null,
// 主界面设置
val showHome: Boolean = true,
val showDiscovery: Boolean = true,
val showRss: Boolean = true,
val showStatusBar: Boolean = true,
@@ -589,6 +594,7 @@ data class ThemeExportData(
val tabletInterface: String = "auto",
val labelVisibilityMode: String = "auto",
val defaultHomePage: String = "bookshelf",
val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
// 导航栏图标
val navIconHome: String = "",
@@ -4,6 +4,7 @@ import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate
object MainConfig {
var showHome by prefDelegate(PreferKey.showHome, true)
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRSS by prefDelegate(PreferKey.showRss, true)
var showBottomView by prefDelegate(PreferKey.showBottomView, true)
@@ -13,6 +14,10 @@ object MainConfig {
false
)
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
var mainNavigationOrder by prefDelegate(
PreferKey.mainNavigationOrder,
"home,bookshelf,explore,rss,my",
)
var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)
@@ -0,0 +1,166 @@
package io.legado.app.ui.config.themeConfig
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.main.MainDestination
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.move
import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun MainNavigationSettingsSheet(
show: Boolean,
onDismissRequest: () -> Unit,
) {
val homeLabel = stringResource(R.string.home)
val bookshelfLabel = stringResource(R.string.bookshelf)
val discoveryLabel = stringResource(R.string.discovery)
val rssLabel = stringResource(R.string.rss)
val myLabel = stringResource(R.string.my)
val destinations = listOfNotNull(
if (ThemeConfig.showHome) homeLabel to MainDestination.Home.route else null,
bookshelfLabel to MainDestination.Bookshelf.route,
if (ThemeConfig.showDiscovery) {
discoveryLabel to MainDestination.Explore.route
} else {
null
},
if (ThemeConfig.showRss) rssLabel to MainDestination.Rss.route else null,
myLabel to MainDestination.My.route,
)
val selectedDefault = ThemeConfig.defaultHomePage.takeIf { route ->
destinations.any { it.second == route }
} ?: MainDestination.Bookshelf.route
val configuredOrder = MainDestination.ordered(ThemeConfig.mainNavigationOrder)
var navigationItems by remember(show) { mutableStateOf(configuredOrder) }
val navigationListState = rememberLazyListState()
val reorderableState =
rememberReorderableLazyListState(navigationListState) { from, to ->
navigationItems = navigationItems.toMutableList().apply {
move(from.index, to.index)
}
}
LaunchedEffect(configuredOrder, reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) {
navigationItems = configuredOrder
}
}
LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) {
ThemeConfig.mainNavigationOrder =
navigationItems.joinToString(",") { it.route }
}
}
fun updateVisibility(
route: String,
visible: Boolean,
update: (Boolean) -> Unit,
) {
update(visible)
if (!visible && ThemeConfig.defaultHomePage == route) {
ThemeConfig.defaultHomePage = MainDestination.Bookshelf.route
}
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.main_navigation_settings),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
) {
CompactSwitchSettingItem(
title = stringResource(R.string.show_home),
checked = ThemeConfig.showHome,
onCheckedChange = {
updateVisibility(
MainDestination.Home.route,
it,
) { value -> ThemeConfig.showHome = value }
},
)
CompactSwitchSettingItem(
title = stringResource(R.string.show_discovery),
checked = ThemeConfig.showDiscovery,
onCheckedChange = {
updateVisibility(
MainDestination.Explore.route,
it,
) { value -> ThemeConfig.showDiscovery = value }
},
)
CompactSwitchSettingItem(
title = stringResource(R.string.show_rss),
checked = ThemeConfig.showRss,
onCheckedChange = {
updateVisibility(
MainDestination.Rss.route,
it,
) { value -> ThemeConfig.showRss = value }
},
)
CompactDropdownSettingItem(
title = stringResource(R.string.default_home_page),
selectedValue = selectedDefault,
displayEntries = destinations.map { it.first }.toTypedArray(),
entryValues = destinations.map { it.second }.toTypedArray(),
onValueChange = { ThemeConfig.defaultHomePage = it },
)
AppText(
text = stringResource(R.string.navigation_order),
style = LegadoTheme.typography.titleSmallEmphasized,
modifier = Modifier.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 8.dp,
),
)
LazyColumn(
state = navigationListState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
items = navigationItems,
key = { it.route },
) { destination ->
ReorderableSelectionItem(
state = reorderableState,
key = destination.route,
title = stringResource(destination.labelId),
containerColor = LegadoTheme.colorScheme.onSheetContent,
)
}
}
}
}
}
@@ -142,6 +142,8 @@ object ThemeConfig {
customTagColorsJson = GSON.toJson(colors)
}
var showHome by prefDelegate(PreferKey.showHome, true)
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRss by prefDelegate(PreferKey.showRss, true)
@@ -165,6 +167,11 @@ object ThemeConfig {
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
var mainNavigationOrder by prefDelegate(
PreferKey.mainNavigationOrder,
"home,bookshelf,explore,rss,my",
)
var navExtended by prefDelegate("navExtended", false)
var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
@@ -123,6 +123,7 @@ fun ThemeConfigScreen(
var showLauncherIconPicker by remember { mutableStateOf(false) }
var showBorderColorPicker by remember { mutableStateOf(false) }
var showNavIconSheet by remember { mutableStateOf(false) }
var showMainNavigationSheet by remember { mutableStateOf(false) }
var showFontSheet by remember { mutableStateOf(false) }
val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
@@ -375,15 +376,10 @@ fun ThemeConfigScreen(
}
SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
SwitchSettingItem(
title = stringResource(R.string.show_discovery),
checked = ThemeConfig.showDiscovery,
onCheckedChange = { ThemeConfig.showDiscovery = it }
)
SwitchSettingItem(
title = stringResource(R.string.show_rss),
checked = ThemeConfig.showRss,
onCheckedChange = { ThemeConfig.showRss = it }
ClickableSettingItem(
title = stringResource(R.string.main_navigation_settings),
description = stringResource(R.string.main_navigation_settings_summary),
onClick = { showMainNavigationSheet = true },
)
SwitchSettingItem(
title = stringResource(R.string.show_status),
@@ -446,13 +442,6 @@ fun ThemeConfigScreen(
entryValues = stringArrayResource(R.array.label_vis_mode_value),
onValueChange = { ThemeConfig.labelVisibilityMode = it }
)
DropdownListSettingItem(
title = stringResource(R.string.default_home_page),
selectedValue = ThemeConfig.defaultHomePage,
displayEntries = stringArrayResource(R.array.default_home_page),
entryValues = stringArrayResource(R.array.default_home_page_value),
onValueChange = { ThemeConfig.defaultHomePage = it }
)
}
SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
@@ -841,6 +830,11 @@ fun ThemeConfigScreen(
onDismissRequest = { showNavIconSheet = false }
)
MainNavigationSettingsSheet(
show = showMainNavigationSheet,
onDismissRequest = { showMainNavigationSheet = false },
)
LauncherIconPickerSheet(
show = showLauncherIconPicker,
@@ -163,6 +163,11 @@ fun EditThemeSheet(
// Interface layout
SectionTitle(stringResource(R.string.theme_manage_section_layout))
CompactSwitchSettingItem(
title = stringResource(R.string.show_home),
checked = data.showHome,
onCheckedChange = { data = data.copy(showHome = it) }
)
CompactSwitchSettingItem(
title = stringResource(R.string.theme_manage_show_discovery),
checked = data.showDiscovery,
@@ -35,9 +35,17 @@ sealed class MainDestination(
)
companion object {
const val DISCOVERY_MODULES_ROUTE = "explore_modules"
val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
fun ordered(order: String): List<MainDestination> {
val byRoute = mainDestinations.associateBy { it.route }
val ordered = order
.split(',')
.map(String::trim)
.distinct()
.mapNotNull(byRoute::get)
return ordered + mainDestinations.filterNot { it in ordered }
}
}
}
@@ -182,14 +182,9 @@ fun MainScreen(
val destinations = mainUiState.destinations
val initialPage = remember(destinations, mainUiState.defaultHomePage) {
val defaultRoute = if (
mainUiState.defaultHomePage == MainDestination.DISCOVERY_MODULES_ROUTE
) {
MainDestination.Explore.route
} else {
mainUiState.defaultHomePage
val index = destinations.indexOfFirst {
it.route == mainUiState.defaultHomePage
}
val index = destinations.indexOfFirst { it.route == defaultRoute }
if (index != -1) index else 0
}
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
@@ -392,6 +387,17 @@ fun MainScreen(
onOpenBook = { book ->
context.startActivityForBook(book)
},
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToBookInfo(
name ?: "",
author ?: "",
bookUrl,
origin,
coverPath,
sharedCoverKey,
)
},
onOpenExploreShow = onNavigateToExploreShow,
onOpenBackupSettings = onNavigateToBackupSettings,
onNavigateToReadRecord = onNavigateToReadRecord,
onNavigateToReadRecordOverview = onNavigateToReadRecordOverview,
@@ -423,21 +429,7 @@ fun MainScreen(
)
MainDestination.Explore -> ExploreScreen(
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,12 +6,9 @@ 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
@@ -27,7 +24,6 @@ class MainViewModel(
application: Application,
private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
private val webDavBackupUseCase: WebDavBackupUseCase,
private val localPreferencesRepository: LocalPreferencesRepository,
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(readMainUiState())
@@ -36,24 +32,12 @@ class MainViewModel(
val effects = _effects.asSharedFlow()
init {
// 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State,全链路走 DS
// 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State
viewModelScope.launch {
snapshotFlow {
readMainUiState()
}.collect { 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))
}
_uiState.value = newState
}
}
deleteNotShelfBook()
@@ -89,17 +73,6 @@ 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))
@@ -158,12 +131,12 @@ data class MainUiState(
val useFloatingBottomBarLiquidGlass: Boolean = false,
val labelVisibilityMode: String = "auto",
val navExtended: Boolean = false,
val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
)
private fun MainViewModel.readMainUiState(): MainUiState {
val destinations = MainDestination.mainDestinations.filter {
val destinations = MainDestination.ordered(ThemeConfig.mainNavigationOrder).filter {
when (it) {
MainDestination.Home -> ThemeConfig.showHome
MainDestination.Explore -> ThemeConfig.showDiscovery
MainDestination.Rss -> ThemeConfig.showRss
else -> true
@@ -2,16 +2,10 @@ 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
@@ -32,7 +26,6 @@ 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
@@ -47,7 +40,6 @@ 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
@@ -67,7 +59,6 @@ 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
@@ -95,64 +86,19 @@ 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)
},
)
}
}
}
ExploreDiscoveryScreen(onOpenExploreShow = onOpenExploreShow)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class,
ExperimentalSharedTransitionApi::class,
)
@Composable
private fun ExploreDiscoveryScreen(
viewModel: ExploreViewModel = koinViewModel(),
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onSwitchToHomepage: () -> Unit,
) {
val context = LocalContext.current
val activity = context as? AppCompatActivity
@@ -208,14 +154,6 @@ private fun ExploreDiscoveryScreen(
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),
@@ -1,12 +0,0 @@
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
}
}
}
@@ -3,6 +3,13 @@ package io.legado.app.ui.main.home
import androidx.annotation.StringRes
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.Book
import io.legado.app.domain.model.DEFAULT_DAILY_READING_GOAL_MINUTES
import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
import io.legado.app.domain.model.HomeDashboardSection
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
@Stable
data class HomeUiState(
@@ -11,8 +18,13 @@ data class HomeUiState(
val todayReadTimeMillis: Long = 0L,
val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
val recentBook: HomeRecentBookUi? = null,
val recentBooks: ImmutableList<HomeRecentBookUi> = persistentListOf(),
val selectedSourceSetUrl: String? = null,
val visibleSections: ImmutableSet<HomeDashboardSection> =
DEFAULT_HOME_DASHBOARD_SECTIONS.toImmutableSet(),
val latestBackup: HomeBackupUi? = null,
val isBackupLoading: Boolean = true,
val isBackupLoadError: Boolean = false,
val isBackupActionRunning: Boolean = false,
val activeDialog: HomeDialog? = null,
val activeSheet: HomeSheet? = null,
@@ -37,6 +49,13 @@ data class HomeBackupUi(
sealed interface HomeIntent {
data object RecentBookClick : HomeIntent
data class RecentHistoryBookClick(val bookUrl: String) : HomeIntent
data class SelectSourceSet(val sourceUrl: String) : HomeIntent
data object DashboardSettingsClick : HomeIntent
data class SetSectionVisible(
val section: HomeDashboardSection,
val visible: Boolean,
) : HomeIntent
data object ReadingGoalClick : HomeIntent
data class UpdateReadingGoal(val minutes: Int) : HomeIntent
data object BackupClick : HomeIntent
@@ -52,6 +71,7 @@ sealed interface HomeIntent {
data class RestoreLocalFileSelected(val uri: String) : HomeIntent
data object ConfirmRestore : HomeIntent
data object BackupSettingsClick : HomeIntent
data object RetryBackupInfo : HomeIntent
data object DismissDialog : HomeIntent
data object DismissSheet : HomeIntent
}
@@ -83,6 +103,7 @@ sealed interface HomeDialog {
@Stable
sealed interface HomeSheet {
data object DashboardSettings : HomeSheet
data object BackupOptions : HomeSheet
data object RestoreOptions : HomeSheet
}
@@ -92,6 +113,3 @@ enum class HomeBackupDestination(val mode: String) {
WebDav("webdav"),
LocalAndWebDav("both"),
}
const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30
const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60
File diff suppressed because it is too large Load Diff
@@ -3,31 +3,35 @@ 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.HomeDashboardSection
import io.legado.app.domain.model.HomeReadingBook
import io.legado.app.domain.model.WebDavBackup
import io.legado.app.domain.usecase.BackupRestoreUseCase
import io.legado.app.domain.usecase.HomeDashboardUseCase
import io.legado.app.domain.usecase.WebDavBackupUseCase
import io.legado.app.ui.config.backupConfig.BackupConfig
import io.legado.app.utils.isContentScheme
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import kotlinx.coroutines.sync.Mutex
class HomeViewModel(
private val readRecordRepository: ReadRecordRepository,
private val homeDashboardUseCase: HomeDashboardUseCase,
private val bookRepository: BookRepository,
private val localPreferencesRepository: LocalPreferencesRepository,
private val webDavBackupUseCase: WebDavBackupUseCase,
private val backupRestoreUseCase: BackupRestoreUseCase,
) : ViewModel() {
@@ -37,58 +41,15 @@ class HomeViewModel(
private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
private var backupRefreshJob: Job? = null
private val backupActionMutex = Mutex()
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
}
},
)
},
)
homeDashboardUseCase.observe(),
homeDashboardUseCase.observeSelectedSourceSetUrl(),
homeDashboardUseCase.observeVisibleSections(),
) { dashboard, selectedSourceUrl, visibleSections ->
Triple(dashboard, selectedSourceUrl, visibleSections)
}
val uiState = combine(
@@ -96,15 +57,24 @@ class HomeViewModel(
_backupState,
_activeDialog,
_activeSheet,
) { dashboard, backup, dialog, sheet ->
) { dashboardData, backup, dialog, sheet ->
val (dashboard, selectedSourceUrl, visibleSections) = dashboardData
HomeUiState(
totalReadBooks = dashboard.totalReadBooks,
totalReadTimeMillis = dashboard.totalReadTimeMillis,
todayReadTimeMillis = dashboard.todayReadTimeMillis,
dailyGoalMinutes = dashboard.dailyGoalMinutes,
recentBook = dashboard.recentBook,
recentBook = dashboard.recentBooks.firstOrNull()?.toUi(),
recentBooks = dashboard.recentBooks
.drop(1)
.take(6)
.map { it.toUi() }
.toImmutableList(),
selectedSourceSetUrl = selectedSourceUrl,
visibleSections = visibleSections.toImmutableSet(),
latestBackup = backup.latest?.toUi(),
isBackupLoading = backup.isLoading,
isBackupLoadError = backup.isLoadError,
isBackupActionRunning = backup.isActionRunning,
activeDialog = dialog,
activeSheet = sheet,
@@ -116,12 +86,33 @@ class HomeViewModel(
)
init {
refreshLatestBackup()
viewModelScope.launch {
homeDashboardUseCase.observeVisibleSections()
.map { HomeDashboardSection.WebDavBackup in it }
.distinctUntilChanged()
.collect { visible ->
if (visible) {
refreshLatestBackup()
} else {
backupRefreshJob?.cancel()
_backupState.update { it.copy(isLoading = false) }
}
}
}
}
fun onIntent(intent: HomeIntent) {
when (intent) {
HomeIntent.RecentBookClick -> openRecentBook()
is HomeIntent.RecentHistoryBookClick -> openBook(intent.bookUrl)
is HomeIntent.SelectSourceSet -> selectSourceSet(intent.sourceUrl)
HomeIntent.DashboardSettingsClick -> {
_activeSheet.value = HomeSheet.DashboardSettings
}
is HomeIntent.SetSectionVisible -> {
setSectionVisible(intent.section, intent.visible)
}
HomeIntent.ReadingGoalClick -> {
_activeDialog.value = HomeDialog.SetReadingGoal(
uiState.value.dailyGoalMinutes
@@ -159,6 +150,7 @@ class HomeViewModel(
_effects.tryEmit(HomeEffect.OpenBackupSettings)
}
HomeIntent.RetryBackupInfo -> refreshLatestBackup()
HomeIntent.DismissDialog -> _activeDialog.value = null
HomeIntent.DismissSheet -> _activeSheet.value = null
}
@@ -166,6 +158,10 @@ class HomeViewModel(
private fun openRecentBook() {
val bookUrl = uiState.value.recentBook?.bookUrl ?: return
openBook(bookUrl)
}
private fun openBook(bookUrl: String) {
viewModelScope.launch {
bookRepository.getBook(bookUrl)?.let {
_effects.emit(HomeEffect.OpenBook(it))
@@ -174,20 +170,42 @@ class HomeViewModel(
}
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,
)
homeDashboardUseCase.updateDailyGoal(minutes)
}
}
private fun selectSourceSet(sourceUrl: String) {
viewModelScope.launch {
homeDashboardUseCase.updateSelectedSourceSetUrl(sourceUrl)
}
}
private fun setSectionVisible(
section: HomeDashboardSection,
visible: Boolean,
) {
val sections = uiState.value.visibleSections.toMutableSet().apply {
if (visible) add(section) else remove(section)
}
viewModelScope.launch {
homeDashboardUseCase.updateVisibleSections(sections)
}
}
private fun requestRestore() {
val backup = _backupState.value.latest
if (backup == null) {
_effects.tryEmit(HomeEffect.ShowMessage(R.string.home_no_webdav_backup))
_effects.tryEmit(
HomeEffect.ShowMessage(
if (_backupState.value.isLoadError) {
R.string.home_webdav_backup_load_error
} else {
R.string.home_no_webdav_backup
}
)
)
return
}
_activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
@@ -221,101 +239,130 @@ class HomeViewModel(
path: String?,
savePath: Boolean = false,
) {
if (_backupState.value.isActionRunning) return
if (!backupActionMutex.tryLock()) 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,
try {
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) {
refreshLatestBackup()
}
}.onFailure { error ->
if (error is CancellationException) throw error
_effects.emit(
HomeEffect.ShowMessage(
messageRes = R.string.backup_error,
detail = error.localizedMessage,
)
)
)
}
} finally {
_backupState.update { it.copy(isActionRunning = false) }
backupActionMutex.unlock()
}
_backupState.update { it.copy(isActionRunning = false) }
}
}
private fun restoreLocal(uri: String) {
if (_backupState.value.isActionRunning) return
if (!backupActionMutex.tryLock()) 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,
try {
runCatching {
backupRestoreUseCase.restoreLocal(uri)
}.onSuccess {
_effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
}.onFailure { error ->
if (error is CancellationException) throw error
_effects.emit(
HomeEffect.ShowMessage(
messageRes = R.string.restore_error,
detail = error.localizedMessage,
)
)
)
}
} finally {
_backupState.update { it.copy(isActionRunning = false) }
backupActionMutex.unlock()
}
_backupState.update { it.copy(isActionRunning = false) }
}
}
private fun restore() {
val backup = _backupState.value.latest ?: return
_activeDialog.value = null
if (_backupState.value.isActionRunning) return
if (!backupActionMutex.tryLock()) 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,
try {
runCatching {
webDavBackupUseCase.restore(backup.name)
}.onSuccess {
_effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
}.onFailure { error ->
if (error is CancellationException) throw error
_effects.emit(
HomeEffect.ShowMessage(
messageRes = R.string.restore_error,
detail = error.localizedMessage,
)
)
)
}
} finally {
_backupState.update { it.copy(isActionRunning = false) }
backupActionMutex.unlock()
}
_backupState.update { it.copy(isActionRunning = false) }
}
}
private fun refreshLatestBackup() {
viewModelScope.launch(Dispatchers.IO) {
backupRefreshJob?.cancel()
backupRefreshJob = 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) }
_backupState.update {
it.copy(
isLoading = true,
isLoadError = false,
)
}
try {
val latest = webDavBackupUseCase.getLatestBackup()
_backupState.update {
it.copy(
latest = latest,
isLoading = false,
isLoadError = false,
)
}
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
_backupState.update {
it.copy(
isLoading = false,
isLoadError = true,
)
}
}
}
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 isLoadError: Boolean = false,
val isActionRunning: Boolean = false,
)
@@ -323,4 +370,14 @@ class HomeViewModel(
name = name,
lastModify = lastModify,
)
private fun HomeReadingBook.toUi() = HomeRecentBookUi(
bookUrl = bookUrl,
name = name,
author = author,
origin = origin,
coverPath = coverPath,
chapterTitle = chapterTitle,
chapterProgress = chapterProgress,
)
}
@@ -1,7 +1,5 @@
package io.legado.app.ui.main.homepage
import androidx.compose.runtime.State
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefStateDelegate
/**
@@ -10,17 +8,10 @@ import io.legado.app.ui.config.prefStateDelegate
*/
object HomepageConfig {
/**
* 首页布局模式 0: 混合列表 1: 分源Tab
*/
private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0)
var homepageLayoutMode by _homepageLayoutMode
val homepageLayoutModeState: State<Int> get() = _homepageLayoutMode.state
/**
* 首页书源隐藏
*/
private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "")
var homepageSourceHidden by _homepageSourceHidden
}
}
@@ -19,7 +19,6 @@ data class HomepageBookItemUi(
data class HomepageUiState(
val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
val isManageMode: Boolean = false,
val isConfigMode: Boolean = false,
val isRefreshing: Boolean = false,
val manageState: HomepageManageUiState = HomepageManageUiState()
)
@@ -53,6 +52,16 @@ data class HomepageManageActions(
val onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> },
)
@Stable
data class HomepageFeedActions(
val onModuleHeaderClick: (String, String?, String?) -> Unit,
val onRetryModule: (String) -> Unit,
val onLoadMoreModule: (String) -> Unit,
val onBookClick: (SearchBook, String?) -> Unit,
val onKindUrlClick: (String, String, String) -> Unit,
val onRefreshButtonGroup: (String) -> Unit,
)
@Stable
data class HomepageSourceManageUi(
val sourceUrl: String,
@@ -1,57 +0,0 @@
package io.legado.app.ui.main.homepage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
@Composable
fun HomepageLayoutSheet(
show: Boolean,
onDismissRequest: () -> Unit,
layoutMode: Int,
onLayoutModeChange: (Int) -> Unit,
) {
HomepageLayoutSheet(
data = if (show) Unit else null,
onDismissRequest = onDismissRequest,
layoutMode = layoutMode,
onLayoutModeChange = onLayoutModeChange,
)
}
@Composable
fun <T> HomepageLayoutSheet(
data: T?,
onDismissRequest: () -> Unit,
layoutMode: Int,
onLayoutModeChange: (Int) -> Unit,
) {
AppModalBottomSheet(
data = data,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.homepage_layout_settings),
) {
Column {
CompactDropdownSettingItem(
title = stringResource(R.string.homepage_layout_mode),
selectedValue = layoutMode.toString(),
displayEntries = arrayOf(
stringResource(R.string.homepage_layout_mixed),
stringResource(R.string.homepage_layout_tabs)
),
entryValues = arrayOf("0", "1"),
onValueChange = { onLayoutModeChange(it.toInt()) }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,493 @@
package io.legado.app.ui.main.homepage
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.modules.BannerModule
import io.legado.app.ui.main.homepage.modules.ButtonGroupModule
import io.legado.app.ui.main.homepage.modules.CardModule
import io.legado.app.ui.main.homepage.modules.GridModule
import io.legado.app.ui.main.homepage.modules.GridRankingModule
import io.legado.app.ui.main.homepage.modules.HomepageModuleSkeleton
import io.legado.app.ui.main.homepage.modules.RankingModule
import io.legado.app.ui.main.homepage.modules.WaterfallItem
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.text.AppText
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun HomepageModuleFeed(
modules: List<HomepageModuleUi>,
actions: HomepageFeedActions,
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(0.dp),
gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
verticalItemSpacing: Dp = 16.dp,
@StringRes emptyMessageRes: Int = R.string.homepage_add_module_definition,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
onErrorClick: (String) -> Unit
) {
if (modules.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AppText(stringResource(emptyMessageRes))
}
} else {
val processedModules = remember(modules) {
fun isInfinite(m: HomepageModuleUi): Boolean {
return m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
val infinite = modules.firstOrNull { isInfinite(it) }
val others = modules.filter { !isInfinite(it) }
if (infinite != null) others + infinite else others
}
val gridColumns = remember(processedModules) {
val infiniteModule = processedModules.find { m ->
m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
infiniteModule?.config?.get("layout_columns")?.toIntOrNull() ?: 2
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(gridColumns),
state = gridState,
modifier = modifier,
verticalItemSpacing = verticalItemSpacing,
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) {
120.dp
} else {
8.dp
},
),
) {
processedModules.forEach { moduleUi ->
item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
ModuleHeader(
title = moduleUi.title,
onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) {
null
} else {
{
actions.onModuleHeaderClick(
moduleUi.sourceUrl,
moduleUi.exploreUrl,
moduleUi.title,
)
}
},
)
}
when (val state = moduleUi.state) {
is ModuleLoadState.Loading,
is ModuleLoadState.Error -> {
item(
key = "status_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedContent(
targetState = state,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
},
contentKey = { it::class },
) { targetState ->
when (targetState) {
is ModuleLoadState.Loading -> {
HomepageModuleSkeleton(
type = moduleUi.type,
modifier = Modifier.fillMaxWidth(),
)
}
is ModuleLoadState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
GlassCard(
onClick = { onErrorClick(targetState.message) },
containerColor = LegadoTheme.colorScheme.errorContainer.copy(
alpha = 0.6f
),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
12.dp
)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = targetState.message,
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(
color = LegadoTheme.colorScheme.error.copy(
alpha = 0.3f
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
actions.onRetryModule(moduleUi.globalId)
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = stringResource(R.string.retry),
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.labelMedium
)
}
}
}
}
}
}
else -> {}
}
}
}
}
is ModuleLoadState.Buttons -> {
item(
key = "buttons_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
ButtonGroupModule(
kinds = state.kinds,
sourceUrl = moduleUi.sourceUrl,
globalId = moduleUi.globalId,
onOpenKind = actions.onKindUrlClick,
onRefreshKinds = actions.onRefreshButtonGroup,
modifier = Modifier.fillMaxWidth(),
layoutConfig = moduleUi.layoutConfig
)
}
}
is ModuleLoadState.Loaded -> {
val config = moduleUi.config
when (moduleUi.type) {
HomepageModuleType.Waterfall -> {
itemsIndexed(
state.books,
key = { index, item ->
"wf_${moduleUi.globalId}_${item.book.bookUrl}_$index"
},
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"home:${moduleUi.globalId}:waterfall:$index"
)
AnimatedVisibility(visible = true, enter = fadeIn()) {
WaterfallItem(
item = item,
onClick = {
actions.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
}
item(
key = "wf_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { actions.onLoadMoreModule(moduleUi.globalId) }
)
}
}
HomepageModuleType.InfiniteGrid -> {
itemsIndexed(
state.books,
key = { index, item ->
"inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index"
},
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"home:${moduleUi.globalId}:infinite:$index"
)
AnimatedVisibility(visible = true, enter = fadeIn()) {
SearchBookGridItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
actions.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
}
item(
key = "inf_grid_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { actions.onLoadMoreModule(moduleUi.globalId) }
)
}
}
HomepageModuleType.Grid -> {
val rows = config["layout_rows"]?.toIntOrNull() ?: 2
val columns = config["layout_columns"]?.toIntOrNull() ?: 3
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
GridModule(
books = state.books,
onClick = { book, sharedCoverKey ->
actions.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
columns = columns,
maxRows = rows,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
)
}
}
}
HomepageModuleType.Banner -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
BannerModule(
books = state.books,
onClick = { book, sharedCoverKey ->
actions.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
)
}
}
}
HomepageModuleType.Ranking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
RankingModule(
books = state.books,
onClick = { book, sharedCoverKey ->
actions.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
)
}
}
}
HomepageModuleType.GridRanking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
GridRankingModule(
books = state.books,
onClick = { book, sharedCoverKey ->
actions.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
)
}
}
}
HomepageModuleType.Card -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
CardModule(
books = state.books,
onClick = { book, sharedCoverKey ->
actions.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
)
}
}
}
else -> {}
}
}
}
}
}
}
}
@Composable
private fun ModuleHeader(
title: String,
onNavigate: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onNavigate != null) {
SmallTonalButton(
onClick = onNavigate,
icon = Icons.AutoMirrored.Filled.ArrowForward
)
}
}
}
@@ -1,795 +0,0 @@
package io.legado.app.ui.main.homepage
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
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
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.modules.BannerModule
import io.legado.app.ui.main.homepage.modules.ButtonGroupModule
import io.legado.app.ui.main.homepage.modules.CardModule
import io.legado.app.ui.main.homepage.modules.GridModule
import io.legado.app.ui.main.homepage.modules.GridRankingModule
import io.legado.app.ui.main.homepage.modules.HomepageModuleSkeleton
import io.legado.app.ui.main.homepage.modules.RankingModule
import io.legado.app.ui.main.homepage.modules.WaterfallItem
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf
import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.GlassCard
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
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.utils.sendToClip
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalSharedTransitionApi::class
)
@Composable
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,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
// Removed allSets and browseSources as they are now part of uiState.manageState
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
val selectedSets = remember(uiState.manageState.sets) {
uiState.manageState.sets.filter { it.isSelected }
}
val pagerState = rememberPagerState(pageCount = {
selectedSets.size.coerceAtLeast(1)
})
val discoveryString = stringResource(R.string.discovery)
val currentTitle by remember(layoutMode, selectedSets) {
derivedStateOf {
if (layoutMode == 1) {
discoveryString
} else {
selectedSets.getOrNull(pagerState.currentPage)?.sourceName ?: discoveryString
}
}
}
BackHandler(enabled = uiState.isManageMode || uiState.isConfigMode) {
if (uiState.isManageMode) viewModel.toggleManageMode()
else viewModel.toggleConfigMode()
}
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is HomepageEffect.NavigateToBookInfo ->
onBookClick(
effect.name,
effect.author,
effect.bookUrl,
effect.origin,
effect.coverPath,
effect.sharedCoverKey
)
is HomepageEffect.NavigateToExploreShow ->
onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
is HomepageEffect.ShowSnackbar -> {}
}
}
}
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentWindowInsets = WindowInsets(0),
topBar = {
GlassMediumFlexibleTopAppBar(
title = currentTitle,
scrollBehavior = scrollBehavior,
actions = {
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()) {
AppTabRow(
tabTitles = selectedSets.map { it.sourceName },
selectedTabIndex = pagerState.currentPage.coerceIn(
0,
selectedSets.size - 1
),
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
)
},
) { paddingValues ->
AppPullToRefresh(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.onRefresh() },
modifier = Modifier.fillMaxSize(),
) {
if (selectedSets.isEmpty()) {
EmptyMessage(
messageResId = R.string.homepage_no_source_sets_selected,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
)
} else {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
key = { index -> selectedSets.getOrNull(index)?.sourceUrl ?: index }
) { pageIndex ->
val source = selectedSets.getOrNull(pageIndex)
val sourceModules = remember(uiState.modules, source) {
uiState.modules.filter { module ->
if (source?.isCustomSet == true) {
val setId =
HomepageViewModel.customSetIdFromUrl(source.sourceUrl)
module.customSetId == setId
} else {
module.sourceUrl == source?.sourceUrl
}
}
}
ModuleList(
modules = sourceModules,
viewModel = viewModel,
modifier = Modifier.fillMaxSize(),
paddingValues = paddingValues,
onErrorClick = { errorMsg = it },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onBookLongClick = { book, sharedCoverKey ->
previewBook = book
previewSharedCoverKey = sharedCoverKey
},
)
}
}
}
AppAlertDialog(
data = errorMsg,
onDismissRequest = { errorMsg = null },
title = stringResource(R.string.homepage_module_error),
confirmText = stringResource(R.string.copy_text),
onConfirm = {
context.sendToClip(it)
errorMsg = null
},
dismissText = stringResource(R.string.close),
onDismiss = { errorMsg = null },
content = { msg ->
SelectionContainer {
AppText(
text = msg,
style = LegadoTheme.typography.bodyMedium,
modifier = Modifier
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
)
}
}
)
HomepageModuleManageSheet(
data = if (uiState.isManageMode) Unit else null,
onDismissRequest = { viewModel.toggleManageMode() },
state = uiState.manageState,
actions = remember {
HomepageManageActions(
onToggleSet = { url, isEnabled ->
viewModel.toggleSourceFilter(
url,
isEnabled
)
},
onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
onSyncSourceModules = { viewModel.syncSourceModules(it) },
onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) },
onJoinModule = { sourceUrl, targetSetId, def ->
viewModel.joinModule(sourceUrl, targetSetId, def)
},
onAddCustomModule = { sourceUrl, targetSetId, def ->
viewModel.addCustomModule(sourceUrl, targetSetId, def)
},
onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds ->
viewModel.addButtonGroupFromKinds(sourceUrl, targetSetId, title, kinds)
},
onGetExploreKinds = { viewModel.getSourceExploreKinds(it) },
onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) },
onDeleteModule = { viewModel.deleteModule(it) },
onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) },
onReorderSets = { urls -> viewModel.reorderCustomSets(urls) },
onSetCustomSetTitle = { id, title ->
viewModel.setModuleCustomSetTitle(
id,
title
)
},
onCreateCustomSet = { viewModel.createCustomSet(it) },
onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) },
onDeleteCustomSet = { viewModel.deleteCustomSet(it) },
onAssignModuleToCustomSet = { id, setId ->
viewModel.assignModuleToCustomSet(id, setId)
}
)
}
)
HomepageLayoutSheet(
data = if (uiState.isConfigMode) Unit else null,
onDismissRequest = { viewModel.toggleConfigMode() },
layoutMode = layoutMode,
onLayoutModeChange = { viewModel.setLayoutMode(it) },
)
SearchBookPreviewSheet(
data = previewBook,
shelfState = previewBook?.let { viewModel.getCurrentBookShelfState(it) },
sharedCoverKey = previewSharedCoverKey,
onDismissRequest = { previewBook = null },
onOpenDetail = { book, sharedCoverKey ->
previewBook = null
onBookClick(
book.name, book.author, book.bookUrl,
book.origin, book.coverUrl, sharedCoverKey
)
},
onAddToShelf = { book ->
viewModel.onAddToShelf(book)
},
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun ModuleList(
modules: List<HomepageModuleUi>,
viewModel: HomepageViewModel,
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(0.dp),
gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
onErrorClick: (String) -> Unit
) {
if (modules.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AppText(stringResource(R.string.homepage_add_module_definition))
}
} else {
// 1. 过滤和重排模块:每个集只能有一个无限流模块,且必须在最下面
val processedModules = remember(modules) {
fun isInfinite(m: HomepageModuleUi): Boolean {
return m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
val infinite = modules.firstOrNull { isInfinite(it) }
val others = modules.filter { !isInfinite(it) }
if (infinite != null) others + infinite else others
}
val gridColumns = remember(processedModules) {
val infiniteModule = processedModules.find { m ->
m.type == HomepageModuleType.Waterfall ||
m.type == HomepageModuleType.InfiniteGrid
}
infiniteModule?.config?.get("layout_columns")?.toIntOrNull() ?: 2
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(gridColumns),
state = gridState,
modifier = modifier,
verticalItemSpacing = 16.dp,
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = if (ThemeConfig.useFloatingBottomBar || ThemeConfig.enableBlur) 120.dp else 8.dp
),
) {
processedModules.forEach { moduleUi ->
// 1. 头部 (全宽)
item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
ModuleHeader(
title = moduleUi.title,
onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else {
{
viewModel.onModuleHeaderClick(
moduleUi.sourceUrl,
moduleUi.exploreUrl,
moduleUi.title,
)
}
},
)
}
// 2. 内容正文
when (val state = moduleUi.state) {
is ModuleLoadState.Loading,
is ModuleLoadState.Error -> {
item(
key = "status_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedContent(
targetState = state,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
},
contentKey = { it::class },
) { targetState ->
when (targetState) {
is ModuleLoadState.Loading -> {
HomepageModuleSkeleton(
type = moduleUi.type,
modifier = Modifier.fillMaxWidth(),
)
}
is ModuleLoadState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
GlassCard(
onClick = { onErrorClick(targetState.message) },
containerColor = LegadoTheme.colorScheme.errorContainer.copy(
alpha = 0.6f
),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AppIcon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = targetState.message,
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(
color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.retryModule(moduleUi.globalId)
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
tint = LegadoTheme.colorScheme.error
)
AppText(
text = stringResource(R.string.retry),
color = LegadoTheme.colorScheme.error,
style = LegadoTheme.typography.labelMedium
)
}
}
}
}
}
}
else -> {}
}
}
}
}
is ModuleLoadState.Buttons -> {
item(
key = "buttons_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
ButtonGroupModule(
kinds = state.kinds,
sourceUrl = moduleUi.sourceUrl,
globalId = moduleUi.globalId,
viewModel = viewModel,
modifier = Modifier.fillMaxWidth(),
layoutConfig = moduleUi.layoutConfig
)
}
}
is ModuleLoadState.Loaded -> {
val config = moduleUi.config
when (moduleUi.type) {
HomepageModuleType.Waterfall -> {
itemsIndexed(
state.books,
key = { index, item -> "wf_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"home:${moduleUi.globalId}:waterfall:$index"
)
AnimatedVisibility(visible = true, enter = fadeIn()) {
WaterfallItem(
item = item,
onClick = {
viewModel.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
}
item(
key = "wf_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
)
}
}
HomepageModuleType.InfiniteGrid -> {
itemsIndexed(
state.books,
key = { index, item -> "inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"home:${moduleUi.globalId}:infinite:$index"
)
AnimatedVisibility(visible = true, enter = fadeIn()) {
SearchBookGridItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
}
item(
key = "inf_grid_more_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
LoadMoreFooter(
isLoading = state.isLoadingMore,
errorMsg = null,
isEnd = !state.hasMore,
onRetry = { viewModel.loadMoreModule(moduleUi.globalId) }
)
}
}
HomepageModuleType.Grid -> {
val rows = config["layout_rows"]?.toIntOrNull() ?: 2
val columns = config["layout_columns"]?.toIntOrNull() ?: 3
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
GridModule(
books = state.books,
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
columns = columns,
maxRows = rows,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid",
)
}
}
}
else -> {
when (moduleUi.type) {
HomepageModuleType.Banner -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
BannerModule(
books = state.books,
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner",
)
}
}
}
HomepageModuleType.Ranking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
RankingModule(
books = state.books,
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking",
)
}
}
}
HomepageModuleType.GridRanking -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
GridRankingModule(
books = state.books,
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking",
)
}
}
}
HomepageModuleType.Card -> {
item(
key = "content_${moduleUi.globalId}",
span = StaggeredGridItemSpan.FullLine
) {
AnimatedVisibility(visible = true, enter = fadeIn()) {
CardModule(
books = state.books,
onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey)
},
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKeySourceId = "home:${moduleUi.globalId}:card",
)
}
}
}
else -> {}
}
}
}
}
}
}
}
}
}
@Composable
private fun ModuleHeader(
title: String,
onNavigate: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onNavigate != null) {
SmallTonalButton(
onClick = onNavigate,
icon = Icons.AutoMirrored.Filled.ArrowForward
)
}
}
}
@@ -1,92 +0,0 @@
package io.legado.app.ui.main.homepage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@Composable
fun HomepageSourceSelectSheet(
show: Boolean,
onDismissRequest: () -> Unit,
sources: List<HomepageSourceManageUi>,
onToggleSource: (String) -> Unit,
onSelectAll: () -> Unit,
) {
var filterText by remember(show) { mutableStateOf("") }
val filteredSources = remember(sources, filterText) {
if (filterText.isBlank()) sources else sources.filter {
it.sourceName.contains(filterText, ignoreCase = true) ||
it.sourceGroup?.contains(filterText, ignoreCase = true) == true
}
}
val isAllSelected = remember(sources) {
sources.all { it.isSelected } || sources.none { it.isSelected }
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.homepage_filter_sources),
) {
Column {
SearchBar(
query = filterText,
onQueryChange = { filterText = it },
placeholder = stringResource(R.string.screen),
autoFocus = false
)
Spacer(modifier = Modifier.height(8.dp))
SelectionItemCard(
title = stringResource(R.string.all_source),
isSelected = isAllSelected,
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
onSelectAll()
}
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 480.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredSources, key = { it.sourceUrl }) { source ->
SelectionItemCard(
title = source.sourceName,
subtitle = source.sourceGroup?.takeIf { it.isNotBlank() },
containerColor = LegadoTheme.colorScheme.onSheetContent,
isSelected = source.isSelected,
inSelectionMode = true,
onToggleSelection = {
onToggleSource(source.sourceUrl)
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -97,7 +97,6 @@ class HomepageViewModel(
// 1. 基础原始状态
private val _isRefreshing = MutableStateFlow(false)
private val _isManageMode = MutableStateFlow(false)
private val _isConfigMode = MutableStateFlow(false)
private val _configVersion = MutableStateFlow(0L)
private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
@@ -151,8 +150,8 @@ class HomepageViewModel(
// 4. 聚合层
private val uiFlagsFlow =
combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config ->
HomepageUiFlags(refreshing, manage, config)
combine(_isRefreshing, _isManageMode) { refreshing, manage ->
HomepageUiFlags(refreshing, manage)
}
private val manageStateFlow = combine(
@@ -275,7 +274,6 @@ class HomepageViewModel(
modules = modules,
isRefreshing = flags.isRefreshing,
isManageMode = flags.isManageMode,
isConfigMode = flags.isConfigMode,
manageState = manageState
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
@@ -550,7 +548,6 @@ class HomepageViewModel(
}
fun toggleManageMode() = _isManageMode.update { !it }
fun toggleConfigMode() = _isConfigMode.update { !it }
fun setModuleVisible(id: String, visible: Boolean) {
_pendingEnabled.update { it + (id to visible) }
@@ -579,11 +576,6 @@ class HomepageViewModel(
notifyConfigChanged()
}
fun setLayoutMode(mode: Int) {
HomepageConfig.homepageLayoutMode = mode
notifyConfigChanged()
}
private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
val setId = "src_$sourceUrl"
if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet(
@@ -875,6 +867,12 @@ class HomepageViewModel(
}
}
fun saveSearchBook(book: SearchBook) {
viewModelScope.launch {
saveSearchBooksUseCase.save(book)
}
}
fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
viewModelScope.launch {
saveSearchBooksUseCase.save(book)
@@ -940,6 +938,5 @@ class HomepageViewModel(
private data class HomepageUiFlags(
val isRefreshing: Boolean,
val isManageMode: Boolean,
val isConfigMode: Boolean
val isManageMode: Boolean
)
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.ui.main.homepage.HomepageViewModel
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.card.GlassCard
@@ -37,7 +36,8 @@ fun ButtonGroupModule(
kinds: List<ExploreKind>,
sourceUrl: String,
globalId: String,
viewModel: HomepageViewModel,
onOpenKind: (sourceUrl: String, url: String, title: String) -> Unit,
onRefreshKinds: (globalId: String) -> Unit,
modifier: Modifier = Modifier,
icon: String? = null,
layoutConfig: String? = null,
@@ -91,10 +91,10 @@ fun ButtonGroupModule(
sourceUrl = sourceUrl,
activity = activity,
onOpenUrl = { url ->
viewModel.onKindUrlClick(sourceUrl, url, kind.title)
onOpenKind(sourceUrl, url, kind.title)
},
onRefreshKinds = {
viewModel.refreshButtonGroup(globalId)
onRefreshKinds(globalId)
},
useCase = useCase,
isMiuix = isMiuix,
+7 -10
View File
@@ -1022,6 +1022,10 @@
<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="main_navigation_settings">首页与导航</string>
<string name="main_navigation_settings_summary">配置首页、发现、订阅和默认主页</string>
<string name="navigation_order">导航顺序</string>
<string name="style">样式</string>
<string name="group_style">分组样式</string>
<string name="export_file_name">导出文件名</string>
@@ -1830,12 +1834,9 @@
<string name="homepage_type_label">类型</string>
<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_recent_books">最近阅读记录</string>
<string name="home_dashboard_settings">首页组件</string>
<string name="home_no_recent_reading">暂无阅读记录</string>
<string name="home_total_read_books">累计阅读</string>
<string name="home_total_reading_time">阅读时长</string>
@@ -1851,14 +1852,10 @@
<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_load_error">无法获取备份信息</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>
<string name="homepage_filter_sources">筛选书源</string>
<string name="homepage_no_source_sets_selected">未选择任何书源集</string>
<string name="homepage_module_error">模块错误</string>
<string name="homepage_add_module_definition">请在书源中添加首页模块定义</string>
<string name="homepage_collapse">收起</string>
<string name="homepage_show_all">显示全部</string>
+7 -10
View File
@@ -895,6 +895,10 @@
<string name="diy_edit_source_group">輸入自訂源分組名稱</string>
<string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string>
<string name="show_home">顯示首頁</string>
<string name="main_navigation_settings">首頁與導覽</string>
<string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
<string name="navigation_order">導覽順序</string>
<string name="style">樣式</string>
<string name="group_style">分組樣式</string>
<string name="export_file_name">導出文件名</string>
@@ -1364,12 +1368,9 @@
<string name="homepage_type_label">類型</string>
<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_recent_books">最近閱讀記錄</string>
<string name="home_dashboard_settings">首頁組件</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string>
@@ -1385,14 +1386,10 @@
<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_load_error">無法獲取備份資訊</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>
<string name="homepage_filter_sources">篩選書源</string>
<string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
<string name="homepage_module_error">模塊錯誤</string>
<string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string>
<string name="homepage_collapse">收起</string>
<string name="homepage_show_all">顯示全部</string>
+7 -10
View File
@@ -898,6 +898,10 @@
<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="main_navigation_settings">首頁與導覽</string>
<string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
<string name="navigation_order">導覽順序</string>
<string name="style">樣式</string>
<string name="group_style">分組樣式</string>
<string name="export_file_name">匯出檔案名</string>
@@ -1368,12 +1372,9 @@
<string name="homepage_type_label">類型</string>
<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_recent_books">最近閱讀記錄</string>
<string name="home_dashboard_settings">首頁元件</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string>
@@ -1389,14 +1390,10 @@
<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_load_error">無法取得備份資訊</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>
<string name="homepage_filter_sources">篩選書源</string>
<string name="homepage_no_source_sets_selected">未選擇任何書源集</string>
<string name="homepage_module_error">模組錯誤</string>
<string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string>
<string name="homepage_collapse">收起</string>
<string name="homepage_show_all">顯示全部</string>
+1 -3
View File
@@ -260,8 +260,7 @@
<string-array name="default_home_page">
<item>@string/home</item>
<item>@string/bookshelf</item>
<item>@string/discovery_modules</item>
<item>@string/classic_discovery</item>
<item>@string/discovery</item>
<item>@string/rss</item>
<item>@string/my</item>
</string-array>
@@ -277,7 +276,6 @@
<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>
+7 -10
View File
@@ -1053,6 +1053,10 @@
<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 Home</string>
<string name="main_navigation_settings">Home and navigation</string>
<string name="main_navigation_settings_summary">Configure Home, Discovery, Subscriptions, and the start page</string>
<string name="navigation_order">Navigation order</string>
<string name="style">Style</string>
<string name="group_style">Group style</string>
<string name="export_file_name">Export file name</string>
@@ -1963,12 +1967,9 @@
<string name="homepage_type_label">Type</string>
<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_recent_books">Recent reading history</string>
<string name="home_dashboard_settings">Home components</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>
@@ -1984,14 +1985,10 @@
<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_load_error">Couldn\'t load 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>
<string name="homepage_filter_sources">Filter Sources</string>
<string name="homepage_no_source_sets_selected">No source sets selected</string>
<string name="homepage_module_error">Module Error</string>
<string name="homepage_add_module_definition">Please add homepage module definitions in the book source</string>
<string name="homepage_collapse">Collapse</string>
<string name="homepage_show_all">Show All</string>