Răsfoiți Sursa

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

HapeLee 11 luni în urmă
părinte
comite
c159252de6

+ 2 - 1
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")

+ 2 - 1
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"
 }

+ 45 - 0
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<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())
+    }
+}

+ 12 - 0
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<ExploreRepository> { ExploreRepositoryImpl(get()) }
+    viewModel { ExploreShowViewModel(get()) }
+}

+ 5 - 0
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)
+        }
 }
 

+ 7 - 0
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
+}

+ 8 - 0
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<SearchBook> {
+        return exploreBookAwait(bookSource, url, page)
+    }
+
     /**
      * 书籍信息
      */

+ 21 - 78
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<ActivityExploreShowBinding, ExploreShowViewModel>(),
-    ExploreShowAdapter.CallBack {
-    override val binding by viewBinding(ActivityExploreShowBinding::inflate)
-    override val viewModel by viewModels<ExploreShowViewModel>()
+class ExploreShowActivity : BaseComposeActivity() {
 
-    private val adapter by lazy { ExploreShowAdapter(this, this) }
-    private val loadMoreView by lazy { LoadMoreView(this) }
+    private lateinit var screenTitle: String
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        binding.titleBar.title = intent.getStringExtra("exploreName")
-        initRecyclerView()
-        viewModel.booksData.observe(this) { upData(it) }
-        viewModel.initData(intent)
-        viewModel.errorLiveData.observe(this) {
-            loadMoreView.error(it)
-        }
-        viewModel.upAdapterLiveData.observe(this) {
-            adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null))
-        }
+        screenTitle = intent.getStringExtra("exploreName") ?: "探索"
     }
 
-    private fun initRecyclerView() {
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-        adapter.addFooterView {
-            ViewLoadMoreBinding.bind(loadMoreView)
-        }
-        loadMoreView.startLoad()
-        loadMoreView.setOnClickListener {
-            if (!loadMoreView.isLoading) {
-                scrollToBottom(true)
-            }
-        }
-        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
-            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
-                super.onScrolled(recyclerView, dx, dy)
-                if (!recyclerView.canScrollVertically(1)) {
-                    scrollToBottom()
+    @Composable
+    override fun Content() {
+        MaterialTheme {
+            ExploreShowScreen(
+                title = screenTitle,
+                intent = intent,
+                onBack = { finish() },
+                onBookClick = { book ->
+                    startActivity<BookInfoActivity> {
+                        putExtra("name", book.name)
+                        putExtra("author", book.author)
+                        putExtra("bookUrl", book.bookUrl)
+                    }
                 }
-            }
-        })
-    }
-
-    private fun scrollToBottom(forceLoad: Boolean = false) {
-        if ((loadMoreView.hasMore && !loadMoreView.isLoading) || forceLoad) {
-            loadMoreView.hasMore()
-            viewModel.explore()
-        }
-    }
-
-    private fun upData(books: List<SearchBook>) {
-        loadMoreView.stopLoad()
-        if (books.isEmpty() && adapter.isEmpty()) {
-            loadMoreView.noMore(getString(R.string.empty))
-        } else if (adapter.getActualItemCount() == books.size) {
-            loadMoreView.noMore()
-        } else {
-            adapter.setItems(books)
-        }
-    }
-
-    override fun getBookShelfState(name: String, author: String, url: String?): BookShelfState {
-        return viewModel.isInBookShelf(name, author, url)
-    }
-
-    override fun showBookInfo(book: Book) {
-        startActivity<BookInfoActivity> {
-            putExtra("name", book.name)
-            putExtra("author", book.author)
-            putExtra("bookUrl", book.bookUrl)
+            )
         }
     }
-}
+}

+ 1 - 1
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

+ 379 - 0
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))
+        }
+    }
+}

+ 133 - 60
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
-
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class ExploreShowViewModel(application: Application) : BaseViewModel(application) {
-    val bookshelf: MutableSet<BookKey> = ConcurrentHashMap.newKeySet()
-    val upAdapterLiveData = MutableLiveData<String>()
-    val booksData = MutableLiveData<List<SearchBook>>()
-    val errorLiveData = MutableLiveData<String>()
+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)
+
+    companion object {
+        fun fromId(id: Int): BookFilterState = when (id) {
+            1 -> HIDE_IN_SHELF
+            2 -> HIDE_SAME_NAME_AUTHOR
+            3 -> SHOW_NOT_IN_SHELF_ONLY
+            else -> SHOW_ALL
+        }
+    }
+}
+
+sealed class UiState<out T> {
+    data object Loading : UiState<Nothing>()
+    data class Success<T>(val data: T) : UiState<T>()
+    data class Error(val message: String) : UiState<Nothing>()
+    data object Empty : UiState<Nothing>()
+}
+
+class ExploreShowViewModel(
+    private val repository: ExploreRepository
+) : ViewModel() {
+
+    private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
+    private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState))
+    private val _isLoading = MutableStateFlow(false)
+    private val _errorMsg = MutableStateFlow<String?>(null)
     private var bookSource: BookSource? = null
     private var exploreUrl: String? = null
     private var page = 1
