diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 319e28a6d..0847e5ae4 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index 152976660..4035116c0 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -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 + @Query( + """ + SELECT COUNT(*) FROM ( + SELECT 1 + FROM readRecord + GROUP BY bookName, bookAuthor + ) + """ + ) + fun observeTotalReadBookCount(): Flow + + @Query("SELECT SUM(readTime) FROM readRecordDetail WHERE date = :date") + fun observeReadTimeByDate(date: String): Flow + + @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> + @Query("select sum(readTime) from readRecord where bookName = :bookName") fun getReadTime(bookName: String): Long? diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt new file mode 100644 index 000000000..3f0fd6ddc --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/HomeRecentBookRow.kt @@ -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?, +) + diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt index c650551aa..e68653ae8 100644 --- a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt @@ -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") diff --git a/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt b/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt new file mode 100644 index 000000000..9793543b7 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt @@ -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 = + readRecordDao.observeTotalReadBookCount() + + override fun observeTotalReadTime(): Flow = + readRecordDao.getTotalReadTime().map { it ?: 0L } + + override fun observeReadTime(date: String): Flow = + readRecordDao.observeReadTimeByDate(date).map { it ?: 0L } + + override fun observeRecentBooks(limit: Int): Flow> = + 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 = + localPreferencesRepository.getPreference( + LocalPreferencesKeys.DAILY_READING_GOAL_MINUTES, + defaultValue, + ) + + override fun observeSelectedSourceSetUrl(): Flow = + localPreferencesRepository.getPreference( + LocalPreferencesKeys.HOME_SOURCE_SET_URL, + "", + ).map { it.takeIf(String::isNotBlank) } + + override fun observeVisibleSections(): Flow> = + 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) { + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.HOME_DASHBOARD_SECTIONS, + HomeDashboardSection.entries + .filter(sections::contains) + .joinToString(",") { it.storageValue }, + ) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 0b8b074b6..53367eb20 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -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) { - 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 diff --git a/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt b/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt index 164c8f3fc..8d46899e0 100644 --- a/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/WebDavBackupRepository.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 06652c662..fefe5cd7d 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -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().bookSourceDao } singleOf(::ReadRecordRepository) + single { 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) diff --git a/app/src/main/java/io/legado/app/domain/gateway/HomeDashboardGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/HomeDashboardGateway.kt new file mode 100644 index 000000000..bb3c02f7a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/HomeDashboardGateway.kt @@ -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 + + fun observeTotalReadTime(): Flow + + fun observeReadTime(date: String): Flow + + fun observeRecentBooks(limit: Int): Flow> + + fun observeDailyGoal(defaultValue: Int): Flow + + fun observeSelectedSourceSetUrl(): Flow + + fun observeVisibleSections(): Flow> + + suspend fun updateDailyGoal(minutes: Int) + + suspend fun updateSelectedSourceSetUrl(sourceUrl: String) + + suspend fun updateVisibleSections(sections: Set) +} diff --git a/app/src/main/java/io/legado/app/domain/model/HomeDashboard.kt b/app/src/main/java/io/legado/app/domain/model/HomeDashboard.kt new file mode 100644 index 000000000..43375b4a2 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/HomeDashboard.kt @@ -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, +) + +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 { + 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.entries.toSet() + +const val DEFAULT_DAILY_READING_GOAL_MINUTES = 30 +const val MAX_DAILY_READING_GOAL_MINUTES = 24 * 60 diff --git a/app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt new file mode 100644 index 000000000..6c4b41b15 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt @@ -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 { + 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 = + gateway.observeSelectedSourceSetUrl() + + fun observeVisibleSections(): Flow> = + gateway.observeVisibleSections() + + suspend fun updateSelectedSourceSetUrl(sourceUrl: String) { + gateway.updateSelectedSourceSetUrl(sourceUrl) + } + + suspend fun updateVisibleSections(sections: Set) { + gateway.updateVisibleSections(sections) + } + + private fun currentDate(): Flow = 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 + } +} diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 6aeded145..e25fe2b92 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -314,6 +314,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { BackupConfig.backupPath = value } + val showHome: Boolean + get() = ThemeConfig.showHome + val showDiscovery: Boolean get() = ThemeConfig.showDiscovery diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 0eb9a16c9..9be3c83fc 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -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 = "", diff --git a/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt b/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt index f982eada5..32b94579b 100644 --- a/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/MainNavigationSettingsSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/MainNavigationSettingsSheet.kt new file mode 100644 index 000000000..59c1b3bdd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/MainNavigationSettingsSheet.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index d89f55328..f2e18ca7e 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index 699706947..dca4153a4 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt index 500897893..7ed4ea4f4 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt index a151b1a77..e8572ea73 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt @@ -35,9 +35,17 @@ sealed class MainDestination( ) companion object { - const val DISCOVERY_MODULES_ROUTE = "explore_modules" - val mainDestinations = persistentListOf(Home, Bookshelf, Explore, Rss, My) + + fun ordered(order: String): List { + val byRoute = mainDestinations.associateBy { it.route } + val ordered = order + .split(',') + .map(String::trim) + .distinct() + .mapNotNull(byRoute::get) + return ordered + mainDestinations.filterNot { it in ordered } + } } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index dcf4e05ae..6f14025bc 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -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 -> diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index 3e0a47c07..d93e29a76 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index 6132b1b00..a3c7a09b1 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -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), diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt deleted file mode 100644 index 1a9f0e836..000000000 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreStyle.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt index 08a90154f..b217b73a2 100644 --- a/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt +++ b/app/src/main/java/io/legado/app/ui/main/home/HomeContract.kt @@ -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 = persistentListOf(), + val selectedSourceSetUrl: String? = null, + val visibleSections: ImmutableSet = + 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 diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt index 6c02b3179..0146b5808 100644 --- a/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt @@ -2,14 +2,17 @@ package io.legado.app.ui.main.home import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,37 +25,51 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Leaderboard +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.filled.TrackChanges import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.OutlinedTextField 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.FirstBaseline import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.FirstBaseline import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -62,28 +79,47 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.data.entities.Book +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.HomeDashboardSection +import io.legado.app.domain.model.MAX_DAILY_READING_GOAL_MINUTES import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.ui.config.backupConfig.BackupOptionSheet import io.legado.app.ui.config.backupConfig.RestoreOptionSheet +import io.legado.app.ui.main.homepage.HomepageFeedActions +import io.legado.app.ui.main.homepage.HomepageManageActions +import io.legado.app.ui.main.homepage.HomepageModuleFeed +import io.legado.app.ui.main.homepage.HomepageModuleManageSheet +import io.legado.app.ui.main.homepage.HomepageUiState +import io.legado.app.ui.main.homepage.HomepageViewModel import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet import io.legado.app.ui.widget.components.button.series.MediumTonalButton import io.legado.app.ui.widget.components.button.series.SmallTonalButton -import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.image.cover.BookshelfCover +import io.legado.app.ui.widget.components.list.TopFloatingStickyItem +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.progressIndicator.AppContainedLoadingIndicator +import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem 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.isContentScheme import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.toastOnUi import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import java.text.DateFormat import java.util.Date @@ -92,15 +128,96 @@ import kotlin.math.roundToInt @Composable fun HomeRouteScreen( onOpenBook: (Book) -> Unit, + onNavigateToBookInfo: ( + name: String?, + author: String?, + bookUrl: String, + origin: String?, + coverPath: String?, + sharedCoverKey: String?, + ) -> Unit, + onOpenExploreShow: ( + title: String?, + sourceUrl: String, + exploreUrl: String?, + ) -> Unit, onOpenBackupSettings: () -> Unit, onNavigateToReadRecord: () -> Unit, onNavigateToReadRecordOverview: () -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, viewModel: HomeViewModel = koinViewModel(), + homepageViewModel: HomepageViewModel = koinViewModel(), ) { val context = LocalContext.current val state by viewModel.uiState.collectAsStateWithLifecycle() + val homepageState by homepageViewModel.uiState.collectAsStateWithLifecycle() + var previewBook by remember { mutableStateOf(null) } + var previewSharedCoverKey by remember { mutableStateOf(null) } + + val feedActions = remember(homepageViewModel, onNavigateToBookInfo, onOpenExploreShow) { + HomepageFeedActions( + onModuleHeaderClick = { sourceUrl, exploreUrl, title -> + onOpenExploreShow(title, sourceUrl, exploreUrl) + }, + onRetryModule = homepageViewModel::retryModule, + onLoadMoreModule = homepageViewModel::loadMoreModule, + onBookClick = { book: SearchBook, sharedCoverKey: String? -> + onNavigateToBookInfo( + book.name, + book.author, + book.bookUrl, + book.origin, + book.coverUrl, + sharedCoverKey, + ) + }, + onKindUrlClick = homepageViewModel::onKindUrlClick, + onRefreshButtonGroup = homepageViewModel::refreshButtonGroup, + ) + } + val manageActions = remember(homepageViewModel) { + HomepageManageActions( + onToggleSet = { url, isEnabled -> + homepageViewModel.toggleSourceFilter(url, isEnabled) + }, + onGetSourceModules = { url, setId -> + homepageViewModel.getSourceModules(url, setId) + }, + onSyncSourceModules = { homepageViewModel.syncSourceModules(it) }, + onToggleModule = { id, visible -> + homepageViewModel.setModuleVisible(id, visible) + }, + onJoinModule = { sourceUrl, targetSetId, def -> + homepageViewModel.joinModule(sourceUrl, targetSetId, def) + }, + onAddCustomModule = { sourceUrl, targetSetId, def -> + homepageViewModel.addCustomModule(sourceUrl, targetSetId, def) + }, + onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds -> + homepageViewModel.addButtonGroupFromKinds(sourceUrl, targetSetId, title, kinds) + }, + onGetExploreKinds = { homepageViewModel.getSourceExploreKinds(it) }, + onUpdateModule = { globalId, def -> + homepageViewModel.updateModule(globalId, def) + }, + onDeleteModule = { homepageViewModel.deleteModule(it) }, + onReorderModules = { homepageViewModel.reorderJoinedModules(it) }, + onReorderSets = { homepageViewModel.reorderCustomSets(it) }, + onSetCustomSetTitle = { id, title -> + homepageViewModel.setModuleCustomSetTitle(id, title) + }, + onCreateCustomSet = { homepageViewModel.createCustomSet(it) }, + onRenameCustomSet = { id, name -> + homepageViewModel.renameCustomSet(id, name) + }, + onDeleteCustomSet = { homepageViewModel.deleteCustomSet(it) }, + onAssignModuleToCustomSet = { id, setId -> + homepageViewModel.assignModuleToCustomSet(id, setId) + }, + ) + } + var pendingBackupDestination by remember { mutableStateOf(null) } @@ -177,27 +294,120 @@ fun HomeRouteScreen( } } + LaunchedEffect(homepageViewModel) { + homepageViewModel.effects.collectLatest { effect -> + when (effect) { + is io.legado.app.ui.main.homepage.HomepageEffect.NavigateToBookInfo -> + onNavigateToBookInfo( + effect.name, + effect.author, + effect.bookUrl, + effect.origin, + effect.coverPath, + effect.sharedCoverKey, + ) + + is io.legado.app.ui.main.homepage.HomepageEffect.NavigateToExploreShow -> + onOpenExploreShow(effect.title, effect.sourceUrl, effect.exploreUrl) + + is io.legado.app.ui.main.homepage.HomepageEffect.ShowSnackbar -> {} + } + } + } + HomeScreen( state = state, + homepageState = homepageState, + homepageFeedActions = feedActions, + homepageManageActions = manageActions, onIntent = viewModel::onIntent, + onRefreshHomepage = homepageViewModel::onRefresh, + onToggleHomepageManage = homepageViewModel::toggleManageMode, onNavigateToReadRecord = onNavigateToReadRecord, onNavigateToReadRecordOverview = onNavigateToReadRecordOverview, + onHomepageBookLongClick = { book, sharedCoverKey -> + previewBook = book + previewSharedCoverKey = sharedCoverKey + }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, ) + + SearchBookPreviewSheet( + data = previewBook, + shelfState = previewBook?.let { homepageViewModel.getCurrentBookShelfState(it) }, + sharedCoverKey = previewSharedCoverKey, + onDismissRequest = { previewBook = null }, + onOpenDetail = { book, sharedCoverKey -> + previewBook = null + onNavigateToBookInfo( + book.name, + book.author, + book.bookUrl, + book.origin, + book.coverUrl, + sharedCoverKey, + ) + }, + onAddToShelf = { book -> + homepageViewModel.onAddToShelf(book) + }, + ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalSharedTransitionApi::class, +) @Composable fun HomeScreen( state: HomeUiState, + homepageState: HomepageUiState, + homepageFeedActions: HomepageFeedActions, + homepageManageActions: HomepageManageActions, onIntent: (HomeIntent) -> Unit, + onRefreshHomepage: () -> Unit, + onToggleHomepageManage: () -> Unit, onNavigateToReadRecord: () -> Unit = {}, onNavigateToReadRecordOverview: () -> Unit = {}, + onHomepageBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> }, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val scope = rememberCoroutineScope() + + val selectedSets = remember(homepageState.manageState.sets) { + homepageState.manageState.sets.filter { it.isSelected } + } + val pagerState = rememberPagerState(pageCount = { + selectedSets.size.coerceAtLeast(1) + }) + + var showPageMenu by remember { mutableStateOf(false) } + var showSourceMenu by remember { mutableStateOf(false) } + + val currentPageSourceName by remember(selectedSets, pagerState) { + derivedStateOf { selectedSets.getOrNull(pagerState.currentPage)?.sourceName } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collect { page -> + val url = selectedSets.getOrNull(page)?.sourceUrl ?: return@collect + onIntent(HomeIntent.SelectSourceSet(url)) + } + } + + LaunchedEffect(state.selectedSourceSetUrl, pagerState) { + val targetUrl = state.selectedSourceSetUrl ?: return@LaunchedEffect + val targetIndex = selectedSets.indexOfFirst { it.sourceUrl == targetUrl } + if (targetIndex >= 0 && targetIndex != pagerState.currentPage) { + pagerState.animateScrollToPage(targetIndex) + } + } AppScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -206,79 +416,366 @@ fun HomeScreen( GlassMediumFlexibleTopAppBar( title = stringResource(R.string.home), scrollBehavior = scrollBehavior, + actions = { + TopBarActionButton( + onClick = onToggleHomepageManage, + imageVector = AppIcons.Settings, + contentDescription = stringResource(R.string.homepage_module_manage), + ) + Box { + TopBarActionButton( + onClick = { showPageMenu = true }, + imageVector = Icons.Default.MoreVert, + contentDescription = null, + ) + RoundDropdownMenu( + expanded = showPageMenu, + onDismissRequest = { showPageMenu = false }, + ) { + RoundDropdownMenuItem( + leadingIcon = { + MenuItemIcon(Icons.Default.Settings) + }, + text = stringResource(R.string.home_dashboard_settings), + onClick = { + showPageMenu = false + onIntent(HomeIntent.DashboardSettingsClick) + }, + ) + } + } + }, ) }, ) { paddingValues -> - LazyColumn( + AppPullToRefresh( + isRefreshing = homepageState.isRefreshing, + onRefresh = onRefreshHomepage, modifier = Modifier.fillMaxSize(), - contentPadding = adaptiveContentPadding( - top = paddingValues.calculateTopPadding() + 8.dp, - bottom = 120.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item(key = "recent_book") { - RecentBookCard( - book = state.recentBook, - onClick = { onIntent(HomeIntent.RecentBookClick) }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val dashboardScrollState = rememberScrollState() + val moduleNestedScrollConnection = remember(dashboardScrollState) { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (available.y >= 0f) { + Offset.Zero + } else { + val consumed = dashboardScrollState.dispatchRawDelta(-available.y) + Offset(0f, -consumed) + } + } - item(key = "statistics") { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (available.y <= 0f) { + Offset.Zero + } else { + val c = dashboardScrollState.dispatchRawDelta(-available.y) + Offset(0f, -c) + } + } + } + } + val viewportHeight = maxHeight + val hasDashboard = state.visibleSections.isNotEmpty() + val isSourceSwitcherVisible by remember(hasDashboard, selectedSets) { + derivedStateOf { + selectedSets.size > 1 && + (!hasDashboard || dashboardScrollState.value > 0 || showSourceMenu) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(dashboardScrollState), ) { + if (hasDashboard) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(paddingValues.calculateTopPadding() + 8.dp), + ) + HomeDashboardContent( + state = state, + onIntent = onIntent, + onNavigateToReadRecord = onNavigateToReadRecord, + onNavigateToReadRecordOverview = onNavigateToReadRecordOverview, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(viewportHeight), + ) { + if (selectedSets.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + AppText(stringResource(R.string.homepage_no_source_sets_selected)) + } + } else { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .nestedScroll(moduleNestedScrollConnection), + key = { index -> + selectedSets.getOrNull(index)?.sourceUrl ?: index + }, + ) { pageIndex -> + val source = selectedSets.getOrNull(pageIndex) + val sourceModules = remember(homepageState.modules, source) { + homepageState.modules.filter { module -> + if (source?.isCustomSet == true) { + val setId = + HomepageViewModel.customSetIdFromUrl(source.sourceUrl) + module.customSetId == setId + } else { + module.sourceUrl == source?.sourceUrl + } + } + } + HomepageModuleFeed( + modules = sourceModules, + actions = homepageFeedActions, + modifier = Modifier.fillMaxSize(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + onBookLongClick = onHomepageBookLongClick, + onErrorClick = {}, + ) + } + } + } + } + + TopFloatingStickyItem( + item = if (isSourceSwitcherVisible) currentPageSourceName else null, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding() + 8.dp), + ) { name -> + Box { + GlassCard( + modifier = Modifier + .padding(horizontal = 12.dp) + .clickable { showSourceMenu = true }, + ) { + Row( + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + AppText( + text = name, + style = LegadoTheme.typography.labelMedium, + ) + } + } + RoundDropdownMenu( + expanded = showSourceMenu, + onDismissRequest = { showSourceMenu = false }, + ) { dismiss -> + selectedSets.forEachIndexed { index, source -> + RoundDropdownMenuItem( + text = source.sourceName, + isSelected = index == pagerState.currentPage, + onClick = { + dismiss() + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) + } + } + } + } + } + } + + HomepageModuleManageSheet( + data = if (homepageState.isManageMode) Unit else null, + onDismissRequest = onToggleHomepageManage, + state = homepageState.manageState, + actions = homepageManageActions, + ) + + HomeDialogs( + dialog = state.activeDialog, + onIntent = onIntent, + ) + HomeSheets( + sheet = state.activeSheet, + visibleSections = state.visibleSections, + onIntent = onIntent, + ) + } +} + +@Composable +private fun HomeDashboardContent( + state: HomeUiState, + onIntent: (HomeIntent) -> Unit, + onNavigateToReadRecord: () -> Unit, + onNavigateToReadRecordOverview: () -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (HomeDashboardSection.RecentBook in state.visibleSections) { + RecentBookCard( + book = state.recentBook, + onClick = { onIntent(HomeIntent.RecentBookClick) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + + val hasStatRead = HomeDashboardSection.TotalReadBooks in state.visibleSections + val hasStatTime = HomeDashboardSection.TotalReadTime in state.visibleSections + if (hasStatRead || hasStatTime) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (hasStatRead) { StatisticCard( modifier = Modifier.weight(1f), - icon = Icons.AutoMirrored.Filled.MenuBook, + icon = Icons.Default.Leaderboard, title = stringResource(R.string.home_total_read_books), value = state.totalReadBooks.toString(), unit = stringResource(R.string.unit_books), onClick = onNavigateToReadRecord, ) + } + if (hasStatTime) { StatisticCard( modifier = Modifier.weight(1f), icon = Icons.Default.AccessTime, title = stringResource(R.string.home_total_reading_time), - value = String.format("%.1f", state.totalReadTimeMillis / 3_600_000.0), + value = String.format( + "%.1f", + state.totalReadTimeMillis / 3_600_000.0, + ), unit = stringResource(R.string.unit_hours), onClick = onNavigateToReadRecordOverview, ) } } + } - item(key = "daily_goal") { - ReadingGoalCard( - todayReadTimeMillis = state.todayReadTimeMillis, - goalMinutes = state.dailyGoalMinutes, - onClick = { onIntent(HomeIntent.ReadingGoalClick) }, - ) - } + if (HomeDashboardSection.RecentBooks in state.visibleSections && state.recentBooks.isNotEmpty()) { + RecentBooksRow( + books = state.recentBooks, + onBookClick = { url -> onIntent(HomeIntent.RecentHistoryBookClick(url)) }, + ) + } - item(key = "webdav") { - WebDavBackupCard( - latestBackup = state.latestBackup, - isLoading = state.isBackupLoading, - isActionRunning = state.isBackupActionRunning, - onBackup = { onIntent(HomeIntent.BackupClick) }, - onRestore = { onIntent(HomeIntent.RestoreClick) }, - onOpenSettings = { onIntent(HomeIntent.BackupSettingsClick) }, + if (HomeDashboardSection.DailyGoal in state.visibleSections) { + ReadingGoalCard( + todayReadTimeMillis = state.todayReadTimeMillis, + goalMinutes = state.dailyGoalMinutes, + onClick = { onIntent(HomeIntent.ReadingGoalClick) }, + ) + } + + if (HomeDashboardSection.WebDavBackup in state.visibleSections) { + WebDavBackupCard( + latestBackup = state.latestBackup, + isLoading = state.isBackupLoading, + isLoadError = state.isBackupLoadError, + isActionRunning = state.isBackupActionRunning, + onBackup = { onIntent(HomeIntent.BackupClick) }, + onRestore = { onIntent(HomeIntent.RestoreClick) }, + onOpenSettings = { onIntent(HomeIntent.BackupSettingsClick) }, + onRetry = { onIntent(HomeIntent.RetryBackupInfo) }, + ) + } + } +} + +@Composable +private fun StatisticCard( + modifier: Modifier, + icon: ImageVector, + title: String, + value: String, + unit: String, + onClick: () -> Unit, +) { + GlassCard( + modifier = modifier, + onClick = onClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 10.dp, + end = 16.dp, + bottom = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AppIcon( + imageVector = icon, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Column { + AppText( + text = title, + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, ) + Row { + AppText( + text = value, + style = LegadoTheme.typography.titleMediumEmphasized, + maxLines = 1, + modifier = Modifier + .alignBy(FirstBaseline) + .basicMarquee(), + ) + AppText( + text = unit, + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .weight(1f) + .alignBy(FirstBaseline) + .padding(start = 2.dp), + ) + } } } } - - HomeDialogs( - dialog = state.activeDialog, - onIntent = onIntent, - ) - HomeSheets( - sheet = state.activeSheet, - onIntent = onIntent, - ) } @OptIn(ExperimentalSharedTransitionApi::class) @@ -289,15 +786,13 @@ private fun RecentBookCard( sharedTransitionScope: SharedTransitionScope?, animatedVisibilityScope: AnimatedVisibilityScope?, ) { - NormalCard( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)), + GlassCard( + modifier = Modifier.fillMaxWidth(), onClick = if (book?.bookUrl != null) onClick else null, - cornerRadius = 20.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainer, ) { - Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { book?.chapterProgress?.let { progress -> RecentReadingProgress( progress = progress, @@ -308,15 +803,20 @@ private fun RecentBookCard( } Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 14.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { AppIcon( - imageVector = Icons.AutoMirrored.Filled.MenuBook, + imageVector = Icons.Default.Book, contentDescription = null, tint = LegadoTheme.colorScheme.primary, ) @@ -342,7 +842,7 @@ private fun RecentBookCard( } else { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, ) { BookshelfCover( @@ -351,7 +851,7 @@ private fun RecentBookCard( path = book.coverPath, sourceOrigin = book.origin, modifier = Modifier - .width(72.dp) + .width(56.dp) .aspectRatio(5f / 7f), coverModifier = Modifier.fillMaxSize(), sharedTransitionScope = sharedTransitionScope, @@ -370,6 +870,7 @@ private fun RecentBookCard( ) AppText( text = book.author, + style = LegadoTheme.typography.bodySmall, color = LegadoTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -483,68 +984,48 @@ private fun RecentReadingProgress( } @Composable -private fun StatisticCard( - modifier: Modifier, - icon: ImageVector, - title: String, - value: String, - unit: String, - onClick: () -> Unit, +private fun RecentBooksRow( + books: List, + onBookClick: (String) -> Unit, + modifier: Modifier = Modifier, ) { - NormalCard( + LazyRow( modifier = modifier, - onClick = onClick, - cornerRadius = 20.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainer, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - top = 10.dp, - end = 16.dp, - bottom = 8.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AppIcon( - imageVector = icon, - contentDescription = null, - tint = LegadoTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + items(books, key = { it.bookUrl.orEmpty() }) { book -> + RecentHistoryBookCard( + book = book, + onClick = { book.bookUrl?.let(onBookClick) }, ) - Column { - AppText( - text = title, - style = LegadoTheme.typography.bodySmall, - color = LegadoTheme.colorScheme.onSurfaceVariant, - ) - Row { - AppText( - text = value, - style = LegadoTheme.typography.titleMediumEmphasized, - maxLines = 1, - modifier = Modifier - .alignBy(FirstBaseline) - .basicMarquee() - ) - AppText( - text = unit, - style = LegadoTheme.typography.bodySmall, - color = LegadoTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .weight(1f) - .alignBy(FirstBaseline) - .padding(start = 2.dp) - ) - } - } } } } +@Composable +private fun RecentHistoryBookCard( + book: HomeRecentBookUi, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .width(60.dp) + .aspectRatio(5f / 7f) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + ) { + BookshelfCover( + name = book.name, + author = book.author, + path = book.coverPath, + sourceOrigin = book.origin, + modifier = Modifier.fillMaxSize(), + coverModifier = Modifier.fillMaxSize(), + ) + } +} + @Composable private fun ReadingGoalCard( todayReadTimeMillis: Long, @@ -555,11 +1036,9 @@ private fun ReadingGoalCard( val progress = (todayReadTimeMillis / 60_000f / goalMinutes) .coerceIn(0f, 1f) - NormalCard( + GlassCard( modifier = Modifier.fillMaxWidth(), onClick = onClick, - cornerRadius = 20.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainer, ) { Column( modifier = Modifier @@ -669,10 +1148,12 @@ private fun SemiCircleProgress( private fun WebDavBackupCard( latestBackup: HomeBackupUi?, isLoading: Boolean, + isLoadError: Boolean, isActionRunning: Boolean, onBackup: () -> Unit, onRestore: () -> Unit, onOpenSettings: () -> Unit, + onRetry: () -> Unit, ) { val lastBackupText = when { isLoading -> stringResource(R.string.home_loading_webdav_backup) @@ -686,13 +1167,12 @@ private fun WebDavBackupCard( stringResource(R.string.home_latest_backup_value, date) } + isLoadError -> stringResource(R.string.home_webdav_backup_load_error) else -> stringResource(R.string.home_no_webdav_backup) } - NormalCard( + GlassCard( modifier = Modifier.fillMaxWidth(), - cornerRadius = 20.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainer, ) { Column( modifier = Modifier.padding(16.dp), @@ -716,7 +1196,11 @@ private fun WebDavBackupCard( AppText( text = lastBackupText, style = LegadoTheme.typography.bodySmall, - color = LegadoTheme.colorScheme.onSurfaceVariant, + color = if (isLoadError) { + LegadoTheme.colorScheme.error + } else { + LegadoTheme.colorScheme.onSurfaceVariant + }, ) } if (isLoading || isActionRunning) { @@ -726,30 +1210,68 @@ private fun WebDavBackupCard( } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + if (isLoadError && !isLoading) { SmallTonalButton( - modifier = Modifier.weight(1f), - onClick = onBackup, - enabled = !isActionRunning, - icon = Icons.Default.CloudUpload, - text = stringResource(R.string.backup), + modifier = Modifier.fillMaxWidth(), + onClick = onRetry, + icon = Icons.Default.Refresh, + text = stringResource(R.string.retry), ) - SmallTonalButton( - modifier = Modifier.weight(1f), - onClick = onRestore, - enabled = !isActionRunning, - icon = Icons.Default.CloudDownload, - text = stringResource(R.string.restore), - ) - SmallTonalButton( - modifier = Modifier.weight(1f), - onClick = onOpenSettings, - enabled = !isActionRunning, - icon = Icons.Default.Settings, - text = stringResource(R.string.setting), + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SmallTonalButton( + modifier = Modifier.weight(1f), + onClick = onBackup, + enabled = !isActionRunning, + icon = Icons.Default.CloudUpload, + text = stringResource(R.string.backup), + ) + SmallTonalButton( + modifier = Modifier.weight(1f), + onClick = onRestore, + enabled = !isActionRunning, + icon = Icons.Default.CloudDownload, + text = stringResource(R.string.restore), + ) + SmallTonalButton( + modifier = Modifier.weight(1f), + onClick = onOpenSettings, + enabled = !isActionRunning, + icon = Icons.Default.Settings, + text = stringResource(R.string.setting), + ) + } + } + } + } +} + +@Composable +private fun HomeDashboardSettingsSheet( + show: Boolean, + onDismissRequest: () -> Unit, + visibleSections: Set, + onSectionVisibilityChanged: (HomeDashboardSection, Boolean) -> Unit, +) { + if (!show) return + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (section in HomeDashboardSection.entries) { + CompactSwitchSettingItem( + title = stringResource(section.labelRes()), + checked = section in visibleSections, + onCheckedChange = { visible -> + onSectionVisibilityChanged(section, visible) + }, ) } } @@ -817,8 +1339,17 @@ private fun HomeDialogs( @Composable private fun HomeSheets( sheet: HomeSheet?, + visibleSections: Set, onIntent: (HomeIntent) -> Unit, ) { + HomeDashboardSettingsSheet( + show = sheet is HomeSheet.DashboardSettings, + onDismissRequest = { onIntent(HomeIntent.DismissSheet) }, + visibleSections = visibleSections, + onSectionVisibilityChanged = { section, visible -> + onIntent(HomeIntent.SetSectionVisible(section, visible)) + }, + ) BackupOptionSheet( show = sheet is HomeSheet.BackupOptions, onDismissRequest = { onIntent(HomeIntent.DismissSheet) }, @@ -845,3 +1376,27 @@ private fun HomeSheets( onRestoreFromNetwork = { onIntent(HomeIntent.RestoreFromNetwork) }, ) } + +@Composable +private fun AppModalBottomSheet( + show: Boolean, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + ) { + content() + } +} + +@StringRes +private fun HomeDashboardSection.labelRes(): Int = when (this) { + HomeDashboardSection.RecentBook -> R.string.home_recent_reading + HomeDashboardSection.TotalReadBooks -> R.string.home_total_read_books + HomeDashboardSection.TotalReadTime -> R.string.home_total_reading_time + HomeDashboardSection.RecentBooks -> R.string.home_recent_books + HomeDashboardSection.DailyGoal -> R.string.home_today_reading_goal + HomeDashboardSection.WebDavBackup -> R.string.home_webdav_backup +} diff --git a/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt b/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt index e42f71634..7f8421a40 100644 --- a/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt @@ -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(null) private val _effects = MutableSharedFlow(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, + ) } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt index 9118a50cd..32f4420af 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt @@ -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 get() = _homepageLayoutMode.state - /** * 首页书源隐藏 */ private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "") var homepageSourceHidden by _homepageSourceHidden -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt index 39d3feeab..8607f4671 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt @@ -19,7 +19,6 @@ data class HomepageBookItemUi( data class HomepageUiState( val modules: ImmutableList = 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, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt deleted file mode 100644 index 62b87dd0d..000000000 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt +++ /dev/null @@ -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 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)) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt new file mode 100644 index 000000000..b49b374d1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleFeed.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt deleted file mode 100644 index 86a41dab1..000000000 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ /dev/null @@ -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(null) } - var previewSharedCoverKey by remember { mutableStateOf(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(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, - 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 - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt deleted file mode 100644 index 520264995..000000000 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt +++ /dev/null @@ -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, - 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)) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index 33995061f..a4d3c964b 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -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>(emptyMap()) private val _bookSourcesCache = MutableStateFlow>(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 ) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt index 392fe4e9d..e0e159d77 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt @@ -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, 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, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 18210a6df..9b1ec9463 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1022,6 +1022,10 @@ 状态栏显示时隐藏 反转目录 显示发现 + 显示首页 + 首页与导航 + 配置首页、发现、订阅和默认主页 + 导航顺序 样式 分组样式 导出文件名 @@ -1830,12 +1834,9 @@ 类型 布局配置 编辑原始 JSON (LayoutConfig) - 布局设置 - 发现模块 - 经典发现 - 切换至发现模块 - 切换至经典发现 最近阅读 + 最近阅读记录 + 首页组件 暂无阅读记录 累计阅读 阅读时长 @@ -1851,14 +1852,10 @@ 最近备份:%1$s 暂无 WebDAV 备份 正在获取备份信息… + 无法获取备份信息 立即将当前数据备份到 WebDAV? 恢复 WebDAV 备份 %1$s?当前数据可能被覆盖。 - 首页布局模式 - 混合列表 - 分源Tab - 筛选书源 未选择任何书源集 - 模块错误 请在书源中添加首页模块定义 收起 显示全部 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b9cd80363..a96fdb1a0 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -895,6 +895,10 @@ 輸入自訂源分組名稱 反轉目錄 顯示發現 + 顯示首頁 + 首頁與導覽 + 設定首頁、發現、訂閱和預設主頁 + 導覽順序 樣式 分組樣式 導出文件名 @@ -1364,12 +1368,9 @@ 類型 佈局配置 編輯原始 JSON (LayoutConfig) - 佈局設置 - 發現模塊 - 經典發現 - 切換至發現模塊 - 切換至經典發現 最近閱讀 + 最近閱讀記錄 + 首頁組件 暫無閱讀記錄 累計閱讀 閱讀時長 @@ -1385,14 +1386,10 @@ 最近備份:%1$s 暫無 WebDAV 備份 正在獲取備份資訊… + 無法獲取備份資訊 立即將目前資料備份到 WebDAV? 恢復 WebDAV 備份 %1$s?目前資料可能被覆蓋。 - 首頁佈局模式 - 混合列表 - 分源Tab - 篩選書源 未選擇任何書源集 - 模塊錯誤 請在書源中添加首頁模塊定義 收起 顯示全部 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7cf0bd3e4..377652723 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -898,6 +898,10 @@ 狀態欄顯示時隱藏 反轉目錄 顯示發現 + 顯示首頁 + 首頁與導覽 + 設定首頁、發現、訂閱和預設主頁 + 導覽順序 樣式 分組樣式 匯出檔案名 @@ -1368,12 +1372,9 @@ 類型 佈局配置 編輯原始 JSON (LayoutConfig) - 佈局設置 - 發現模組 - 經典發現 - 切換至發現模組 - 切換至經典發現 最近閱讀 + 最近閱讀記錄 + 首頁元件 暫無閱讀記錄 累計閱讀 閱讀時長 @@ -1389,14 +1390,10 @@ 最近備份:%1$s 暫無 WebDAV 備份 正在取得備份資訊… + 無法取得備份資訊 立即將目前資料備份到 WebDAV? 還原 WebDAV 備份 %1$s?目前資料可能被覆蓋。 - 首頁佈局模式 - 混合列表 - 分源Tab - 篩選書源 未選擇任何書源集 - 模組錯誤 請在書源中添加首頁模組定義 收起 顯示全部 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index af8b169e7..e4ef1edec 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -260,8 +260,7 @@ @string/home @string/bookshelf - @string/discovery_modules - @string/classic_discovery + @string/discovery @string/rss @string/my @@ -277,7 +276,6 @@ home bookshelf - explore_modules explore rss my diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22296ad77..fce5ac0fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1053,6 +1053,10 @@ Hide when status bar show Reverse toc Show Discovery + Show Home + Home and navigation + Configure Home, Discovery, Subscriptions, and the start page + Navigation order Style Group style Export file name @@ -1963,12 +1967,9 @@ Type Layout Config Edit Raw JSON (LayoutConfig) - Layout Settings - Discovery Modules - Classic Discovery - Switch to Discovery Modules - Switch to Classic Discovery Recently Reading + Recent reading history + Home components No reading history yet Books Read Reading Time @@ -1984,14 +1985,10 @@ Latest backup: %1$s No WebDAV backup found Loading backup information… + Couldn\'t load backup information Back up current data to WebDAV now? Restore WebDAV backup %1$s? Current data may be overwritten. - Homepage Layout Mode - Mixed List - Tabs by Source - Filter Sources No source sets selected - Module Error Please add homepage module definitions in the book source Collapse Show All