diff --git a/app/src/main/java/io/legado/app/App.kt b/app/src/main/java/io/legado/app/App.kt index bb93ff837..edce813d5 100644 --- a/app/src/main/java/io/legado/app/App.kt +++ b/app/src/main/java/io/legado/app/App.kt @@ -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") diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 658734132..a9f72d074 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -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" } diff --git a/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt new file mode 100644 index 000000000..1d912ab2e --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt @@ -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> + suspend fun getBookSource(url: String): BookSource? + suspend fun exploreBook(source: BookSource, url: String, page: Int): Result> + suspend fun saveSearchBooks(books: List) +} + +class ExploreRepositoryImpl( + private val appDb: AppDatabase +) : ExploreRepository { + + override fun getBookshelfItems(): Flow> { + 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> { + return try { + val books = WebBook.exploreBookSuspend(source, url, page) + Result.success(books) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun saveSearchBooks(books: List) { + appDb.searchBookDao.insert(*books.toTypedArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/exploreModule.kt b/app/src/main/java/io/legado/app/di/exploreModule.kt new file mode 100644 index 000000000..4138d45c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/di/exploreModule.kt @@ -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 { ExploreRepositoryImpl(get()) } + viewModel { ExploreShowViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 804590bea..1167cd48f 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -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) + } } diff --git a/app/src/main/java/io/legado/app/model/BookShelfState.kt b/app/src/main/java/io/legado/app/model/BookShelfState.kt new file mode 100644 index 000000000..e7ab2a7ac --- /dev/null +++ b/app/src/main/java/io/legado/app/model/BookShelfState.kt @@ -0,0 +1,7 @@ +package io.legado.app.model + +enum class BookShelfState { + IN_SHELF, + SAME_NAME_AUTHOR, + NOT_IN_SHELF +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt index 1d9d39319..ac12f2a2c 100644 --- a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt +++ b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt @@ -134,6 +134,14 @@ object WebBook { ) } + suspend fun exploreBookSuspend( + bookSource: BookSource, + url: String, + page: Int? = 1, + ): List { + return exploreBookAwait(bookSource, url, page) + } + /** * 书籍信息 */ diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt index 38d25078d..ba1e4b581 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt @@ -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(), - ExploreShowAdapter.CallBack { - override val binding by viewBinding(ActivityExploreShowBinding::inflate) - override val viewModel by viewModels() +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 { + 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) { - 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 { - putExtra("name", book.name) - putExtra("author", book.author) - putExtra("bookUrl", book.bookUrl) - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt index e7cf321ca..c539772a2 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowAdapter.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt new file mode 100644 index 000000000..484320fd3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index 80cfe342e..322627e7a 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -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 = ConcurrentHashMap.newKeySet() - val upAdapterLiveData = MutableLiveData() - val booksData = MutableLiveData>() - val errorLiveData = MutableLiveData() + 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 { + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() + data object Empty : UiState() +} + +class ExploreShowViewModel( + private val repository: ExploreRepository +) : ViewModel() { + + private val _rawBooks = MutableStateFlow>(emptyList()) + private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState)) + private val _isLoading = MutableStateFlow(false) + private val _errorMsg = MutableStateFlow(null) private var bookSource: BookSource? = null private var exploreUrl: String? = null private var page = 1 - private var books = linkedSetOf() + private var isEnd = false + private val _bookshelf = MutableStateFlow>(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): 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 } - -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt index 3f1f05570..fceb6e689 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt @@ -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(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(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(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, - 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) { - 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" } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt new file mode 100644 index 000000000..89cbe0e1e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -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(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(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(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, + 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) { + 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" +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index dad2ed0f6..28773538a 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt index e1c79452c..6c0050642 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchAdapter.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 14369decb..b882558b9 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt b/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt index a2d7c165a..e237ecffa 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/Cover.kt @@ -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 + ) + } } } } \ No newline at end of file