由Compose重写的发现页。现在支持过滤书架内书籍。注意,初始版本存在不稳定与性能问题,仅供尝鲜使用。

This commit is contained in:
HapeLee
2025-11-29 03:03:35 +08:00
parent d4951f0723
commit c159252de6
17 changed files with 1332 additions and 791 deletions
+2 -1
View File
@@ -33,6 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule
import io.legado.app.data.entities.rule.ExploreRule
import io.legado.app.data.entities.rule.SearchRule
import io.legado.app.di.appDatabaseModule
import io.legado.app.di.exploreModule
import io.legado.app.di.readRecordModule
import io.legado.app.help.AppFreezeMonitor
import io.legado.app.help.AppWebDav
@@ -76,7 +77,7 @@ class App : Application() {
override fun onCreate() {
startKoin {
modules(appDatabaseModule, readRecordModule)
modules(appDatabaseModule, readRecordModule, exploreModule)
}
if (getPrefString("app_theme", "0") == "12") {
if (AppConfig.customMode == "accent")
@@ -219,6 +219,7 @@ object PreferKey {
const val disableMangaScrollAnimation = "disableMangaScrollAnimation"
const val disableMangaCrossFade = "disableMangaCrossFade"
const val titleBarMode = "titleBarMode"
const val shouldShowExpandButton = "shouldShowExpandButton"
const val exploreFilterState = "explore_filter_state"
}
@@ -0,0 +1,45 @@
package io.legado.app.data.repository
import io.legado.app.data.AppDatabase
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.book.isNotShelf
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface ExploreRepository {
fun getBookshelfItems(): Flow<List<SearchBook>>
suspend fun getBookSource(url: String): BookSource?
suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
suspend fun saveSearchBooks(books: List<SearchBook>)
}
class ExploreRepositoryImpl(
private val appDb: AppDatabase
) : ExploreRepository {
override fun getBookshelfItems(): Flow<List<SearchBook>> {
return appDb.bookDao.flowAll().map { books ->
books.filterNot { it.isNotShelf }
.map { it.toSearchBook() }
}
}
override suspend fun getBookSource(url: String): BookSource? {
return appDb.bookSourceDao.getBookSource(url)
}
override suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>> {
return try {
val books = WebBook.exploreBookSuspend(source, url, page)
Result.success(books)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun saveSearchBooks(books: List<SearchBook>) {
appDb.searchBookDao.insert(*books.toTypedArray())
}
}
@@ -0,0 +1,12 @@
package io.legado.app.di
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.ui.book.explore.ExploreShowViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val exploreModule = module {
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
viewModel { ExploreShowViewModel(get()) }
}
@@ -897,5 +897,10 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.shouldShowExpandButton, value)
}
var exploreFilterState: Int
get() = appCtx.getPrefInt(PreferKey.exploreFilterState, 0)
set(value) {
appCtx.putPrefInt(PreferKey.exploreFilterState, value)
}
}
@@ -0,0 +1,7 @@
package io.legado.app.model
enum class BookShelfState {
IN_SHELF,
SAME_NAME_AUTHOR,
NOT_IN_SHELF
}
@@ -134,6 +134,14 @@ object WebBook {
)
}
suspend fun exploreBookSuspend(
bookSource: BookSource,
url: String,
page: Int? = 1,
): List<SearchBook> {
return exploreBookAwait(bookSource, url, page)
}
/**
* 书籍信息
*/
@@ -1,96 +1,39 @@
package io.legado.app.ui.book.explore
import android.os.Bundle
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ActivityExploreShowBinding
import io.legado.app.databinding.ViewLoadMoreBinding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.search.BookShelfState
import io.legado.app.ui.widget.recycler.LoadMoreView
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.startActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 发现列表
*/
class ExploreShowActivity : VMBaseActivity<ActivityExploreShowBinding, ExploreShowViewModel>(),
ExploreShowAdapter.CallBack {
override val binding by viewBinding(ActivityExploreShowBinding::inflate)
override val viewModel by viewModels<ExploreShowViewModel>()
class ExploreShowActivity : BaseComposeActivity() {
private val adapter by lazy { ExploreShowAdapter(this, this) }
private val loadMoreView by lazy { LoadMoreView(this) }
private lateinit var screenTitle: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.titleBar.title = intent.getStringExtra("exploreName")
initRecyclerView()
viewModel.booksData.observe(this) { upData(it) }
viewModel.initData(intent)
viewModel.errorLiveData.observe(this) {
loadMoreView.error(it)
}
viewModel.upAdapterLiveData.observe(this) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null))
}
screenTitle = intent.getStringExtra("exploreName") ?: "探索"
}
private fun initRecyclerView() {
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
adapter.addFooterView {
ViewLoadMoreBinding.bind(loadMoreView)
}
loadMoreView.startLoad()
loadMoreView.setOnClickListener {
if (!loadMoreView.isLoading) {
scrollToBottom(true)
}
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
scrollToBottom()
@Composable
override fun Content() {
MaterialTheme {
ExploreShowScreen(
title = screenTitle,
intent = intent,
onBack = { finish() },
onBookClick = { book ->
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
}
})
}
private fun scrollToBottom(forceLoad: Boolean = false) {
if ((loadMoreView.hasMore && !loadMoreView.isLoading) || forceLoad) {
loadMoreView.hasMore()
viewModel.explore()
)
}
}
private fun upData(books: List<SearchBook>) {
loadMoreView.stopLoad()
if (books.isEmpty() && adapter.isEmpty()) {
loadMoreView.noMore(getString(R.string.empty))
} else if (adapter.getActualItemCount() == books.size) {
loadMoreView.noMore()
} else {
adapter.setItems(books)
}
}
override fun getBookShelfState(name: String, author: String, url: String?): BookShelfState {
return viewModel.isInBookShelf(name, author, url)
}
override fun showBookInfo(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
}
}
@@ -12,7 +12,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemSearchBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.search.BookShelfState
import io.legado.app.model.BookShelfState
import io.legado.app.ui.widget.text.AccentBgTextView
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone
@@ -0,0 +1,379 @@
package io.legado.app.ui.book.explore
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.legado.app.data.entities.SearchBook
import io.legado.app.model.BookShelfState
import io.legado.app.ui.widget.components.Cover
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExploreShowScreen(
title: String,
intent: Intent,
onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
viewModel.initData(intent)
}
val books by viewModel.uiBooks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState()
val filterState by viewModel.filterState.collectAsState()
val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
ExploreTopBar(
title = title,
filterState = filterState,
onBack = onBack,
onFilterSelect = viewModel::setFilterState,
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(
items = books,
key = { it.bookUrl }
) { book ->
val shelfState = viewModel.getCurrentBookShelfState(book)
ExploreBookItem(
book = book,
shelfState = shelfState,
onClick = { onBookClick(book) },
modifier = Modifier.animateItem()
)
}
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
onRetry = { viewModel.loadMore() }
)
}
}
if (!isLoading && books.isEmpty() && errorMsg == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("暂无书籍", color = Color.Gray)
}
}
val shouldLoadMore = remember {
derivedStateOf {
val totalItems = listState.layoutInfo.totalItemsCount
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
totalItems > 0 && lastVisibleIndex >= totalItems - 2
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
viewModel.loadMore()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExploreTopBar(
title: String,
filterState: BookFilterState,
onBack: () -> Unit,
onFilterSelect: (BookFilterState) -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
var showMenu by remember { mutableStateOf(false) }
MediumTopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.FilterList, contentDescription = "Filter")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("全部显示") },
onClick = {
onFilterSelect(BookFilterState.SHOW_ALL)
showMenu = false
},
trailingIcon = { if(filterState == BookFilterState.SHOW_ALL) Icon(Icons.Default.Check, null) }
)
DropdownMenuItem(
text = { Text("隐藏已在书架的同源书籍") },
onClick = {
onFilterSelect(BookFilterState.HIDE_IN_SHELF)
showMenu = false
},
trailingIcon = { if(filterState == BookFilterState.HIDE_IN_SHELF) Icon(Icons.Default.Check, null) }
)
DropdownMenuItem(
text = { Text("隐藏已在书架的非同源书籍") },
onClick = {
onFilterSelect(BookFilterState.HIDE_SAME_NAME_AUTHOR)
showMenu = false
},
trailingIcon = { if(filterState == BookFilterState.HIDE_SAME_NAME_AUTHOR) Icon(Icons.Default.Check, null) }
)
DropdownMenuItem(
text = { Text("只显示不在书架的书籍") },
onClick = {
onFilterSelect(BookFilterState.SHOW_NOT_IN_SHELF_ONLY)
showMenu = false
},
trailingIcon = { if(filterState == BookFilterState.SHOW_NOT_IN_SHELF_ONLY) Icon(Icons.Default.Check, null) }
)
}
},
scrollBehavior = scrollBehavior
)
}
@Composable
fun ExploreBookItem(
book: SearchBook,
shelfState: BookShelfState,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column {
Cover(path = book.coverUrl)
if (shelfState != BookShelfState.NOT_IN_SHELF) {
Spacer(modifier = Modifier.height(4.dp))
BookshelfStatusBadge(
shelfState = shelfState,
modifier = Modifier
.width(48.dp)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)) {
Text(
text = book.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row {
Text(
text = book.author,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1
)
val latestChapter = book.latestChapterTitle
if (!latestChapter.isNullOrEmpty()) {
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1
)
Text(
text = "最新: $latestChapter",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
val intro = book.intro?.replace("\\s+".toRegex(), "") ?: ""
if (intro.isNotEmpty()) {
Text(
text = intro,
style = MaterialTheme.typography.bodySmall,
color = Color.DarkGray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
val kinds = book.getKindList()
if (kinds.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
kinds.forEach { kind ->
TagChip(text = kind)
Spacer(modifier = Modifier.width(6.dp))
}
}
}
}
}
}
@Composable
fun BookshelfStatusBadge(
shelfState: BookShelfState,
modifier: Modifier = Modifier
) {
val text = when (shelfState) {
BookShelfState.IN_SHELF -> "已在书架"
BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
else -> null
}
if (text != null) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp),
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
}
// 简单的标签组件
@Composable
fun TagChip(text: String) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LoadMoreFooter(
isLoading: Boolean,
errorMsg: String?,
onRetry: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
if (isLoading) {
LoadingIndicator()
} else if (errorMsg != null) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "加载失败: $errorMsg", color = Color.Red, style = MaterialTheme.typography.bodySmall)
TextButton(onClick = onRetry) {
Text("重试")
}
}
} else {
// 没有在加载且没有错误,可能到底了,或者等待滑动触发
Spacer(modifier = Modifier.height(1.dp))
}
}
}
@@ -1,91 +1,164 @@
package io.legado.app.ui.book.explore
import android.app.Application
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.BuildConfig
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.book.isNotShelf
import io.legado.app.model.webBook.WebBook
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookShelfState
import io.legado.app.ui.book.search.BookKey
import io.legado.app.ui.book.search.BookShelfState
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.stackTraceStr
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.mapLatest
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed class BookFilterState(val id: Int) {
data object SHOW_ALL : BookFilterState(0)
data object HIDE_IN_SHELF : BookFilterState(1)
data object HIDE_SAME_NAME_AUTHOR : BookFilterState(2)
data object SHOW_NOT_IN_SHELF_ONLY : BookFilterState(3)
@OptIn(ExperimentalCoroutinesApi::class)
class ExploreShowViewModel(application: Application) : BaseViewModel(application) {
val bookshelf: MutableSet<BookKey> = ConcurrentHashMap.newKeySet()
val upAdapterLiveData = MutableLiveData<String>()
val booksData = MutableLiveData<List<SearchBook>>()
val errorLiveData = MutableLiveData<String>()
companion object {
fun fromId(id: Int): BookFilterState = when (id) {
1 -> HIDE_IN_SHELF
2 -> HIDE_SAME_NAME_AUTHOR
3 -> SHOW_NOT_IN_SHELF_ONLY
else -> SHOW_ALL
}
}
}
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
data object Empty : UiState<Nothing>()
}
class ExploreShowViewModel(
private val repository: ExploreRepository
) : ViewModel() {
private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState))
private val _isLoading = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null)
private var bookSource: BookSource? = null
private var exploreUrl: String? = null
private var page = 1
private var books = linkedSetOf<SearchBook>()
private var isEnd = false
private val _bookshelf = MutableStateFlow<Set<BookKey>>(emptySet())
val uiBooks = combine(
_rawBooks,
_filterState,
_bookshelf
) { books, filter, bookshelf ->
books.filter { item ->
val state = getBookShelfState(item, bookshelf)
when (filter) {
BookFilterState.SHOW_ALL -> true
BookFilterState.HIDE_IN_SHELF ->
state != BookShelfState.IN_SHELF
BookFilterState.HIDE_SAME_NAME_AUTHOR ->
state != BookShelfState.SAME_NAME_AUTHOR
BookFilterState.SHOW_NOT_IN_SHELF_ONLY ->
state == BookShelfState.NOT_IN_SHELF
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val isLoading = _isLoading.asStateFlow()
val errorMsg = _errorMsg.asStateFlow()
val filterState = _filterState.asStateFlow()
init {
execute {
appDb.bookDao.flowAll().mapLatest { books ->
books.filterNot { it.isNotShelf }
}.catch {
AppLog.put("搜索界面获取书籍列表失败\n${it.localizedMessage}", it)
}.collect { books ->
bookshelf.clear()
bookshelf.addAll(books.map { BookKey(it.name, it.author, it.bookUrl) })
upAdapterLiveData.postValue("isInBookshelf")
viewModelScope.launch {
repository.getBookshelfItems().collect { list ->
val keys = list.map { BookKey(it.name, it.author, it.bookUrl) }.toSet()
_bookshelf.value = keys
}
}.onError {
AppLog.put("加载书架数据失败", it)
}
}
fun initData(intent: Intent) {
execute {
val sourceUrl = intent.getStringExtra("sourceUrl")
exploreUrl = intent.getStringExtra("exploreUrl")
val sourceUrl = intent.getStringExtra("sourceUrl")
exploreUrl = intent.getStringExtra("exploreUrl")
viewModelScope.launch {
if (bookSource == null && sourceUrl != null) {
bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
bookSource = repository.getBookSource(sourceUrl)
}
explore()
loadMore(isRefresh = true)
}
}
fun explore() {
val source = bookSource
val url = exploreUrl
if (source == null || url == null) return
WebBook.exploreBook(viewModelScope, source, url, page)
.timeout(if (BuildConfig.DEBUG) 0L else 30000L)
.onSuccess(IO) { searchBooks ->
books.addAll(searchBooks)
booksData.postValue(books.toList())
appDb.searchBookDao.insert(*searchBooks.toTypedArray())
page++
}.onError {
it.printOnDebug()
errorLiveData.postValue(it.stackTraceStr)
}
fun setFilterState(state: BookFilterState) {
_filterState.value = state
AppConfig.exploreFilterState = state.id
}
fun isInBookShelf(name: String, author: String, url: String?): BookShelfState {
val exactMatch = bookshelf.any { it.name == name && it.author == author && it.url == url }
fun loadMore(isRefresh: Boolean = false) {
if (_isLoading.value || (isEnd && !isRefresh)) return
viewModelScope.launch {
_isLoading.value = true
_errorMsg.value = null
val source = bookSource
val url = exploreUrl
if (source == null || url == null) {
_isLoading.value = false
_errorMsg.value = "源或URL为空"
return@launch
}
if (isRefresh) {
page = 1
isEnd = false
_rawBooks.value = emptyList()
}
repository.exploreBook(source, url, page)
.onSuccess { newBooks ->
if (newBooks.isEmpty()) {
isEnd = true
} else {
repository.saveSearchBooks(newBooks)
val currentList = if (page == 1) emptyList() else _rawBooks.value
val combined = (currentList + newBooks).distinctBy { it.bookUrl }
_rawBooks.value = combined
page++
}
}
.onFailure {
_errorMsg.value = it.localizedMessage
}
_isLoading.value = false
}
}
fun getCurrentBookShelfState(item: SearchBook): BookShelfState {
return getBookShelfState(item, _bookshelf.value)
}
private fun getBookShelfState(item: SearchBook, shelf: Set<BookKey>): BookShelfState {
val exactMatch = shelf.any { it.name == item.name && it.author == item.author && it.url == item.bookUrl }
if (exactMatch) return BookShelfState.IN_SHELF
val sameNameAuthor = bookshelf.any { it.name == name && it.author == author && it.url != url }
val sameNameAuthor = shelf.any { it.name == item.name && it.author == item.author && it.url != item.bookUrl }
if (sameNameAuthor) return BookShelfState.SAME_NAME_AUTHOR
return BookShelfState.NOT_IN_SHELF
}
}
}
@@ -1,60 +1,20 @@
package io.legado.app.ui.book.readRecord
import android.os.Bundle
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import io.legado.app.data.appDb
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.utils.startActivityForBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil
import io.legado.app.base.BaseComposeActivity
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Calendar
import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
// 包含绘制时间线所需的上下文信息
data class TimelineItem(
val session: ReadRecordSession,
// 是否显示封面和标题
val showHeader: Boolean
)
@@ -83,589 +43,4 @@ class ReadRecordActivity : BaseComposeActivity() {
super.onCreate(savedInstanceState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReadRecordScreen(
viewModel: ReadRecordViewModel,
onBackClick: () -> Unit,
onBookClick: (String) -> Unit
) {
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(showSearch) {
if (!showSearch) {
viewModel.loadData("")
}
}
LaunchedEffect(searchText) {
if (showSearch) {
delay(100L)
viewModel.loadData(searchText)
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
MediumTopAppBar(
title = {
val (mainTitle, subTitle) = when (displayMode) {
DisplayMode.AGGREGATE -> "阅读记录" to "汇总视图"
DisplayMode.TIMELINE -> "阅读记录" to "时间线视图"
DisplayMode.LATEST -> "阅读记录" to "最后阅读"
}
Column {
Text(
text = mainTitle,
style = MaterialTheme.typography.titleLarge,
)
AnimatedTextLine(
text = subTitle,
style = MaterialTheme.typography.titleMedium
)
}
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = {
val newMode = when (displayMode) {
DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
DisplayMode.TIMELINE -> DisplayMode.LATEST
DisplayMode.LATEST -> DisplayMode.AGGREGATE
}
viewModel.setDisplayMode(newMode)
}) {
val icon = when (displayMode) {
DisplayMode.AGGREGATE -> Icons.Default.Timeline
DisplayMode.TIMELINE -> Icons.Default.Schedule
DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
}
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
Icon(icon, description)
}
IconButton(onClick = { showCalendar = !showCalendar }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
}
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = null)
}
},
scrollBehavior = scrollBehavior
)
AnimatedVisibility(visible = showSearch) {
SearchBarSection(
query = searchText,
onQueryChange = { searchText = it }
)
}
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
val isEmpty = when (displayMode) {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
DisplayMode.LATEST -> state.latestRecords.isEmpty()
}
Crossfade(
targetState = isEmpty,
animationSpec = tween(durationMillis = 500),
label = "ContentCrossfade"
) { isListEmpty ->
LazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item {
val selectedDate = state.selectedDate
if (selectedDate != null) {
val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
}
item {
AnimatedVisibility(visible = showCalendar) {
CalendarSection(
selectedDate = state.selectedDate,
onDateSelected = { date ->
viewModel.setSelectedDate(date)
showCalendar = false // 选择后自动收起日历
},
onClearDate = {
viewModel.setSelectedDate(null)
showCalendar = false // 清除后自动收起
}
)
}
if (isListEmpty){
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
}
when(displayMode){
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
val dailyTotalTime = details.sumOf { it.readTime }
stickyHeader {
DateHeader(date, dailyTotalTime)
}
items(
items = details,
key = { it.bookName + it.readTime.toString() }
) { detail ->
ReadRecordItem(
detail = detail,
viewModel = viewModel,
onClick = { onBookClick(detail.bookName) },
onDelete = { viewModel.deleteDetail(detail) },
modifier = Modifier.animateItem()
)
}
}
}
DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) ->
val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime }
stickyHeader { DateHeader(date, dailyTotalTime) }
val timelineItems = sessions.mapIndexed { index, session ->
val showHeader = true
TimelineItem(session, showHeader)
}
items(items = timelineItems, key = { it.session.id }) { item ->
TimelineSessionItem(
item = item,
onBookClick = onBookClick,
viewModel = viewModel
)
}
}
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record ->
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) },
modifier = Modifier.animateItem()
)
}
}
}
}
}
}
}
}
@Composable
fun LatestReadItem(
record: ReadRecord,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) {
coverPath = viewModel.getBookCover(record.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Cover(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "总时长: ${formatDuring(record.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
fun TimelineSessionItem(
item: TimelineItem,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
) {
val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.bookName) {
coverPath = viewModel.getBookCover(session.bookName)
}
val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
val duration = session.endTime - session.startTime
val nodeRadius = 4.dp
val lineWidth = 2.dp
val timelineX = 24.dp
val contentPaddingStart = 32.dp
val lineColor = MaterialTheme.colorScheme.surfaceContainerHigh
val nodeColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.clickable { onBookClick(session.bookName) }
.drawBehind {
val x = timelineX.toPx()
val h = size.height
val cy = h / 2f
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, h),
strokeWidth = lineWidth.toPx()
)
drawCircle(
color = nodeColor,
radius = nodeRadius.toPx(),
center = Offset(x, cy)
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.width(48.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = endTimeText,
style = MaterialTheme.typography.bodySmall
)
}
Column(modifier = Modifier.weight(1f)) {
if (item.showHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Cover(coverPath)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
}
}
}
}
}
@Composable
fun ReadRecordItem(
detail: ReadRecordDetail,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) {
coverPath = viewModel.getBookCover(detail.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Cover(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = detail.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
}
}
}
@Composable
fun DateHeader(
date: String,
dailyTotalTime: Long
) {
val dateText = formatFriendlyDate(date)
val totalTimeText = "已读 ${formatDuring(dailyTotalTime)}"
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = totalTimeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
fun CalendarSection(
selectedDate: LocalDate?,
onDateSelected: (LocalDate) -> Unit,
onClearDate: () -> Unit
) {
val effectiveInitialDate = selectedDate ?: LocalDate.now()
Calendar(
modifier = Modifier.padding(horizontal = 16.dp),
initialDate = effectiveInitialDate,
selectedDate = selectedDate,
onDateSelected = onDateSelected,
onClearDate = onClearDate
)
}
@Composable
fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
}
}
val totalDurationMinutes = totalTimeMillis / 60000
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = "已读 ",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "$bookCount",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
Text(
text = " 本书",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
val hours = totalDurationMinutes / 60
val minutes = totalDurationMinutes % 60
val timeString = if (hours > 0) "${hours}小时${minutes}分钟" else "${minutes}分钟"
Text(
text = "共阅读 $timeString",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (bookNamesForCover.isNotEmpty()) {
BookStackView(coverPaths = coverPaths)
}
}
}
}
@Composable
fun BookStackView(coverPaths: List<String?>) {
val xOffsetStep = 12.dp
val stackWidth = 48.dp + (xOffsetStep * (coverPaths.size - 1).coerceAtLeast(0))
Box(
modifier = Modifier
.width(stackWidth)
.height(72.dp),
contentAlignment = Alignment.CenterStart
) {
coverPaths.forEachIndexed { index, path ->
Box(
modifier = Modifier
.padding(start = xOffsetStep * index)
.zIndex(index.toFloat())
.rotate(if (index % 2 == 0) 3f else -3f)
) {
Surface(
shadowElevation = 4.dp,
shape = RoundedCornerShape(4.dp),
color = Color.Transparent
) {
Cover(path = path)
}
}
}
}
}
fun formatDuring(mss: Long): String {
val days = mss / (1000 * 60 * 60 * 24)
val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
val seconds = mss % (1000 * 60) / 1000
val d = if (days > 0) "${days}" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}" else ""
return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
}
@@ -0,0 +1,659 @@
package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.Calendar
import io.legado.app.ui.widget.components.Cover
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.delay
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Date
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.isNotEmpty
import kotlin.collections.map
import kotlin.collections.sumOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReadRecordScreen(
viewModel: ReadRecordViewModel,
onBackClick: () -> Unit,
onBookClick: (String) -> Unit
) {
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var showCalendar by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(showSearch) {
if (!showSearch) {
viewModel.loadData("")
}
}
LaunchedEffect(searchText) {
if (showSearch) {
delay(100L)
viewModel.loadData(searchText)
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
MediumTopAppBar(
title = {
val (mainTitle, subTitle) = when (displayMode) {
DisplayMode.AGGREGATE -> "阅读记录" to "汇总视图"
DisplayMode.TIMELINE -> "阅读记录" to "时间线视图"
DisplayMode.LATEST -> "阅读记录" to "最后阅读"
}
Column {
Text(
text = mainTitle,
style = MaterialTheme.typography.titleLarge,
)
AnimatedTextLine(
text = subTitle,
style = MaterialTheme.typography.titleMedium
)
}
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = {
val newMode = when (displayMode) {
DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
DisplayMode.TIMELINE -> DisplayMode.LATEST
DisplayMode.LATEST -> DisplayMode.AGGREGATE
}
viewModel.setDisplayMode(newMode)
}) {
val icon = when (displayMode) {
DisplayMode.AGGREGATE -> Icons.Default.Timeline
DisplayMode.TIMELINE -> Icons.Default.Schedule
DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
}
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
Icon(icon, description)
}
IconButton(onClick = { showCalendar = !showCalendar }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
}
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = null)
}
},
scrollBehavior = scrollBehavior
)
AnimatedVisibility(visible = showSearch) {
SearchBarSection(
query = searchText,
onQueryChange = { searchText = it }
)
}
AnimatedVisibility(visible = showCalendar) {
CalendarSection(
selectedDate = state.selectedDate,
onDateSelected = { date ->
viewModel.setSelectedDate(date)
showCalendar = false // 选择后自动收起日历
},
onClearDate = {
viewModel.setSelectedDate(null)
showCalendar = false // 清除后自动收起
}
)
}
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
val isEmpty = when (displayMode) {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
DisplayMode.LATEST -> state.latestRecords.isEmpty()
}
Crossfade(
targetState = isEmpty,
animationSpec = tween(durationMillis = 500),
label = "ContentCrossfade"
) { isListEmpty ->
LazyColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item {
val selectedDate = state.selectedDate
if (selectedDate != null) {
val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
bookCount = distinctBooks.size,
totalTimeMillis = dailyTime,
bookNamesForCover = distinctBooks.take(3),
viewModel = viewModel,
onClick = { }
)
}
} else {
val allBooksCount = state.latestRecords.size
val totalTime = state.totalReadTime
if (allBooksCount > 0) {
ReadingSummaryCard(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
viewModel = viewModel,
onClick = { }
)
}
}
}
item {
if (isListEmpty){
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
}
when(displayMode){
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
val dailyTotalTime = details.sumOf { it.readTime }
stickyHeader {
DateHeader(date, dailyTotalTime)
}
items(
items = details,
key = { it.bookName + it.readTime.toString() }
) { detail ->
ReadRecordItem(
detail = detail,
viewModel = viewModel,
onClick = { onBookClick(detail.bookName) },
onDelete = { viewModel.deleteDetail(detail) },
modifier = Modifier.animateItem()
)
}
}
}
DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) ->
val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime }
stickyHeader { DateHeader(date, dailyTotalTime) }
val timelineItems = sessions.mapIndexed { index, session ->
val showHeader = true
TimelineItem(session, showHeader)
}
items(items = timelineItems, key = { it.session.id }) { item ->
TimelineSessionItem(
item = item,
onBookClick = onBookClick,
viewModel = viewModel
)
}
}
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record ->
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) },
modifier = Modifier.animateItem()
)
}
}
}
}
}
}
}
}
@Composable
fun LatestReadItem(
record: ReadRecord,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) {
coverPath = viewModel.getBookCover(record.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Cover(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "总时长: ${formatDuring(record.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
fun TimelineSessionItem(
item: TimelineItem,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
) {
val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.bookName) {
coverPath = viewModel.getBookCover(session.bookName)
}
val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
val duration = session.endTime - session.startTime
val nodeRadius = 4.dp
val lineWidth = 2.dp
val timelineX = 24.dp
val contentPaddingStart = 32.dp
val lineColor = MaterialTheme.colorScheme.surfaceContainerHigh
val nodeColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.clickable { onBookClick(session.bookName) }
.drawBehind {
val x = timelineX.toPx()
val h = size.height
val cy = h / 2f
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, h),
strokeWidth = lineWidth.toPx()
)
drawCircle(
color = nodeColor,
radius = nodeRadius.toPx(),
center = Offset(x, cy)
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.width(48.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = endTimeText,
style = MaterialTheme.typography.bodySmall
)
}
Column(modifier = Modifier.weight(1f)) {
if (item.showHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Cover(coverPath)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
}
}
}
}
}
@Composable
fun ReadRecordItem(
detail: ReadRecordDetail,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) {
coverPath = viewModel.getBookCover(detail.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Cover(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = detail.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
}
}
}
@Composable
fun DateHeader(
date: String,
dailyTotalTime: Long
) {
val dateText = formatFriendlyDate(date)
val totalTimeText = "已读 ${formatDuring(dailyTotalTime)}"
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = totalTimeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
fun CalendarSection(
selectedDate: LocalDate?,
onDateSelected: (LocalDate) -> Unit,
onClearDate: () -> Unit
) {
val effectiveInitialDate = selectedDate ?: LocalDate.now()
Calendar(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
initialDate = effectiveInitialDate,
selectedDate = selectedDate,
onDateSelected = onDateSelected,
onClearDate = onClearDate
)
}
@Composable
fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
}
}
val totalDurationMinutes = totalTimeMillis / 60000
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = "已读 ",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "$bookCount",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
Text(
text = " 本书",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
val hours = totalDurationMinutes / 60
val minutes = totalDurationMinutes % 60
val timeString = if (hours > 0) "${hours}小时${minutes}分钟" else "${minutes}分钟"
Text(
text = "共阅读 $timeString",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (bookNamesForCover.isNotEmpty()) {
BookStackView(coverPaths = coverPaths)
}
}
}
}
@Composable
fun BookStackView(coverPaths: List<String?>) {
val xOffsetStep = 12.dp
val stackWidth = 48.dp + (xOffsetStep * (coverPaths.size - 1).coerceAtLeast(0))
Box(
modifier = Modifier
.width(stackWidth)
.height(72.dp),
contentAlignment = Alignment.CenterStart
) {
coverPaths.forEachIndexed { index, path ->
Box(
modifier = Modifier
.padding(start = xOffsetStep * index)
.zIndex(index.toFloat())
.rotate(if (index % 2 == 0) 3f else -3f)
) {
Surface(
shadowElevation = 4.dp,
shape = RoundedCornerShape(4.dp),
color = Color.Transparent
) {
Cover(path = path)
}
}
}
}
}
fun formatDuring(mss: Long): String {
val days = mss / (1000 * 60 * 60 * 24)
val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
val seconds = mss % (1000 * 60) / 1000
val d = if (days > 0) "${days}" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}" else ""
return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
}
@@ -33,6 +33,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.databinding.ActivityBookSearchBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.BookShelfState
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
@@ -12,6 +12,7 @@ import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemSearchBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookShelfState
import io.legado.app.ui.widget.text.AccentBgTextView
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone
@@ -12,6 +12,7 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.help.book.isNotShelf
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookShelfState
import io.legado.app.model.webBook.SearchModel
import io.legado.app.utils.ConflateLiveData
import io.legado.app.utils.toastOnUi
@@ -20,12 +21,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.mapLatest
import java.util.concurrent.ConcurrentHashMap
enum class BookShelfState {
IN_SHELF,
SAME_NAME_AUTHOR,
NOT_IN_SHELF
}
data class BookKey(val name: String, val author: String, val url: String?)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -1,47 +1,83 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
private val DefaultCoverModifier = Modifier
.width(48.dp)
.height(68.dp)
@Composable
fun Cover(path: String?) {
fun Cover(
path: String?,
modifier: Modifier = DefaultCoverModifier,
badgeContent: (@Composable RowScope.() -> Unit)? = null
) {
Box(
modifier = Modifier
.width(48.dp)
.height(68.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFEEEEEE)), // 灰色背景
contentAlignment = Alignment.Center
modifier = modifier
) {
if (path == null) {
Icon(Icons.Default.Book, null, tint = Color.Gray)
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(path)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow),
contentAlignment = Alignment.Center
) {
if (path == null) {
Icon(Icons.Default.Book, null, tint = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.size(24.dp))
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(path)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
if (badgeContent != null) {
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = badgeContent
)
}
}
}
}