由Compose重写的发现页。现在支持过滤书架内书籍。注意,初始版本存在不稳定与性能问题,仅供尝鲜使用。
This commit is contained in:
17 files changed
+1330
-789
No files matched your search
@@ -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.ExploreRule
|
||||||
import io.legado.app.data.entities.rule.SearchRule
|
import io.legado.app.data.entities.rule.SearchRule
|
||||||
import io.legado.app.di.appDatabaseModule
|
import io.legado.app.di.appDatabaseModule
|
||||||
|
import io.legado.app.di.exploreModule
|
||||||
import io.legado.app.di.readRecordModule
|
import io.legado.app.di.readRecordModule
|
||||||
import io.legado.app.help.AppFreezeMonitor
|
import io.legado.app.help.AppFreezeMonitor
|
||||||
import io.legado.app.help.AppWebDav
|
import io.legado.app.help.AppWebDav
|
||||||
@@ -76,7 +77,7 @@ class App : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
startKoin {
|
startKoin {
|
||||||
modules(appDatabaseModule, readRecordModule)
|
modules(appDatabaseModule, readRecordModule, exploreModule)
|
||||||
}
|
}
|
||||||
if (getPrefString("app_theme", "0") == "12") {
|
if (getPrefString("app_theme", "0") == "12") {
|
||||||
if (AppConfig.customMode == "accent")
|
if (AppConfig.customMode == "accent")
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ object PreferKey {
|
|||||||
const val disableMangaScrollAnimation = "disableMangaScrollAnimation"
|
const val disableMangaScrollAnimation = "disableMangaScrollAnimation"
|
||||||
const val disableMangaCrossFade = "disableMangaCrossFade"
|
const val disableMangaCrossFade = "disableMangaCrossFade"
|
||||||
const val titleBarMode = "titleBarMode"
|
const val titleBarMode = "titleBarMode"
|
||||||
|
|
||||||
const val shouldShowExpandButton = "shouldShowExpandButton"
|
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)
|
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
|
package io.legado.app.ui.book.explore
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.viewModels
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.core.os.bundleOf
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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 io.legado.app.ui.book.info.BookInfoActivity
|
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.startActivity
|
||||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发现列表
|
* 发现列表
|
||||||
*/
|
*/
|
||||||
class ExploreShowActivity : VMBaseActivity<ActivityExploreShowBinding, ExploreShowViewModel>(),
|
class ExploreShowActivity : BaseComposeActivity() {
|
||||||
ExploreShowAdapter.CallBack {
|
|
||||||
override val binding by viewBinding(ActivityExploreShowBinding::inflate)
|
|
||||||
override val viewModel by viewModels<ExploreShowViewModel>()
|
|
||||||
|
|
||||||
private val adapter by lazy { ExploreShowAdapter(this, this) }
|
private lateinit var screenTitle: String
|
||||||
private val loadMoreView by lazy { LoadMoreView(this) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding.titleBar.title = intent.getStringExtra("exploreName")
|
screenTitle = 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initRecyclerView() {
|
@Composable
|
||||||
binding.recyclerView.adapter = adapter
|
override fun Content() {
|
||||||
binding.recyclerView.applyNavigationBarPadding()
|
MaterialTheme {
|
||||||
adapter.addFooterView {
|
ExploreShowScreen(
|
||||||
ViewLoadMoreBinding.bind(loadMoreView)
|
title = screenTitle,
|
||||||
}
|
intent = intent,
|
||||||
loadMoreView.startLoad()
|
onBack = { finish() },
|
||||||
loadMoreView.setOnClickListener {
|
onBookClick = { book ->
|
||||||
if (!loadMoreView.isLoading) {
|
startActivity<BookInfoActivity> {
|
||||||
scrollToBottom(true)
|
putExtra("name", book.name)
|
||||||
}
|
putExtra("author", book.author)
|
||||||
}
|
putExtra("bookUrl", book.bookUrl)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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.data.entities.SearchBook
|
||||||
import io.legado.app.databinding.ItemSearchBinding
|
import io.legado.app.databinding.ItemSearchBinding
|
||||||
import io.legado.app.help.config.AppConfig
|
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.ui.widget.text.AccentBgTextView
|
||||||
import io.legado.app.utils.dpToPx
|
import io.legado.app.utils.dpToPx
|
||||||
import io.legado.app.utils.gone
|
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
|
package io.legado.app.ui.book.explore
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.BookSource
|
||||||
import io.legado.app.data.entities.SearchBook
|
import io.legado.app.data.entities.SearchBook
|
||||||
import io.legado.app.help.book.isNotShelf
|
import io.legado.app.data.repository.ExploreRepository
|
||||||
import io.legado.app.model.webBook.WebBook
|
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.BookKey
|
||||||
import io.legado.app.ui.book.search.BookShelfState
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import io.legado.app.utils.printOnDebug
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import io.legado.app.utils.stackTraceStr
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
|
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)
|
companion object {
|
||||||
class ExploreShowViewModel(application: Application) : BaseViewModel(application) {
|
fun fromId(id: Int): BookFilterState = when (id) {
|
||||||
val bookshelf: MutableSet<BookKey> = ConcurrentHashMap.newKeySet()
|
1 -> HIDE_IN_SHELF
|
||||||
val upAdapterLiveData = MutableLiveData<String>()
|
2 -> HIDE_SAME_NAME_AUTHOR
|
||||||
val booksData = MutableLiveData<List<SearchBook>>()
|
3 -> SHOW_NOT_IN_SHELF_ONLY
|
||||||
val errorLiveData = MutableLiveData<String>()
|
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 bookSource: BookSource? = null
|
||||||
private var exploreUrl: String? = null
|
private var exploreUrl: String? = null
|
||||||
private var page = 1
|
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 {
|
init {
|
||||||
execute {
|
viewModelScope.launch {
|
||||||
appDb.bookDao.flowAll().mapLatest { books ->
|
repository.getBookshelfItems().collect { list ->
|
||||||
books.filterNot { it.isNotShelf }
|
val keys = list.map { BookKey(it.name, it.author, it.bookUrl) }.toSet()
|
||||||
}.catch {
|
_bookshelf.value = keys
|
||||||
AppLog.put("搜索界面获取书籍列表失败\n${it.localizedMessage}", it)
|
|
||||||
}.collect { books ->
|
|
||||||
bookshelf.clear()
|
|
||||||
bookshelf.addAll(books.map { BookKey(it.name, it.author, it.bookUrl) })
|
|
||||||
upAdapterLiveData.postValue("isInBookshelf")
|
|
||||||
}
|
}
|
||||||
}.onError {
|
|
||||||
AppLog.put("加载书架数据失败", it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initData(intent: Intent) {
|
fun initData(intent: Intent) {
|
||||||
execute {
|
val sourceUrl = intent.getStringExtra("sourceUrl")
|
||||||
val sourceUrl = intent.getStringExtra("sourceUrl")
|
exploreUrl = intent.getStringExtra("exploreUrl")
|
||||||
exploreUrl = intent.getStringExtra("exploreUrl")
|
|
||||||
|
viewModelScope.launch {
|
||||||
if (bookSource == null && sourceUrl != null) {
|
if (bookSource == null && sourceUrl != null) {
|
||||||
bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
|
bookSource = repository.getBookSource(sourceUrl)
|
||||||
}
|
}
|
||||||
explore()
|
loadMore(isRefresh = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun explore() {
|
fun setFilterState(state: BookFilterState) {
|
||||||
val source = bookSource
|
_filterState.value = state
|
||||||
val url = exploreUrl
|
AppConfig.exploreFilterState = state.id
|
||||||
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 isInBookShelf(name: String, author: String, url: String?): BookShelfState {
|
fun loadMore(isRefresh: Boolean = false) {
|
||||||
val exactMatch = bookshelf.any { it.name == name && it.author == author && it.url == url }
|
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
|
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
|
if (sameNameAuthor) return BookShelfState.SAME_NAME_AUTHOR
|
||||||
|
|
||||||
return BookShelfState.NOT_IN_SHELF
|
return BookShelfState.NOT_IN_SHELF
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,20 @@
|
|||||||
package io.legado.app.ui.book.readRecord
|
package io.legado.app.ui.book.readRecord
|
||||||
|
|
||||||
import android.os.Bundle
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
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 androidx.lifecycle.lifecycleScope
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
|
||||||
import io.legado.app.utils.startActivityForBook
|
import io.legado.app.utils.startActivityForBook
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.base.BaseComposeActivity
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecord
|
|
||||||
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
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 org.koin.androidx.compose.koinViewModel
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
// 包含绘制时间线所需的上下文信息
|
|
||||||
data class TimelineItem(
|
data class TimelineItem(
|
||||||
val session: ReadRecordSession,
|
val session: ReadRecordSession,
|
||||||
// 是否显示封面和标题
|
|
||||||
val showHeader: Boolean
|
val showHeader: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,588 +44,3 @@ class ReadRecordActivity : BaseComposeActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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.data.entities.SearchKeyword
|
||||||
import io.legado.app.databinding.ActivityBookSearchBinding
|
import io.legado.app.databinding.ActivityBookSearchBinding
|
||||||
import io.legado.app.lib.dialogs.alert
|
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.about.AppLogDialog
|
||||||
import io.legado.app.ui.book.info.BookInfoActivity
|
import io.legado.app.ui.book.info.BookInfoActivity
|
||||||
import io.legado.app.ui.book.source.manage.BookSourceActivity
|
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.data.entities.SearchBook
|
||||||
import io.legado.app.databinding.ItemSearchBinding
|
import io.legado.app.databinding.ItemSearchBinding
|
||||||
import io.legado.app.help.config.AppConfig
|
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.ui.widget.text.AccentBgTextView
|
||||||
import io.legado.app.utils.dpToPx
|
import io.legado.app.utils.dpToPx
|
||||||
import io.legado.app.utils.gone
|
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.data.entities.SearchKeyword
|
||||||
import io.legado.app.help.book.isNotShelf
|
import io.legado.app.help.book.isNotShelf
|
||||||
import io.legado.app.help.config.AppConfig
|
import io.legado.app.help.config.AppConfig
|
||||||
|
import io.legado.app.model.BookShelfState
|
||||||
import io.legado.app.model.webBook.SearchModel
|
import io.legado.app.model.webBook.SearchModel
|
||||||
import io.legado.app.utils.ConflateLiveData
|
import io.legado.app.utils.ConflateLiveData
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
@@ -20,12 +21,6 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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?)
|
data class BookKey(val name: String, val author: String, val url: String?)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|||||||
@@ -1,47 +1,83 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Book
|
import androidx.compose.material.icons.filled.Book
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
|
||||||
|
private val DefaultCoverModifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.height(68.dp)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Cover(path: String?) {
|
fun Cover(
|
||||||
|
path: String?,
|
||||||
|
modifier: Modifier = DefaultCoverModifier,
|
||||||
|
badgeContent: (@Composable RowScope.() -> Unit)? = null
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.width(48.dp)
|
|
||||||
.height(68.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(Color(0xFFEEEEEE)), // 灰色背景
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
if (path == null) {
|
Box(
|
||||||
Icon(Icons.Default.Book, null, tint = Color.Gray)
|
modifier = Modifier
|
||||||
} else {
|
.fillMaxSize()
|
||||||
AsyncImage(
|
.clip(RoundedCornerShape(4.dp))
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
.background(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
.data(path)
|
contentAlignment = Alignment.Center
|
||||||
.crossfade(true)
|
) {
|
||||||
.build(),
|
if (path == null) {
|
||||||
contentDescription = null,
|
Icon(Icons.Default.Book, null, tint = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.size(24.dp))
|
||||||
contentScale = ContentScale.Crop,
|
} else {
|
||||||
modifier = Modifier.fillMaxSize()
|
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