由Compose重写的发现页。现在支持过滤书架内书籍。注意,初始版本存在不稳定与性能问题,仅供尝鲜使用。
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user