油画首页

This commit is contained in:
HapeLee committed 2026-06-30 01:52:13 +08:00
1 parent abbb90d06a
commit 9f897ddd27
39 files changed
+2123 -1509

No files matched your search

@@ -44,9 +44,11 @@ object PreferKey {
const val ttsSpeechRate = "ttsSpeechRate" const val ttsSpeechRate = "ttsSpeechRate"
const val prevKeys = "prevKeyCodes" const val prevKeys = "prevKeyCodes"
const val nextKeys = "nextKeyCodes" const val nextKeys = "nextKeyCodes"
const val showHome = "showHome"
const val showDiscovery = "showDiscovery" const val showDiscovery = "showDiscovery"
const val enableReview = "enableReview" const val enableReview = "enableReview"
const val showRss = "showRss" const val showRss = "showRss"
const val mainNavigationOrder = "mainNavigationOrder"
const val showStatusBar = "showStatusBar" const val showStatusBar = "showStatusBar"
const val swipeAnimation = "swipeAnimation" const val swipeAnimation = "swipeAnimation"
const val bookshelfLayout = "bookshelfLayout" const val bookshelfLayout = "bookshelfLayout"
@@ -364,9 +366,6 @@ object PreferKey {
const val llmTemperature = "llmTemperature" const val llmTemperature = "llmTemperature"
const val llmPrompt = "llmPrompt" const val llmPrompt = "llmPrompt"
const val homepageModuleOrder = "homepageModuleOrder"
const val homepageModuleHidden = "homepageModuleHidden"
const val homepageLayoutMode = "homepageLayoutMode"
const val navIconHome = "navIconHome" const val navIconHome = "navIconHome"
// Lab // Lab
@@ -6,6 +6,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update 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.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -25,6 +26,56 @@ interface ReadRecordDao {
@Query("SELECT sum(readTime) FROM readRecord") @Query("SELECT sum(readTime) FROM readRecord")
fun getTotalReadTime(): Flow<Long?> 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") @Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long? 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_MODE = intPreferencesKey("explore_layout_mode")
val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait") val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait")
val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape") val EXPLORE_LAYOUT_GRID_LANDSCAPE = intPreferencesKey("explore_layout_grid_landscape")
val EXPLORE_STYLE = stringPreferencesKey("explore_style")
val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser") val READ_URL_IN_BROWSER = booleanPreferencesKey("read_url_in_browser")
val LAST_BACKUP = longPreferencesKey("last_backup") val LAST_BACKUP = longPreferencesKey("last_backup")
val PASSWORD = stringPreferencesKey("password") val PASSWORD = stringPreferencesKey("password")
val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok") val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked") val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes") 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 // Change source options
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor") 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 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.DatePattern
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
import io.legado.app.data.AppDatabase
import io.legado.app.data.dao.ReadRecordDao import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
@@ -15,7 +16,8 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class ReadRecordRepository( class ReadRecordRepository(
private val dao: ReadRecordDao private val dao: ReadRecordDao,
private val database: AppDatabase,
) { ) {
private fun getCurrentDeviceId(): String = "" private fun getCurrentDeviceId(): String = ""
@@ -85,11 +87,15 @@ class ReadRecordRepository(
* 保存一个完整的阅读会话. * 保存一个完整的阅读会话.
*/ */
suspend fun saveReadSession(newSession: ReadRecordSession) { suspend fun saveReadSession(newSession: ReadRecordSession) {
val segmentDuration = newSession.endTime - newSession.startTime if (newSession.endTime <= newSession.startTime) return
dao.insertSession(newSession) database.withTransaction {
val dateString = DateUtil.format(Date(newSession.startTime), DatePattern.NORM_DATE_PATTERN) val segmentDuration = newSession.endTime - newSession.startTime
updateReadRecordDetail(newSession, segmentDuration, newSession.words, dateString) dao.insertSession(newSession)
updateReadRecord(newSession, segmentDuration) 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) { private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
@@ -151,58 +157,72 @@ class ReadRecordRepository(
} }
suspend fun deleteDetail(detail: ReadRecordDetail) { suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail) database.withTransaction {
dao.deleteSessionsByBookAndDate( dao.deleteDetail(detail)
detail.deviceId, dao.deleteSessionsByBookAndDate(
detail.bookName, detail.deviceId,
detail.bookAuthor, detail.bookName,
detail.date detail.bookAuthor,
) detail.date
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor) )
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
}
} }
@Transaction
suspend fun deleteSession(session: ReadRecordSession) { suspend fun deleteSession(session: ReadRecordSession) {
dao.deleteSession(session) database.withTransaction {
dao.deleteSession(session)
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd") val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions = val remainingSessions =
dao.getSessionsByBookAndDate( dao.getSessionsByBookAndDate(
session.deviceId, session.deviceId,
session.bookName, session.bookName,
session.bookAuthor, session.bookAuthor,
dateString dateString
) )
if (remainingSessions.isEmpty()) { if (remainingSessions.isEmpty()) {
val detail = dao.getDetail( val detail = dao.getDetail(
session.deviceId, session.deviceId,
session.bookName, session.bookName,
session.bookAuthor, session.bookAuthor,
dateString dateString
) )
detail?.let { dao.deleteDetail(it) } detail?.let { dao.deleteDetail(it) }
} else { } else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime } val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
val totalWords = remainingSessions.sumOf { it.words } val totalWords = remainingSessions.sumOf { it.words }
val firstRead = remainingSessions.minOf { it.startTime } val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime } val lastRead = remainingSessions.maxOf { it.endTime }
val existingDetail = dao.getDetail( val existingDetail = dao.getDetail(
session.deviceId, session.deviceId,
session.bookName, session.bookName,
session.bookAuthor, session.bookAuthor,
dateString dateString
) )
existingDetail?.copy( dao.insertDetail(
readTime = totalTime, existingDetail?.copy(
readWords = totalWords, readTime = totalTime,
firstReadTime = firstRead, readWords = totalWords,
lastReadTime = lastRead firstReadTime = firstRead,
)?.let { dao.insertDetail(it) } 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) { 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 totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime } val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy( val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
readTime = totalTime, if (existingRecord == null) {
lastRead = lastRead dao.insert(
)?.let { dao.update(it) } 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) { suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record) database.withTransaction {
dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor) dao.deleteReadRecord(record)
dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor) 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>) { suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
sourceRecords.forEach { sourceRecord -> database.withTransaction {
mergeSingleReadRecordInto(targetRecord, sourceRecord) sourceRecords.forEach { sourceRecord ->
mergeSingleReadRecordInto(targetRecord, sourceRecord)
}
} }
} }
@Transaction
private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) { private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
if (targetRecord == sourceRecord) return if (targetRecord == sourceRecord) return
if (targetRecord.deviceId != sourceRecord.deviceId) return if (targetRecord.deviceId != sourceRecord.deviceId) return
@@ -39,7 +39,7 @@ class WebDavBackupRepository : WebDavBackupGateway {
override suspend fun getLatestBackup(): WebDavBackup? { override suspend fun getLatestBackup(): WebDavBackup? {
return withContext(IO) { return withContext(IO) {
AppWebDav.lastBackUp().getOrNull()?.let { AppWebDav.lastBackUp().getOrThrow()?.let {
WebDavBackup( WebDavBackup(
name = it.displayName, name = it.displayName,
lastModify = it.lastModify lastModify = it.lastModify
@@ -7,14 +7,14 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import io.legado.app.data.AppDatabase import io.legado.app.data.AppDatabase
import io.legado.app.data.local.preferences.LocalPreferencesRepository import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.AppStartupRepository
import io.legado.app.data.repository.BackupRestoreRepository
import io.legado.app.data.repository.AiArtifactRepository import io.legado.app.data.repository.AiArtifactRepository
import io.legado.app.data.repository.AiChatRepository import io.legado.app.data.repository.AiChatRepository
import io.legado.app.data.repository.AiMemoryRepository import io.legado.app.data.repository.AiMemoryRepository
import io.legado.app.data.repository.AiProfileRepository import io.legado.app.data.repository.AiProfileRepository
import io.legado.app.data.repository.AiTextRepositoryImpl import io.legado.app.data.repository.AiTextRepositoryImpl
import io.legado.app.data.repository.AiToolRepository 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.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl import io.legado.app.data.repository.BookDomainRepositoryImpl
import io.legado.app.data.repository.BookGroupRepository 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.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HighlightRuleRepository 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.HomepageModulesRepository
import io.legado.app.data.repository.LocalBookRepository import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository 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.UploadRepository
import io.legado.app.data.repository.WebDavBackupRepository import io.legado.app.data.repository.WebDavBackupRepository
import io.legado.app.data.repository.WebDavReadingProgressRepository import io.legado.app.data.repository.WebDavReadingProgressRepository
import io.legado.app.domain.gateway.AppStartupGateway
import io.legado.app.domain.gateway.BackupRestoreGateway
import io.legado.app.domain.gateway.AiArtifactGateway import io.legado.app.domain.gateway.AiArtifactGateway
import io.legado.app.domain.gateway.AiChatGateway import io.legado.app.domain.gateway.AiChatGateway
import io.legado.app.domain.gateway.AiMemoryGateway import io.legado.app.domain.gateway.AiMemoryGateway
import io.legado.app.domain.gateway.AiProfileGateway import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.AiToolGateway 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.BookCacheCleanupGateway
import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.gateway.BookSearchGateway 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.DatabaseMaintenanceGateway
import io.legado.app.domain.gateway.DictionaryGateway import io.legado.app.domain.gateway.DictionaryGateway
import io.legado.app.domain.gateway.ExploreBooksGateway 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.HomepageModulesGateway
import io.legado.app.domain.gateway.LocalBookGateway import io.legado.app.domain.gateway.LocalBookGateway
import io.legado.app.domain.gateway.ReadingProgressGateway 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.repository.BookDomainRepository
import io.legado.app.domain.usecase.AddBookUseCase import io.legado.app.domain.usecase.AddBookUseCase
import io.legado.app.domain.usecase.AddToBookshelfUseCase import io.legado.app.domain.usecase.AddToBookshelfUseCase
import io.legado.app.domain.usecase.AiChatGenerationUseCase
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.BackupRestoreUseCase import io.legado.app.domain.usecase.BackupRestoreUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase 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.ExploreBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase 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.GetChapterContentUseCase
import io.legado.app.domain.usecase.GetReadingProgressUseCase import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.AiChatGenerationUseCase import io.legado.app.domain.usecase.HomeDashboardUseCase
import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase import io.legado.app.domain.usecase.RefreshTocUseCase
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase 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.okHttpClient
import io.legado.app.help.http.okHttpClientManga import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.about.AboutViewModel 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.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel 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.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel 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.AiConfigViewModel
import io.legado.app.ui.config.ai.AiModelEditViewModel import io.legado.app.ui.config.ai.AiModelEditViewModel
import io.legado.app.ui.config.ai.AiProviderEditViewModel 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.bookshelfConfig.BookshelfManageScreenConfig
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel 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.DictViewModel
import io.legado.app.ui.dict.rule.DictRuleViewModel import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel 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.MainRouteSearchContent
import io.legado.app.ui.main.MainViewModel import io.legado.app.ui.main.MainViewModel
import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -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.read.ReadRssViewModel
import io.legado.app.ui.rss.source.manage.RssSourceViewModel import io.legado.app.ui.rss.source.manage.RssSourceViewModel
import io.legado.app.ui.rss.subscription.RuleSubViewModel 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.singleOf
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
import java.time.Clock
val appModule = module { val appModule = module {
@@ -170,6 +174,7 @@ val appModule = module {
single { get<AppDatabase>().bookSourceDao } single { get<AppDatabase>().bookSourceDao }
singleOf(::ReadRecordRepository) singleOf(::ReadRecordRepository)
single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
singleOf(::BookRepository) singleOf(::BookRepository)
singleOf(::BookGroupRepository) singleOf(::BookGroupRepository)
singleOf(::BookSourceRepository) singleOf(::BookSourceRepository)
@@ -195,6 +200,7 @@ val appModule = module {
singleOf(::ClearBookCacheUseCase) singleOf(::ClearBookCacheUseCase)
singleOf(::DeleteBooksUseCase) singleOf(::DeleteBooksUseCase)
singleOf(::GetReadingProgressUseCase) singleOf(::GetReadingProgressUseCase)
single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
singleOf(::RemoveBookGroupAssignmentUseCase) singleOf(::RemoveBookGroupAssignmentUseCase)
singleOf(::UpdateBooksGroupUseCase) singleOf(::UpdateBooksGroupUseCase)
singleOf(::UploadReadingProgressUseCase) 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 BackupConfig.backupPath = value
} }
val showHome: Boolean
get() = ThemeConfig.showHome
val showDiscovery: Boolean val showDiscovery: Boolean
get() = ThemeConfig.showDiscovery get() = ThemeConfig.showDiscovery
@@ -174,6 +174,7 @@ object ThemeImportExport {
customTagColorsJson = ThemeConfig.customTagColorsJson, customTagColorsJson = ThemeConfig.customTagColorsJson,
// 主界面设置 // 主界面设置
showHome = ThemeConfig.showHome,
showDiscovery = ThemeConfig.showDiscovery, showDiscovery = ThemeConfig.showDiscovery,
showRss = ThemeConfig.showRss, showRss = ThemeConfig.showRss,
showStatusBar = ThemeConfig.showStatusBar, showStatusBar = ThemeConfig.showStatusBar,
@@ -184,6 +185,7 @@ object ThemeImportExport {
tabletInterface = ThemeConfig.tabletInterface, tabletInterface = ThemeConfig.tabletInterface,
labelVisibilityMode = ThemeConfig.labelVisibilityMode, labelVisibilityMode = ThemeConfig.labelVisibilityMode,
defaultHomePage = ThemeConfig.defaultHomePage, defaultHomePage = ThemeConfig.defaultHomePage,
mainNavigationOrder = ThemeConfig.mainNavigationOrder,
// 导航栏图标 // 导航栏图标
navIconHome = ThemeConfig.navIconHome, navIconHome = ThemeConfig.navIconHome,
@@ -343,6 +345,7 @@ object ThemeImportExport {
ThemeConfig.customTagColorsJson = data.customTagColorsJson ThemeConfig.customTagColorsJson = data.customTagColorsJson
// 主界面设置 // 主界面设置
ThemeConfig.showHome = data.showHome
ThemeConfig.showDiscovery = data.showDiscovery ThemeConfig.showDiscovery = data.showDiscovery
ThemeConfig.showRss = data.showRss ThemeConfig.showRss = data.showRss
ThemeConfig.showStatusBar = data.showStatusBar ThemeConfig.showStatusBar = data.showStatusBar
@@ -353,6 +356,7 @@ object ThemeImportExport {
ThemeConfig.tabletInterface = data.tabletInterface ThemeConfig.tabletInterface = data.tabletInterface
ThemeConfig.labelVisibilityMode = data.labelVisibilityMode ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
ThemeConfig.defaultHomePage = data.defaultHomePage ThemeConfig.defaultHomePage = data.defaultHomePage
ThemeConfig.mainNavigationOrder = data.mainNavigationOrder
// 导航栏图标 // 导航栏图标
ThemeConfig.navIconHome = data.navIconHome ThemeConfig.navIconHome = data.navIconHome
@@ -579,6 +583,7 @@ data class ThemeExportData(
val customTagColorsJson: String? = null, val customTagColorsJson: String? = null,
// 主界面设置 // 主界面设置
val showHome: Boolean = true,
val showDiscovery: Boolean = true, val showDiscovery: Boolean = true,
val showRss: Boolean = true, val showRss: Boolean = true,
val showStatusBar: Boolean = true, val showStatusBar: Boolean = true,
@@ -589,6 +594,7 @@ data class ThemeExportData(
val tabletInterface: String = "auto", val tabletInterface: String = "auto",
val labelVisibilityMode: String = "auto", val labelVisibilityMode: String = "auto",
val defaultHomePage: String = "bookshelf", val defaultHomePage: String = "bookshelf",
val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
// 导航栏图标 // 导航栏图标
val navIconHome: String = "", val navIconHome: String = "",
@@ -4,6 +4,7 @@ import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate import io.legado.app.ui.config.prefDelegate
object MainConfig { object MainConfig {
var showHome by prefDelegate(PreferKey.showHome, true)
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRSS by prefDelegate(PreferKey.showRss, true) var showRSS by prefDelegate(PreferKey.showRss, true)
var showBottomView by prefDelegate(PreferKey.showBottomView, true) var showBottomView by prefDelegate(PreferKey.showBottomView, true)
@@ -13,6 +14,10 @@ object MainConfig {
false false
) )
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf") 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 tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto") var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true) 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) customTagColorsJson = GSON.toJson(colors)
} }
var showHome by prefDelegate(PreferKey.showHome, true)
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRss by prefDelegate(PreferKey.showRss, true) var showRss by prefDelegate(PreferKey.showRss, true)
@@ -165,6 +167,11 @@ object ThemeConfig {
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf") 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 navExtended by prefDelegate("navExtended", false)
var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false) var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
@@ -123,6 +123,7 @@ fun ThemeConfigScreen(
var showLauncherIconPicker by remember { mutableStateOf(false) } var showLauncherIconPicker by remember { mutableStateOf(false) }
var showBorderColorPicker by remember { mutableStateOf(false) } var showBorderColorPicker by remember { mutableStateOf(false) }
var showNavIconSheet by remember { mutableStateOf(false) } var showNavIconSheet by remember { mutableStateOf(false) }
var showMainNavigationSheet by remember { mutableStateOf(false) }
var showFontSheet by remember { mutableStateOf(false) } var showFontSheet by remember { mutableStateOf(false) }
val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle() val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle()
@@ -375,15 +376,10 @@ fun ThemeConfigScreen(
} }
SplicedColumnGroup(title = stringResource(R.string.main_activity)) { SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
SwitchSettingItem( ClickableSettingItem(
title = stringResource(R.string.show_discovery), title = stringResource(R.string.main_navigation_settings),
checked = ThemeConfig.showDiscovery, description = stringResource(R.string.main_navigation_settings_summary),
onCheckedChange = { ThemeConfig.showDiscovery = it } onClick = { showMainNavigationSheet = true },
)
SwitchSettingItem(
title = stringResource(R.string.show_rss),
checked = ThemeConfig.showRss,
onCheckedChange = { ThemeConfig.showRss = it }
) )
SwitchSettingItem( SwitchSettingItem(
title = stringResource(R.string.show_status), title = stringResource(R.string.show_status),
@@ -446,13 +442,6 @@ fun ThemeConfigScreen(
entryValues = stringArrayResource(R.array.label_vis_mode_value), entryValues = stringArrayResource(R.array.label_vis_mode_value),
onValueChange = { ThemeConfig.labelVisibilityMode = it } 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)) { SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
@@ -841,6 +830,11 @@ fun ThemeConfigScreen(
onDismissRequest = { showNavIconSheet = false } onDismissRequest = { showNavIconSheet = false }
) )
MainNavigationSettingsSheet(
show = showMainNavigationSheet,
onDismissRequest = { showMainNavigationSheet = false },
)
LauncherIconPickerSheet( LauncherIconPickerSheet(
show = showLauncherIconPicker, show = showLauncherIconPicker,
@@ -163,6 +163,11 @@ fun EditThemeSheet(
// Interface layout // Interface layout
SectionTitle(stringResource(R.string.theme_manage_section_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( CompactSwitchSettingItem(
title = stringResource(R.string.theme_manage_show_discovery), title = stringResource(R.string.theme_manage_show_discovery),
checked = data.showDiscovery, checked = data.showDiscovery,
@@ -35,9 +35,17 @@ sealed class MainDestination(
) )
companion object { companion object {
const val DISCOVERY_MODULES_ROUTE = "explore_modules"
val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My) val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
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 destinations = mainUiState.destinations
val initialPage = remember(destinations, mainUiState.defaultHomePage) { val initialPage = remember(destinations, mainUiState.defaultHomePage) {
val defaultRoute = if ( val index = destinations.indexOfFirst {
mainUiState.defaultHomePage == MainDestination.DISCOVERY_MODULES_ROUTE it.route == mainUiState.defaultHomePage
) {
MainDestination.Explore.route
} else {
mainUiState.defaultHomePage
} }
val index = destinations.indexOfFirst { it.route == defaultRoute }
if (index != -1) index else 0 if (index != -1) index else 0
} }
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size } val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
@@ -392,6 +387,17 @@ fun MainScreen(
onOpenBook = { book -> onOpenBook = { book ->
context.startActivityForBook(book) context.startActivityForBook(book)
}, },
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey ->
onNavigateToBookInfo(
name ?: "",
author ?: "",
bookUrl,
origin,
coverPath,
sharedCoverKey,
)
},
onOpenExploreShow = onNavigateToExploreShow,
onOpenBackupSettings = onNavigateToBackupSettings, onOpenBackupSettings = onNavigateToBackupSettings,
onNavigateToReadRecord = onNavigateToReadRecord, onNavigateToReadRecord = onNavigateToReadRecord,
onNavigateToReadRecordOverview = onNavigateToReadRecordOverview, onNavigateToReadRecordOverview = onNavigateToReadRecordOverview,
@@ -423,21 +429,7 @@ fun MainScreen(
) )
MainDestination.Explore -> ExploreScreen( 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, onOpenExploreShow = onNavigateToExploreShow,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
) )
MainDestination.Rss -> RssScreen( MainDestination.Rss -> RssScreen(
onOpenSort = { sourceUrl, sortUrl, key -> onOpenSort = { sourceUrl, sortUrl, key ->
@@ -6,12 +6,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.WebDavBackupUseCase import io.legado.app.domain.usecase.WebDavBackupUseCase
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.main.explore.ExploreStyle
import io.legado.app.ui.main.my.PrefClickEvent import io.legado.app.ui.main.my.PrefClickEvent
import io.legado.app.utils.eventBus.FlowEventBus import io.legado.app.utils.eventBus.FlowEventBus
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -27,7 +24,6 @@ class MainViewModel(
application: Application, application: Application,
private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase, private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
private val webDavBackupUseCase: WebDavBackupUseCase, private val webDavBackupUseCase: WebDavBackupUseCase,
private val localPreferencesRepository: LocalPreferencesRepository,
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(readMainUiState()) private val _uiState = MutableStateFlow(readMainUiState())
@@ -36,24 +32,12 @@ class MainViewModel(
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
init { init {
// 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State,全链路走 DS // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State
viewModelScope.launch { viewModelScope.launch {
snapshotFlow { snapshotFlow {
readMainUiState() readMainUiState()
}.collect { newState -> }.collect { newState ->
_uiState.update { currentState -> _uiState.value = newState
newState.copy(exploreStyle = currentState.exploreStyle)
}
}
}
viewModelScope.launch {
localPreferencesRepository.getPreference(
LocalPreferencesKeys.EXPLORE_STYLE,
ExploreStyle.ClassicDiscovery.storageValue,
).collect { storedValue ->
_uiState.update {
it.copy(exploreStyle = ExploreStyle.fromStorageValue(storedValue))
}
} }
} }
deleteNotShelfBook() deleteNotShelfBook()
@@ -89,17 +73,6 @@ class MainViewModel(
ThemeConfig.navExtended = expanded ThemeConfig.navExtended = expanded
} }
fun setExploreStyle(style: ExploreStyle) {
if (_uiState.value.exploreStyle == style) return
_uiState.update { it.copy(exploreStyle = style) }
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.EXPLORE_STYLE,
style.storageValue,
)
}
}
fun onPrefClickEvent(event: PrefClickEvent) { fun onPrefClickEvent(event: PrefClickEvent) {
when (event) { when (event) {
is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url)) is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
@@ -158,12 +131,12 @@ data class MainUiState(
val useFloatingBottomBarLiquidGlass: Boolean = false, val useFloatingBottomBarLiquidGlass: Boolean = false,
val labelVisibilityMode: String = "auto", val labelVisibilityMode: String = "auto",
val navExtended: Boolean = false, val navExtended: Boolean = false,
val exploreStyle: ExploreStyle = ExploreStyle.ClassicDiscovery,
) )
private fun MainViewModel.readMainUiState(): MainUiState { private fun MainViewModel.readMainUiState(): MainUiState {
val destinations = MainDestination.mainDestinations.filter { val destinations = MainDestination.ordered(ThemeConfig.mainNavigationOrder).filter {
when (it) { when (it) {
MainDestination.Home -> ThemeConfig.showHome
MainDestination.Explore -> ThemeConfig.showDiscovery MainDestination.Explore -> ThemeConfig.showDiscovery
MainDestination.Rss -> ThemeConfig.showRss MainDestination.Rss -> ThemeConfig.showRss
else -> true else -> true
@@ -2,16 +2,10 @@ package io.legado.app.ui.main.explore
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -32,7 +26,6 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VerticalAlignTop import androidx.compose.material.icons.filled.VerticalAlignTop
@@ -47,7 +40,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -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.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.main.homepage.HomepageScreen
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
@@ -95,64 +86,19 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ExploreScreen( fun ExploreScreen(
style: ExploreStyle,
onStyleChange: (ExploreStyle) -> Unit,
onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val stateHolder = rememberSaveableStateHolder() ExploreDiscoveryScreen(onOpenExploreShow = onOpenExploreShow)
AnimatedContent(
targetState = style,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 220,
delayMillis = 90,
)
) togetherWith fadeOut(
animationSpec = tween(durationMillis = 90)
)
},
contentKey = { it },
label = "ExploreStyle",
) { page ->
stateHolder.SaveableStateProvider(page) {
when (page) {
ExploreStyle.DiscoveryModules -> HomepageScreen(
onBookClick = onBookClick,
onModuleHeaderClick = onOpenExploreShow,
onSwitchToDiscovery = {
onStyleChange(ExploreStyle.ClassicDiscovery)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
else -> ExploreDiscoveryScreen(
onOpenExploreShow = onOpenExploreShow,
onSwitchToHomepage = {
onStyleChange(ExploreStyle.DiscoveryModules)
},
)
}
}
}
} }
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class, ExperimentalFoundationApi::class,
ExperimentalSharedTransitionApi::class,
) )
@Composable @Composable
private fun ExploreDiscoveryScreen( private fun ExploreDiscoveryScreen(
viewModel: ExploreViewModel = koinViewModel(), viewModel: ExploreViewModel = koinViewModel(),
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onSwitchToHomepage: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity val activity = context as? AppCompatActivity
@@ -208,14 +154,6 @@ private fun ExploreDiscoveryScreen(
onSearchToggle = { viewModel.toggleSearchVisible(it) }, onSearchToggle = { viewModel.toggleSearchVisible(it) },
searchPlaceholder = stringResource(R.string.search), searchPlaceholder = stringResource(R.string.search),
dropDownMenuContent = { dismiss -> dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Home) },
text = stringResource(R.string.switch_to_discovery_modules),
onClick = {
dismiss()
onSwitchToHomepage()
},
)
RoundDropdownMenuItem( RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Group) }, leadingIcon = { MenuItemIcon(Icons.Default.Group) },
text = stringResource(R.string.all), text = stringResource(R.string.all),
@@ -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.annotation.StringRes
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.legado.app.data.entities.Book 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 @Stable
data class HomeUiState( data class HomeUiState(
@@ -11,8 +18,13 @@ data class HomeUiState(
val todayReadTimeMillis: Long = 0L, val todayReadTimeMillis: Long = 0L,
val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES, val dailyGoalMinutes: Int = DEFAULT_DAILY_READING_GOAL_MINUTES,
val recentBook: HomeRecentBookUi? = null, 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 latestBackup: HomeBackupUi? = null,
val isBackupLoading: Boolean = true, val isBackupLoading: Boolean = true,
val isBackupLoadError: Boolean = false,
val isBackupActionRunning: Boolean = false, val isBackupActionRunning: Boolean = false,
val activeDialog: HomeDialog? = null, val activeDialog: HomeDialog? = null,
val activeSheet: HomeSheet? = null, val activeSheet: HomeSheet? = null,
@@ -37,6 +49,13 @@ data class HomeBackupUi(
sealed interface HomeIntent { sealed interface HomeIntent {
data object RecentBookClick : 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 object ReadingGoalClick : HomeIntent
data class UpdateReadingGoal(val minutes: Int) : HomeIntent data class UpdateReadingGoal(val minutes: Int) : HomeIntent
data object BackupClick : HomeIntent data object BackupClick : HomeIntent
@@ -52,6 +71,7 @@ sealed interface HomeIntent {
data class RestoreLocalFileSelected(val uri: String) : HomeIntent data class RestoreLocalFileSelected(val uri: String) : HomeIntent
data object ConfirmRestore : HomeIntent data object ConfirmRestore : HomeIntent
data object BackupSettingsClick : HomeIntent data object BackupSettingsClick : HomeIntent
data object RetryBackupInfo : HomeIntent
data object DismissDialog : HomeIntent data object DismissDialog : HomeIntent
data object DismissSheet : HomeIntent data object DismissSheet : HomeIntent
} }
@@ -83,6 +103,7 @@ sealed interface HomeDialog {
@Stable @Stable
sealed interface HomeSheet { sealed interface HomeSheet {
data object DashboardSettings : HomeSheet
data object BackupOptions : HomeSheet data object BackupOptions : HomeSheet
data object RestoreOptions : HomeSheet data object RestoreOptions : HomeSheet
} }
@@ -92,6 +113,3 @@ enum class HomeBackupDestination(val mode: String) {
WebDav("webdav"), WebDav("webdav"),
LocalAndWebDav("both"), 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.R 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.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.model.WebDavBackup
import io.legado.app.domain.usecase.BackupRestoreUseCase 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.domain.usecase.WebDavBackupUseCase
import io.legado.app.ui.config.backupConfig.BackupConfig import io.legado.app.ui.config.backupConfig.BackupConfig
import io.legado.app.utils.isContentScheme 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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import kotlinx.coroutines.sync.Mutex
class HomeViewModel( class HomeViewModel(
private val readRecordRepository: ReadRecordRepository, private val homeDashboardUseCase: HomeDashboardUseCase,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val localPreferencesRepository: LocalPreferencesRepository,
private val webDavBackupUseCase: WebDavBackupUseCase, private val webDavBackupUseCase: WebDavBackupUseCase,
private val backupRestoreUseCase: BackupRestoreUseCase, private val backupRestoreUseCase: BackupRestoreUseCase,
) : ViewModel() { ) : ViewModel() {
@@ -37,58 +41,15 @@ class HomeViewModel(
private val _activeSheet = MutableStateFlow<HomeSheet?>(null) private val _activeSheet = MutableStateFlow<HomeSheet?>(null)
private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16) private val _effects = MutableSharedFlow<HomeEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private var backupRefreshJob: Job? = null
private val backupActionMutex = Mutex()
private val dashboardData = combine( private val dashboardData = combine(
readRecordRepository.getLatestReadRecords(), homeDashboardUseCase.observe(),
readRecordRepository.getTotalReadTime(), homeDashboardUseCase.observeSelectedSourceSetUrl(),
readRecordRepository.getAllRecordDetails(), homeDashboardUseCase.observeVisibleSections(),
bookRepository.getAllBooks(), ) { dashboard, selectedSourceUrl, visibleSections ->
localPreferencesRepository.getPreference( Triple(dashboard, selectedSourceUrl, visibleSections)
LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES,
DEFAULT_DAILY_READING_GOAL_MINUTES,
),
) { records, totalReadTime, details, books, dailyGoalMinutes ->
val latestRecord = records.firstOrNull()
val latestBook = latestRecord?.let { record ->
books.firstOrNull {
it.name == record.bookName && it.author == record.bookAuthor
}
}
val today = LocalDate.now().toString()
HomeDashboardData(
totalReadBooks = records
.distinctBy { it.bookName to it.bookAuthor }
.size,
totalReadTimeMillis = totalReadTime,
todayReadTimeMillis = details
.asSequence()
.filter { it.date == today }
.sumOf { it.readTime },
dailyGoalMinutes = dailyGoalMinutes.coerceIn(
1,
MAX_DAILY_READING_GOAL_MINUTES,
),
recentBook = latestRecord?.let { record ->
HomeRecentBookUi(
bookUrl = latestBook?.bookUrl,
name = record.bookName,
author = record.bookAuthor,
origin = latestBook?.origin,
coverPath = latestBook?.getDisplayCover(),
chapterTitle = latestBook?.durChapterTitle,
chapterProgress = latestBook?.let { book ->
if (book.totalChapterNum > 0) {
(book.durChapterIndex + 1)
.coerceIn(0, book.totalChapterNum)
.toFloat() / book.totalChapterNum
} else {
null
}
},
)
},
)
} }
val uiState = combine( val uiState = combine(
@@ -96,15 +57,24 @@ class HomeViewModel(
_backupState, _backupState,
_activeDialog, _activeDialog,
_activeSheet, _activeSheet,
) { dashboard, backup, dialog, sheet -> ) { dashboardData, backup, dialog, sheet ->
val (dashboard, selectedSourceUrl, visibleSections) = dashboardData
HomeUiState( HomeUiState(
totalReadBooks = dashboard.totalReadBooks, totalReadBooks = dashboard.totalReadBooks,
totalReadTimeMillis = dashboard.totalReadTimeMillis, totalReadTimeMillis = dashboard.totalReadTimeMillis,
todayReadTimeMillis = dashboard.todayReadTimeMillis, todayReadTimeMillis = dashboard.todayReadTimeMillis,
dailyGoalMinutes = dashboard.dailyGoalMinutes, 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(), latestBackup = backup.latest?.toUi(),
isBackupLoading = backup.isLoading, isBackupLoading = backup.isLoading,
isBackupLoadError = backup.isLoadError,
isBackupActionRunning = backup.isActionRunning, isBackupActionRunning = backup.isActionRunning,
activeDialog = dialog, activeDialog = dialog,
activeSheet = sheet, activeSheet = sheet,
@@ -116,12 +86,33 @@ class HomeViewModel(
) )
init { 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) { fun onIntent(intent: HomeIntent) {
when (intent) { when (intent) {
HomeIntent.RecentBookClick -> openRecentBook() 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 -> { HomeIntent.ReadingGoalClick -> {
_activeDialog.value = HomeDialog.SetReadingGoal( _activeDialog.value = HomeDialog.SetReadingGoal(
uiState.value.dailyGoalMinutes uiState.value.dailyGoalMinutes
@@ -159,6 +150,7 @@ class HomeViewModel(
_effects.tryEmit(HomeEffect.OpenBackupSettings) _effects.tryEmit(HomeEffect.OpenBackupSettings)
} }
HomeIntent.RetryBackupInfo -> refreshLatestBackup()
HomeIntent.DismissDialog -> _activeDialog.value = null HomeIntent.DismissDialog -> _activeDialog.value = null
HomeIntent.DismissSheet -> _activeSheet.value = null HomeIntent.DismissSheet -> _activeSheet.value = null
} }
@@ -166,6 +158,10 @@ class HomeViewModel(
private fun openRecentBook() { private fun openRecentBook() {
val bookUrl = uiState.value.recentBook?.bookUrl ?: return val bookUrl = uiState.value.recentBook?.bookUrl ?: return
openBook(bookUrl)
}
private fun openBook(bookUrl: String) {
viewModelScope.launch { viewModelScope.launch {
bookRepository.getBook(bookUrl)?.let { bookRepository.getBook(bookUrl)?.let {
_effects.emit(HomeEffect.OpenBook(it)) _effects.emit(HomeEffect.OpenBook(it))
@@ -174,20 +170,42 @@ class HomeViewModel(
} }
private fun updateReadingGoal(minutes: Int) { private fun updateReadingGoal(minutes: Int) {
val validMinutes = minutes.coerceIn(1, MAX_DAILY_READING_GOAL_MINUTES)
_activeDialog.value = null _activeDialog.value = null
viewModelScope.launch { viewModelScope.launch {
localPreferencesRepository.updatePreference( homeDashboardUseCase.updateDailyGoal(minutes)
LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES, }
validMinutes, }
)
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() { private fun requestRestore() {
val backup = _backupState.value.latest val backup = _backupState.value.latest
if (backup == null) { 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 return
} }
_activeDialog.value = HomeDialog.ConfirmRestore(backup.name) _activeDialog.value = HomeDialog.ConfirmRestore(backup.name)
@@ -221,101 +239,130 @@ class HomeViewModel(
path: String?, path: String?,
savePath: Boolean = false, savePath: Boolean = false,
) { ) {
if (_backupState.value.isActionRunning) return if (!backupActionMutex.tryLock()) return
_backupState.update { it.copy(isActionRunning = true) } _backupState.update { it.copy(isActionRunning = true) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { try {
if (savePath) { runCatching {
BackupConfig.backupPath = path if (savePath) {
} BackupConfig.backupPath = path
if (destination != HomeBackupDestination.Local) { }
webDavBackupUseCase.refreshConfig() if (destination != HomeBackupDestination.Local) {
} webDavBackupUseCase.refreshConfig()
backupRestoreUseCase.backup(path, destination.mode) }
}.onSuccess { backupRestoreUseCase.backup(path, destination.mode)
_effects.emit(HomeEffect.ShowMessage(R.string.backup_success)) }.onSuccess {
if (destination != HomeBackupDestination.Local) { _effects.emit(HomeEffect.ShowMessage(R.string.backup_success))
loadLatestBackup() if (destination != HomeBackupDestination.Local) {
} refreshLatestBackup()
}.onFailure { error -> }
_effects.emit( }.onFailure { error ->
HomeEffect.ShowMessage( if (error is CancellationException) throw error
messageRes = R.string.backup_error, _effects.emit(
detail = error.localizedMessage, 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) { private fun restoreLocal(uri: String) {
if (_backupState.value.isActionRunning) return if (!backupActionMutex.tryLock()) return
_backupState.update { it.copy(isActionRunning = true) } _backupState.update { it.copy(isActionRunning = true) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { try {
backupRestoreUseCase.restoreLocal(uri) runCatching {
}.onSuccess { backupRestoreUseCase.restoreLocal(uri)
_effects.emit(HomeEffect.ShowMessage(R.string.restore_success)) }.onSuccess {
}.onFailure { error -> _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
_effects.emit( }.onFailure { error ->
HomeEffect.ShowMessage( if (error is CancellationException) throw error
messageRes = R.string.restore_error, _effects.emit(
detail = error.localizedMessage, 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() { private fun restore() {
val backup = _backupState.value.latest ?: return val backup = _backupState.value.latest ?: return
_activeDialog.value = null _activeDialog.value = null
if (_backupState.value.isActionRunning) return if (!backupActionMutex.tryLock()) return
_backupState.update { it.copy(isActionRunning = true) } _backupState.update { it.copy(isActionRunning = true) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { try {
webDavBackupUseCase.restore(backup.name) runCatching {
}.onSuccess { webDavBackupUseCase.restore(backup.name)
_effects.emit(HomeEffect.ShowMessage(R.string.restore_success)) }.onSuccess {
}.onFailure { error -> _effects.emit(HomeEffect.ShowMessage(R.string.restore_success))
_effects.emit( }.onFailure { error ->
HomeEffect.ShowMessage( if (error is CancellationException) throw error
messageRes = R.string.restore_error, _effects.emit(
detail = error.localizedMessage, 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() { private fun refreshLatestBackup() {
viewModelScope.launch(Dispatchers.IO) { backupRefreshJob?.cancel()
backupRefreshJob = viewModelScope.launch(Dispatchers.IO) {
loadLatestBackup() loadLatestBackup()
} }
} }
private suspend fun loadLatestBackup() { private suspend fun loadLatestBackup() {
_backupState.update { it.copy(isLoading = true) } _backupState.update {
val latest = runCatching { it.copy(
webDavBackupUseCase.getLatestBackup() isLoading = true,
}.getOrNull() isLoadError = false,
_backupState.update { it.copy(latest = latest, isLoading = 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( private data class HomeBackupState(
val latest: WebDavBackup? = null, val latest: WebDavBackup? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isLoadError: Boolean = false,
val isActionRunning: Boolean = false, val isActionRunning: Boolean = false,
) )
@@ -323,4 +370,14 @@ class HomeViewModel(
name = name, name = name,
lastModify = lastModify, 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 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 import io.legado.app.ui.config.prefStateDelegate
/** /**
@@ -10,13 +8,6 @@ import io.legado.app.ui.config.prefStateDelegate
*/ */
object HomepageConfig { object HomepageConfig {
/**
* 首页布局模式 0: 混合列表 1: 分源Tab
*/
private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0)
var homepageLayoutMode by _homepageLayoutMode
val homepageLayoutModeState: State<Int> get() = _homepageLayoutMode.state
/** /**
* 首页书源隐藏 * 首页书源隐藏
*/ */
@@ -19,7 +19,6 @@ data class HomepageBookItemUi(
data class HomepageUiState( data class HomepageUiState(
val modules: ImmutableList<HomepageModuleUi> = persistentListOf(), val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
val isManageMode: Boolean = false, val isManageMode: Boolean = false,
val isConfigMode: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val manageState: HomepageManageUiState = HomepageManageUiState() val manageState: HomepageManageUiState = HomepageManageUiState()
) )
@@ -53,6 +52,16 @@ data class HomepageManageActions(
val onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, 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 @Stable
data class HomepageSourceManageUi( data class HomepageSourceManageUi(
val sourceUrl: String, 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. 基础原始状态 // 1. 基础原始状态
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
private val _isManageMode = MutableStateFlow(false) private val _isManageMode = MutableStateFlow(false)
private val _isConfigMode = MutableStateFlow(false)
private val _configVersion = MutableStateFlow(0L) private val _configVersion = MutableStateFlow(0L)
private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap()) private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap()) private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
@@ -151,8 +150,8 @@ class HomepageViewModel(
// 4. 聚合层 // 4. 聚合层
private val uiFlagsFlow = private val uiFlagsFlow =
combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config -> combine(_isRefreshing, _isManageMode) { refreshing, manage ->
HomepageUiFlags(refreshing, manage, config) HomepageUiFlags(refreshing, manage)
} }
private val manageStateFlow = combine( private val manageStateFlow = combine(
@@ -275,7 +274,6 @@ class HomepageViewModel(
modules = modules, modules = modules,
isRefreshing = flags.isRefreshing, isRefreshing = flags.isRefreshing,
isManageMode = flags.isManageMode, isManageMode = flags.isManageMode,
isConfigMode = flags.isConfigMode,
manageState = manageState manageState = manageState
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
@@ -550,7 +548,6 @@ class HomepageViewModel(
} }
fun toggleManageMode() = _isManageMode.update { !it } fun toggleManageMode() = _isManageMode.update { !it }
fun toggleConfigMode() = _isConfigMode.update { !it }
fun setModuleVisible(id: String, visible: Boolean) { fun setModuleVisible(id: String, visible: Boolean) {
_pendingEnabled.update { it + (id to visible) } _pendingEnabled.update { it + (id to visible) }
@@ -579,11 +576,6 @@ class HomepageViewModel(
notifyConfigChanged() notifyConfigChanged()
} }
fun setLayoutMode(mode: Int) {
HomepageConfig.homepageLayoutMode = mode
notifyConfigChanged()
}
private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String { private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
val setId = "src_$sourceUrl" val setId = "src_$sourceUrl"
if (gateway.getCustomSetById(setId) == null) gateway.upsertCustomSet( 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?) { fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
viewModelScope.launch { viewModelScope.launch {
saveSearchBooksUseCase.save(book) saveSearchBooksUseCase.save(book)
@@ -940,6 +938,5 @@ class HomepageViewModel(
private data class HomepageUiFlags( private data class HomepageUiFlags(
val isRefreshing: Boolean, val isRefreshing: Boolean,
val isManageMode: Boolean, val isManageMode: Boolean
val isConfigMode: Boolean
) )
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.usecase.ExploreKindUiUseCase 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.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
@@ -37,7 +36,8 @@ fun ButtonGroupModule(
kinds: List<ExploreKind>, kinds: List<ExploreKind>,
sourceUrl: String, sourceUrl: String,
globalId: String, globalId: String,
viewModel: HomepageViewModel, onOpenKind: (sourceUrl: String, url: String, title: String) -> Unit,
onRefreshKinds: (globalId: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: String? = null, icon: String? = null,
layoutConfig: String? = null, layoutConfig: String? = null,
@@ -91,10 +91,10 @@ fun ButtonGroupModule(
sourceUrl = sourceUrl, sourceUrl = sourceUrl,
activity = activity, activity = activity,
onOpenUrl = { url -> onOpenUrl = { url ->
viewModel.onKindUrlClick(sourceUrl, url, kind.title) onOpenKind(sourceUrl, url, kind.title)
}, },
onRefreshKinds = { onRefreshKinds = {
viewModel.refreshButtonGroup(globalId) onRefreshKinds(globalId)
}, },
useCase = useCase, useCase = useCase,
isMiuix = isMiuix, isMiuix = isMiuix,
+7 -10
View File
@@ -1022,6 +1022,10 @@
<string name="hide_when_status_bar_show">状态栏显示时隐藏</string> <string name="hide_when_status_bar_show">状态栏显示时隐藏</string>
<string name="reverse_toc">反转目录</string> <string name="reverse_toc">反转目录</string>
<string name="show_discovery">显示发现</string> <string name="show_discovery">显示发现</string>
<string name="show_home">显示首页</string>
<string name="main_navigation_settings">首页与导航</string>
<string name="main_navigation_settings_summary">配置首页、发现、订阅和默认主页</string>
<string name="navigation_order">导航顺序</string>
<string name="style">样式</string> <string name="style">样式</string>
<string name="group_style">分组样式</string> <string name="group_style">分组样式</string>
<string name="export_file_name">导出文件名</string> <string name="export_file_name">导出文件名</string>
@@ -1830,12 +1834,9 @@
<string name="homepage_type_label">类型</string> <string name="homepage_type_label">类型</string>
<string name="homepage_layout_config_label">布局配置</string> <string name="homepage_layout_config_label">布局配置</string>
<string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string> <string name="homepage_edit_raw_json">编辑原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">布局设置</string>
<string name="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_reading">最近阅读</string>
<string name="home_recent_books">最近阅读记录</string>
<string name="home_dashboard_settings">首页组件</string>
<string name="home_no_recent_reading">暂无阅读记录</string> <string name="home_no_recent_reading">暂无阅读记录</string>
<string name="home_total_read_books">累计阅读</string> <string name="home_total_read_books">累计阅读</string>
<string name="home_total_reading_time">阅读时长</string> <string name="home_total_reading_time">阅读时长</string>
@@ -1851,14 +1852,10 @@
<string name="home_latest_backup_value">最近备份:%1$s</string> <string name="home_latest_backup_value">最近备份:%1$s</string>
<string name="home_no_webdav_backup">暂无 WebDAV 备份</string> <string name="home_no_webdav_backup">暂无 WebDAV 备份</string>
<string name="home_loading_webdav_backup">正在获取备份信息…</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_backup_confirmation">立即将当前数据备份到 WebDAV?</string>
<string name="home_webdav_restore_confirmation">恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。</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_no_source_sets_selected">未选择任何书源集</string>
<string name="homepage_module_error">模块错误</string>
<string name="homepage_add_module_definition">请在书源中添加首页模块定义</string> <string name="homepage_add_module_definition">请在书源中添加首页模块定义</string>
<string name="homepage_collapse">收起</string> <string name="homepage_collapse">收起</string>
<string name="homepage_show_all">显示全部</string> <string name="homepage_show_all">显示全部</string>
+7 -10
View File
@@ -895,6 +895,10 @@
<string name="diy_edit_source_group">輸入自訂源分組名稱</string> <string name="diy_edit_source_group">輸入自訂源分組名稱</string>
<string name="reverse_toc">反轉目錄</string> <string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string> <string name="show_discovery">顯示發現</string>
<string name="show_home">顯示首頁</string>
<string name="main_navigation_settings">首頁與導覽</string>
<string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
<string name="navigation_order">導覽順序</string>
<string name="style">樣式</string> <string name="style">樣式</string>
<string name="group_style">分組樣式</string> <string name="group_style">分組樣式</string>
<string name="export_file_name">導出文件名</string> <string name="export_file_name">導出文件名</string>
@@ -1364,12 +1368,9 @@
<string name="homepage_type_label">類型</string> <string name="homepage_type_label">類型</string>
<string name="homepage_layout_config_label">佈局配置</string> <string name="homepage_layout_config_label">佈局配置</string>
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string> <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">佈局設置</string>
<string name="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_reading">最近閱讀</string>
<string name="home_recent_books">最近閱讀記錄</string>
<string name="home_dashboard_settings">首頁組件</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string> <string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string> <string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string> <string name="home_total_reading_time">閱讀時長</string>
@@ -1385,14 +1386,10 @@
<string name="home_latest_backup_value">最近備份:%1$s</string> <string name="home_latest_backup_value">最近備份:%1$s</string>
<string name="home_no_webdav_backup">暫無 WebDAV 備份</string> <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
<string name="home_loading_webdav_backup">正在獲取備份資訊…</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_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
<string name="home_webdav_restore_confirmation">恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。</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_no_source_sets_selected">未選擇任何書源集</string>
<string name="homepage_module_error">模塊錯誤</string>
<string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string> <string name="homepage_add_module_definition">請在書源中添加首頁模塊定義</string>
<string name="homepage_collapse">收起</string> <string name="homepage_collapse">收起</string>
<string name="homepage_show_all">顯示全部</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="hide_when_status_bar_show">狀態欄顯示時隱藏</string>
<string name="reverse_toc">反轉目錄</string> <string name="reverse_toc">反轉目錄</string>
<string name="show_discovery">顯示發現</string> <string name="show_discovery">顯示發現</string>
<string name="show_home">顯示首頁</string>
<string name="main_navigation_settings">首頁與導覽</string>
<string name="main_navigation_settings_summary">設定首頁、發現、訂閱和預設主頁</string>
<string name="navigation_order">導覽順序</string>
<string name="style">樣式</string> <string name="style">樣式</string>
<string name="group_style">分組樣式</string> <string name="group_style">分組樣式</string>
<string name="export_file_name">匯出檔案名</string> <string name="export_file_name">匯出檔案名</string>
@@ -1368,12 +1372,9 @@
<string name="homepage_type_label">類型</string> <string name="homepage_type_label">類型</string>
<string name="homepage_layout_config_label">佈局配置</string> <string name="homepage_layout_config_label">佈局配置</string>
<string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string> <string name="homepage_edit_raw_json">編輯原始 JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">佈局設置</string>
<string name="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_reading">最近閱讀</string>
<string name="home_recent_books">最近閱讀記錄</string>
<string name="home_dashboard_settings">首頁元件</string>
<string name="home_no_recent_reading">暫無閱讀記錄</string> <string name="home_no_recent_reading">暫無閱讀記錄</string>
<string name="home_total_read_books">累計閱讀</string> <string name="home_total_read_books">累計閱讀</string>
<string name="home_total_reading_time">閱讀時長</string> <string name="home_total_reading_time">閱讀時長</string>
@@ -1389,14 +1390,10 @@
<string name="home_latest_backup_value">最近備份:%1$s</string> <string name="home_latest_backup_value">最近備份:%1$s</string>
<string name="home_no_webdav_backup">暫無 WebDAV 備份</string> <string name="home_no_webdav_backup">暫無 WebDAV 備份</string>
<string name="home_loading_webdav_backup">正在取得備份資訊…</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_backup_confirmation">立即將目前資料備份到 WebDAV?</string>
<string name="home_webdav_restore_confirmation">還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。</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_no_source_sets_selected">未選擇任何書源集</string>
<string name="homepage_module_error">模組錯誤</string>
<string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string> <string name="homepage_add_module_definition">請在書源中添加首頁模組定義</string>
<string name="homepage_collapse">收起</string> <string name="homepage_collapse">收起</string>
<string name="homepage_show_all">顯示全部</string> <string name="homepage_show_all">顯示全部</string>
+1 -3
View File
@@ -260,8 +260,7 @@
<string-array name="default_home_page"> <string-array name="default_home_page">
<item>@string/home</item> <item>@string/home</item>
<item>@string/bookshelf</item> <item>@string/bookshelf</item>
<item>@string/discovery_modules</item> <item>@string/discovery</item>
<item>@string/classic_discovery</item>
<item>@string/rss</item> <item>@string/rss</item>
<item>@string/my</item> <item>@string/my</item>
</string-array> </string-array>
@@ -277,7 +276,6 @@
<string-array name="default_home_page_value"> <string-array name="default_home_page_value">
<item>home</item> <item>home</item>
<item>bookshelf</item> <item>bookshelf</item>
<item>explore_modules</item>
<item>explore</item> <item>explore</item>
<item>rss</item> <item>rss</item>
<item>my</item> <item>my</item>
+7 -10
View File
@@ -1053,6 +1053,10 @@
<string name="hide_when_status_bar_show">Hide when status bar show</string> <string name="hide_when_status_bar_show">Hide when status bar show</string>
<string name="reverse_toc">Reverse toc</string> <string name="reverse_toc">Reverse toc</string>
<string name="show_discovery">Show Discovery</string> <string name="show_discovery">Show Discovery</string>
<string name="show_home">Show 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="style">Style</string>
<string name="group_style">Group style</string> <string name="group_style">Group style</string>
<string name="export_file_name">Export file name</string> <string name="export_file_name">Export file name</string>
@@ -1963,12 +1967,9 @@
<string name="homepage_type_label">Type</string> <string name="homepage_type_label">Type</string>
<string name="homepage_layout_config_label">Layout Config</string> <string name="homepage_layout_config_label">Layout Config</string>
<string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string> <string name="homepage_edit_raw_json">Edit Raw JSON (LayoutConfig)</string>
<string name="homepage_layout_settings">Layout Settings</string>
<string name="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_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_no_recent_reading">No reading history yet</string>
<string name="home_total_read_books">Books Read</string> <string name="home_total_read_books">Books Read</string>
<string name="home_total_reading_time">Reading Time</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_latest_backup_value">Latest backup: %1$s</string>
<string name="home_no_webdav_backup">No WebDAV backup found</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_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_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="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_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_add_module_definition">Please add homepage module definitions in the book source</string>
<string name="homepage_collapse">Collapse</string> <string name="homepage_collapse">Collapse</string>
<string name="homepage_show_all">Show All</string> <string name="homepage_show_all">Show All</string>