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