-    private var books = linkedSetOf<SearchBook>()
+    private var isEnd = false
+    private val _bookshelf = MutableStateFlow<Set<BookKey>>(emptySet())
+
+    val uiBooks = combine(
+        _rawBooks,
+        _filterState,
+        _bookshelf
+    ) { books, filter, bookshelf ->
+        books.filter { item ->
+            val state = getBookShelfState(item, bookshelf)
+            when (filter) {
+                BookFilterState.SHOW_ALL -> true
+
+                BookFilterState.HIDE_IN_SHELF ->
+                    state != BookShelfState.IN_SHELF
+
+                BookFilterState.HIDE_SAME_NAME_AUTHOR ->
+                    state != BookShelfState.SAME_NAME_AUTHOR
+
+                BookFilterState.SHOW_NOT_IN_SHELF_ONLY ->
+                    state == BookShelfState.NOT_IN_SHELF
+            }
+        }
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+
+    val isLoading = _isLoading.asStateFlow()
+    val errorMsg = _errorMsg.asStateFlow()
+    val filterState = _filterState.asStateFlow()
 
     init {
-        execute {
-            appDb.bookDao.flowAll().mapLatest { books ->
-                books.filterNot { it.isNotShelf }
-            }.catch {
-                AppLog.put("搜索界面获取书籍列表失败\n${it.localizedMessage}", it)
-            }.collect { books ->
-                bookshelf.clear()
-                bookshelf.addAll(books.map { BookKey(it.name, it.author, it.bookUrl) })
-                upAdapterLiveData.postValue("isInBookshelf")
+        viewModelScope.launch {
+            repository.getBookshelfItems().collect { list ->
+                val keys = list.map { BookKey(it.name, it.author, it.bookUrl) }.toSet()
+                _bookshelf.value = keys
             }
-        }.onError {
-            AppLog.put("加载书架数据失败", it)
         }
     }
 
     fun initData(intent: Intent) {
-        execute {
-            val sourceUrl = intent.getStringExtra("sourceUrl")
-            exploreUrl = intent.getStringExtra("exploreUrl")
+        val sourceUrl = intent.getStringExtra("sourceUrl")
+        exploreUrl = intent.getStringExtra("exploreUrl")
+
+        viewModelScope.launch {
             if (bookSource == null && sourceUrl != null) {
-                bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
+                bookSource = repository.getBookSource(sourceUrl)
             }
-            explore()
+            loadMore(isRefresh = true)
         }
     }
 
-    fun explore() {
-        val source = bookSource
-        val url = exploreUrl
-        if (source == null || url == null) return
-        WebBook.exploreBook(viewModelScope, source, url, page)
-            .timeout(if (BuildConfig.DEBUG) 0L else 30000L)
-            .onSuccess(IO) { searchBooks ->
-                books.addAll(searchBooks)
-                booksData.postValue(books.toList())
-                appDb.searchBookDao.insert(*searchBooks.toTypedArray())
-                page++
-            }.onError {
-                it.printOnDebug()
-                errorLiveData.postValue(it.stackTraceStr)
+    fun setFilterState(state: BookFilterState) {
+        _filterState.value = state
+        AppConfig.exploreFilterState = state.id
+    }
+
+    fun 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 isInBookShelf(name: String, author: String, url: String?): BookShelfState {
-        val exactMatch = bookshelf.any { it.name == name && it.author == author && it.url == url }
+    fun getCurrentBookShelfState(item: SearchBook): BookShelfState {
+        return getBookShelfState(item, _bookshelf.value)
+    }
+
+    private fun getBookShelfState(item: SearchBook, shelf: Set<BookKey>): BookShelfState {
+        val exactMatch = shelf.any { it.name == item.name && it.author == item.author && it.url == item.bookUrl }
         if (exactMatch) return BookShelfState.IN_SHELF
 
-        val sameNameAuthor = bookshelf.any { it.name == name && it.author == author && it.url != url }
+        val sameNameAuthor = shelf.any { it.name == item.name && it.author == item.author && it.url != item.bookUrl }
         if (sameNameAuthor) return BookShelfState.SAME_NAME_AUTHOR
 
         return BookShelfState.NOT_IN_SHELF
     }
-
-}
+}

+ 0 - 625
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<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"
 }

+ 659 - 0
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<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"
+}

+ 1 - 0
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

+ 1 - 0
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

+ 1 - 6
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)

+ 56 - 20
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
+                )
+            }
         }
     }
 }