Quellcode durchsuchen

[优化] 优化了大部分Compose界面的动画与逻辑(正文选字搜索、部分界面的动画效果等)

HapeLee vor 10 Monaten
Ursprung
Commit
8362a0fe8b

+ 0 - 5
app/src/main/AndroidManifest.xml

@@ -339,11 +339,6 @@
             android:enableOnBackInvokedCallback="true"
             android:configChanges="orientation|screenSize"
             android:hardwareAccelerated="true" />
-        <activity
-            android:name=".ui.about.ReadRecordActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
-            android:hardwareAccelerated="true" />
         <!-- 字典管理 -->
         <activity
             android:name=".ui.dict.rule.DictRuleActivity"

+ 0 - 246
app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt

@@ -1,246 +0,0 @@
-package io.legado.app.ui.about
-
-import android.content.Context
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.ViewGroup
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.BaseActivity
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.ReadRecordShow
-import io.legado.app.databinding.ActivityReadRecordBinding
-import io.legado.app.databinding.ItemReadRecordBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.model.BookCover
-//import io.legado.app.lib.theme.primaryTextColor
-import io.legado.app.ui.book.search.SearchActivity
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.getInt
-import io.legado.app.utils.putInt
-import io.legado.app.utils.startActivityForBook
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.text.SimpleDateFormat
-import java.util.Locale
-
-class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
-
-    private val adapter by lazy { RecordAdapter(this) }
-    private var sortMode
-        get() = LocalConfig.getInt("readRecordSort")
-        set(value) {
-            LocalConfig.putInt("readRecordSort", value)
-        }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-
-    override val binding by viewBinding(ActivityReadRecordBinding::inflate)
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initView()
-        initAllTime()
-        initData()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_read_record, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_enable_record)?.isChecked = AppConfig.enableReadRecord
-        when (sortMode) {
-            1 -> menu.findItem(R.id.menu_sort_read_long)?.isChecked = true
-            2 -> menu.findItem(R.id.menu_sort_read_time)?.isChecked = true
-            else -> menu.findItem(R.id.menu_sort_name)?.isChecked = true
-        }
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_sort_name -> {
-                sortMode = 0
-                item.isChecked = true
-                initData()
-            }
-
-            R.id.menu_sort_read_long -> {
-                sortMode = 1
-                item.isChecked = true
-                initData()
-            }
-
-            R.id.menu_sort_read_time -> {
-                sortMode = 2
-                item.isChecked = true
-                initData()
-            }
-
-            R.id.menu_enable_record -> {
-                AppConfig.enableReadRecord = !item.isChecked
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initView() {
-        initSearchView()
-        binding.tvBookName.setText(R.string.all_read_time)
-        binding.tvRemove.setOnClickListener {
-            alert(R.string.delete, R.string.sure_del) {
-                yesButton {
-                    appDb.readRecordDao.clear()
-                    initData()
-                }
-                noButton()
-            }
-        }
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-    }
-
-    private fun initSearchView() {
-        //searchView.applyTint(primaryTextColor)
-        searchView.isSubmitButtonEnabled = true
-        searchView.queryHint = getString(R.string.search)
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String): Boolean {
-                searchView.clearFocus()
-                return false
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                initData(newText)
-                return false
-            }
-        })
-    }
-
-    private fun initAllTime() {
-        lifecycleScope.launch {
-            val allTime = withContext(IO) {
-                appDb.readRecordDao.getTotalReadTime()
-            }
-            binding.tvReadingTime.text = formatDuring(allTime)
-        }
-    }
-
-    private fun initData(searchKey: String? = null) {
-        lifecycleScope.launch {
-            val readRecords = withContext(IO) {
-                appDb.readRecordDao.search(searchKey ?: "").let { records ->
-                    when (sortMode) {
-                        1 -> records.sortedByDescending { it.readTime }
-                        2 -> records.sortedByDescending { it.lastRead }
-                        else -> records.sortedWith { o1, o2 ->
-                            o1.bookName.cnCompare(o2.bookName)
-                        }
-                    }
-                }
-            }
-            adapter.setItems(readRecords)
-        }
-    }
-
-    inner class RecordAdapter(context: Context) :
-        RecyclerAdapter<ReadRecordShow, ItemReadRecordBinding>(context) {
-
-        private val dateFormat = SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault())
-
-        override fun getViewBinding(parent: ViewGroup): ItemReadRecordBinding {
-            return ItemReadRecordBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemReadRecordBinding,
-            item: ReadRecordShow,
-            payloads: MutableList<Any>,
-        ) {
-            binding.apply {
-                tvBookName.text = item.bookName
-                tvReadingTime.text = formatDuring(item.readTime)
-                if (item.lastRead > 0) {
-                    tvLastReadTime.text = dateFormat.format(item.lastRead)
-                } else {
-                    tvLastReadTime.text = "时间未知"
-                }
-
-                lifecycleScope.launch {
-                    val book = withContext(IO) {
-                        appDb.bookDao.findByName(item.bookName).firstOrNull()
-                    }
-                    if (book != null) {
-                        BookCover.load(root.context, book.getDisplayCover(), false, book.origin)
-                            .into(ivCover)
-                    }
-                }
-            }
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemReadRecordBinding) {
-            binding.apply {
-                root.setOnClickListener {
-                    val item = getItem(holder.layoutPosition) ?: return@setOnClickListener
-                    lifecycleScope.launch {
-                        val book = withContext(IO) {
-                            appDb.bookDao.findByName(item.bookName).firstOrNull()
-                        }
-                        if (book == null) {
-                            SearchActivity.start(this@ReadRecordActivity, item.bookName)
-                        } else {
-                            startActivityForBook(book)
-                        }
-                    }
-                }
-                tvRemove.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let { item ->
-                        sureDelAlert(item)
-                    }
-                }
-            }
-        }
-
-        private fun sureDelAlert(item: ReadRecordShow) {
-            alert(R.string.delete) {
-                setMessage(getString(R.string.sure_del_any, item.bookName))
-                yesButton {
-                    appDb.readRecordDao.deleteByName(item.bookName)
-                    initData()
-                }
-                noButton()
-            }
-        }
-
-    }
-
-    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 ""
-        var time = "$d$h$m$s"
-        if (time.isBlank()) {
-            time = "0秒"
-        }
-        return time
-    }
-
-}

+ 75 - 35
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -4,6 +4,7 @@ import android.net.Uri
 import android.widget.Toast
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.animateContentSize
 import androidx.compose.animation.core.animateFloatAsState
@@ -33,12 +34,15 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.Bookmark
+import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.lazylist.Scroller
 import io.legado.app.ui.widget.components.SearchBarSection
 import org.koin.androidx.compose.koinViewModel
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
+    ExperimentalMaterial3ExpressiveApi::class
+)
 @Composable
 fun AllBookmarkScreen(
     viewModel: AllBookmarkViewModel = koinViewModel(),
@@ -46,7 +50,7 @@ fun AllBookmarkScreen(
 ) {
     val context = LocalContext.current
     val searchText by viewModel.searchQuery.collectAsState()
-    val bookmarksGrouped by viewModel.bookmarksState.collectAsState()
+    val uiState by viewModel.bookmarksState.collectAsState()
     val collapsedGroups by viewModel.collapsedGroups.collectAsState()
 
     var showMenu by remember { mutableStateOf(false) }
@@ -56,6 +60,7 @@ fun AllBookmarkScreen(
     var pendingExportIsMd by remember { mutableStateOf(false) }
     val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
 
+    val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap()
     val allKeys = bookmarksGrouped.keys
     val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
 
@@ -137,54 +142,89 @@ fun AllBookmarkScreen(
                     SearchBarSection(
                         query = searchText,
                         onQueryChange = { viewModel.onSearchQueryChanged(it) },
-                        placeholder = "模糊搜索"
+                        placeholder = "搜索..."
                     )
                 }
             }
         }
     ) { paddingValues ->
-        FastScrollLazyColumn(
+        Column(
             modifier = Modifier
                 .fillMaxSize()
                 .padding(paddingValues)
         ) {
-            bookmarksGrouped.forEach { (headerKey, bookmarks) ->
+            AnimatedContent(
+                targetState = uiState,
+                label = "bookmarkTransition"
+            ) { state ->
+                when (state) {
+                    BookmarkUiState.Loading -> {
+                        EmptyMessageView(
+                            message = "加载中...",
+                            isLoading = true,
+                            modifier = Modifier
+                                .fillMaxSize()
+                        )
+                    }
 
-                val isCollapsed = collapsedGroups.contains(headerKey.toString())
+                    is BookmarkUiState.Success -> {
+                        if (state.bookmarks.isEmpty()) {
+                            EmptyMessageView(
+                                message = "没有书签!",
+                                modifier = Modifier
+                                    .fillMaxSize()
+                            )
+                        } else {
+                            FastScrollLazyColumn(
+                                modifier = Modifier.fillMaxSize()
+                            ) {
+                                state.bookmarks.forEach { (headerKey, bookmarks) ->
 
-                stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
-                    BookAuthorHeader(
-                        bookTitle = headerKey.bookName,
-                        bookAuthor = headerKey.bookAuthor,
-                        isCollapsed = isCollapsed,
-                        onToggle = { viewModel.toggleGroupCollapse(headerKey) }
-                    )
-                }
+                                    val isCollapsed = collapsedGroups.contains(headerKey.toString())
 
-                item(key = "content_${headerKey}") {
-                    AnimatedVisibility(
-                        visible = !isCollapsed,
-                        enter = expandVertically() + fadeIn(),
-                        exit = shrinkVertically() + fadeOut()
-                    ) {
-                        Column(
-                            modifier = Modifier
-                                .animateContentSize()
-                        ) {
-                            bookmarks.forEach { bookmark ->
-                                BookmarkItem(
-                                    bookmark = bookmark,
-                                    modifier = Modifier
-                                        .animateItem()
-                                        .fillMaxWidth(),
-                                    onClick = {
-                                        editingBookmark = bookmark
-                                        showBottomSheet = true
+                                    stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
+                                        BookAuthorHeader(
+                                            bookTitle = headerKey.bookName,
+                                            bookAuthor = headerKey.bookAuthor,
+                                            isCollapsed = isCollapsed,
+                                            onToggle = { viewModel.toggleGroupCollapse(headerKey) }
+                                        )
                                     }
-                                )
+
+                                    item(key = "content_${headerKey}") {
+                                        AnimatedVisibility(
+                                            visible = !isCollapsed,
+                                            enter = expandVertically() + fadeIn(),
+                                            exit = shrinkVertically() + fadeOut()
+                                        ) {
+                                            Column(
+                                                modifier = Modifier.animateContentSize()
+                                            ) {
+                                                bookmarks.forEach { bookmark ->
+                                                    BookmarkItem(
+                                                        bookmark = bookmark,
+                                                        modifier = Modifier
+                                                            .animateItem()
+                                                            .fillMaxWidth(),
+                                                        onClick = {
+                                                            editingBookmark = bookmark
+                                                            showBottomSheet = true
+                                                        }
+                                                    )
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
                             }
                         }
                     }
+
+                    is BookmarkUiState.Error -> {
+                        EmptyMessageView(
+                            message = state.throwable.localizedMessage ?: "发生错误",
+                        )
+                    }
                 }
             }
         }
@@ -407,4 +447,4 @@ fun BookmarkEditSheet(
             }
         )
     }
-}
+}

+ 18 - 11
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -15,14 +15,11 @@ import io.legado.app.utils.writeToOutputStream
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOn
@@ -39,7 +36,13 @@ data class BookmarkGroupHeader(
     val bookName: String,
     val bookAuthor: String
 ) {
-    override fun toString(): String = "$bookName|${bookAuthor}"
+    override fun toString(): String = "$bookName|$bookAuthor"
+}
+
+sealed class BookmarkUiState {
+    object Loading : BookmarkUiState()
+    data class Success(val bookmarks: Map<BookmarkGroupHeader, List<Bookmark>>) : BookmarkUiState()
+    data class Error(val throwable: Throwable) : BookmarkUiState()
 }
 
 class AllBookmarkViewModel(
@@ -55,24 +58,28 @@ class AllBookmarkViewModel(
 
 
     @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
-    val bookmarksState: StateFlow<Map<BookmarkGroupHeader, List<Bookmark>>> = _searchQuery
+    val bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
         .debounce(300L)
         .flatMapLatest { query ->
-            if (query.isBlank()) {
+            val flow = if (query.isBlank()) {
                 bookmarkDao.flowAll()
             } else {
                 bookmarkDao.flowSearchAll(query)
             }
+            flow.map<List<Bookmark>, BookmarkUiState> { list ->
+                BookmarkUiState.Success(list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) })
+            }
+                .onStart { emit(BookmarkUiState.Loading) }
+                .catch { e ->
+                    e.printStackTrace()
+                    emit(BookmarkUiState.Error(e))
+                }
         }
-        .map { list ->
-            list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) }
-        }
-        .catch { e -> e.printStackTrace() }
         .flowOn(Dispatchers.IO)
         .stateIn(
             viewModelScope,
             SharingStarted.WhileSubscribed(5000),
-            emptyMap()
+            BookmarkUiState.Loading
         )
 
     fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -1318,7 +1318,7 @@ class ReadBookActivity : BaseReadBookActivity(),
         val book = ReadBook.book ?: return
         searchContentActivity.launch {
             putExtra("bookUrl", book.bookUrl)
-            putExtra("searchWord", searchWord ?: viewModel.searchContentQuery)
+            putExtra("searchWord", searchWord)
             putExtra("searchResultIndex", viewModel.searchResultIndex)
             viewModel.searchResultList?.first()?.let {
                 if (it.query == viewModel.searchContentQuery) {

+ 120 - 114
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -1,8 +1,7 @@
 package io.legado.app.ui.book.readRecord
 
+import androidx.compose.animation.AnimatedContent
 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
@@ -15,6 +14,7 @@ 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.LazyListScope
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
@@ -182,128 +182,134 @@ fun ReadRecordScreen(
         }
     ) { 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()
+            val contentState = when {
+                state.isLoading -> "LOADING"
+                (displayMode == DisplayMode.AGGREGATE && state.groupedRecords.isEmpty()) ||
+                        (displayMode == DisplayMode.TIMELINE && state.timelineRecords.isEmpty()) ||
+                        (displayMode == DisplayMode.LATEST && state.latestRecords.isEmpty()) -> "EMPTY"
+                else -> "CONTENT"
             }
-            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 = {  }
-                                )
-                            }
-                        }
+            AnimatedContent(
+                targetState = contentState,
+                label = "MainContentAnimation"
+            ) { targetState ->
+                when (targetState) {
+                    "LOADING" -> {
+                        EmptyMessageView(
+                            modifier = Modifier.fillMaxSize(),
+                            message = "加载中"
+                        )
                     }
 
-                    item {
-                        if (isListEmpty){
-                            Box(
-                                modifier = Modifier
-                                    .fillMaxSize(),
-                                contentAlignment = Alignment.Center
-                            ) {
-                                EmptyMessageView(
-                                    message = "没有记录"
-                                )
-                            }
-                        }
+                    "EMPTY" -> {
+                        EmptyMessageView(
+                            modifier = Modifier.fillMaxSize(),
+                            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) ->
-                                stickyHeader { DateHeader(date) }
-
-                                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()
-                                )
+                    "CONTENT" -> {
+                        LazyColumn(
+                            modifier = Modifier
+                                .nestedScroll(scrollBehavior.nestedScrollConnection)
+                        ) {
+                            item(key = "summary_card") {
+                                SummarySection(state, viewModel)
                             }
+
+                            renderListByMode(displayMode, state, viewModel, onBookClick)
                         }
                     }
                 }
+            }
+        }
+    }
+}
+
+@Composable
+fun SummarySection(
+    state: ReadRecordUiState,
+    viewModel: ReadRecordViewModel
+) {
+    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 = {  }
+            )
+        }
+    }
+}
 
+fun LazyListScope.renderListByMode(
+    displayMode: DisplayMode,
+    state: ReadRecordUiState,
+    viewModel: ReadRecordViewModel,
+    onBookClick: (String) -> Unit
+) {
+    when (displayMode) {
+        DisplayMode.AGGREGATE -> {
+            state.groupedRecords.forEach { (date, details) ->
+                stickyHeader(key = "header_$date") {
+                    DateHeader(date, details.sumOf { it.readTime })
+                }
+                items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail ->
+                    ReadRecordItem(
+                        detail = detail,
+                        viewModel = viewModel,
+                        onClick = { onBookClick(detail.bookName) },
+                        onDelete = { viewModel.deleteDetail(detail) },
+                        modifier = Modifier.animateItem()
+                    )
+                }
+            }
+        }
+        DisplayMode.TIMELINE -> {
+            state.timelineRecords.forEach { (date, sessions) ->
+                stickyHeader(key = "timeline_header_$date") { DateHeader(date) }
+                items(items = sessions, key = { it.id }) { session ->
+                    TimelineSessionItem(
+                        item = TimelineItem(session, true),
+                        onBookClick = onBookClick,
+                        viewModel = viewModel
+                    )
+                }
+            }
+        }
+        DisplayMode.LATEST -> {
+            items(items = state.latestRecords, key = { it.bookName }) { record ->
+                LatestReadItem(
+                    record = record,
+                    viewModel = viewModel,
+                    onClick = { onBookClick(record.bookName) },
+                    modifier = Modifier.animateItem()
+                )
             }
         }
     }

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt

@@ -8,10 +8,14 @@ import io.legado.app.base.BaseComposeActivity
 class SearchContentActivity : BaseComposeActivity() {
 
     private var bookUrl: String? = null
+    private var searchWord: String? = null
+    private var searchResultIndex: Int = 0
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         bookUrl = intent.getStringExtra("bookUrl")
+        searchWord = intent.getStringExtra("searchWord")
+        searchResultIndex = intent.getIntExtra("searchResultIndex", 0)
     }
 
     @Composable
@@ -20,6 +24,7 @@ class SearchContentActivity : BaseComposeActivity() {
             bookUrl?.let {
                 SearchContentScreen(
                     bookUrl = it,
+                    searchWord = searchWord,
                     onBack = { finish() }
                 )
             }

+ 43 - 13
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.FilterChip
 import androidx.compose.material3.FloatingActionButton
 import androidx.compose.material3.HorizontalDivider
@@ -31,9 +32,15 @@ import androidx.compose.material3.IconButton
 import androidx.compose.material3.LinearProgressIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.PlainTooltip
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
 import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.animateFloatingActionButton
+import androidx.compose.material3.rememberTooltipState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -48,17 +55,17 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.text.buildAnnotatedString
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.widget.components.AnimatedText
-import io.legado.app.ui.widget.components.AnimatedTextLine
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.TextCard
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import org.koin.androidx.compose.koinViewModel
 
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SearchContentScreen(
     bookUrl: String,
+    searchWord: String?,
     onBack: () -> Unit,
     viewModel: SearchContentViewModel = koinViewModel()
 ) {
@@ -73,8 +80,15 @@ fun SearchContentScreen(
 
     val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
 
-    LaunchedEffect(bookUrl) {
+    var searchQuery by remember(searchWord) { mutableStateOf(searchWord ?: "") }
+    var replaceEnabled by remember { mutableStateOf(false) }
+    var regexReplace by remember { mutableStateOf(false) }
+
+    LaunchedEffect(bookUrl, searchWord) {
         viewModel.initBook(bookUrl)
+        if (!searchWord.isNullOrBlank()) {
+            viewModel.startSearch(searchWord, replaceEnabled, regexReplace)
+        }
     }
 
     /*
@@ -98,9 +112,6 @@ fun SearchContentScreen(
         }
     }*/
 
-    var searchQuery by remember { mutableStateOf("") }
-    var replaceEnabled by remember { mutableStateOf(false) }
-    var regexReplace by remember { mutableStateOf(false) }
     val contentState = when {
         error != null -> SearchContentState.Error(error)
         isSearching -> SearchContentState.Loading
@@ -150,12 +161,22 @@ fun SearchContentScreen(
                     ) {
                         FilterChip(
                             selected = replaceEnabled,
-                            onClick = { replaceEnabled = !replaceEnabled },
+                            onClick = {
+                                replaceEnabled = !replaceEnabled
+                                if (searchQuery.isNotBlank()) {
+                                    viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
+                                }
+                            },
                             label = { Text("启用替换") }
                         )
                         FilterChip(
                             selected = regexReplace,
-                            onClick = { regexReplace = !regexReplace },
+                            onClick = {
+                                regexReplace = !regexReplace
+                                if (searchQuery.isNotBlank()) {
+                                    viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
+                                }
+                            },
                             label = { Text("正则匹配") }
                         )
                     }
@@ -168,8 +189,19 @@ fun SearchContentScreen(
             }
         },
         floatingActionButton = {
-            if (isSearching) {
-                FloatingActionButton(onClick = { viewModel.stopSearch() }) {
+            TooltipBox(
+                positionProvider =
+                    TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+                tooltip = { PlainTooltip { Text("Localized description") } },
+                state = rememberTooltipState(),
+            ) {
+                FloatingActionButton(
+                    modifier = Modifier.animateFloatingActionButton(
+                        visible = isSearching,
+                        alignment = Alignment.BottomEnd,
+                    ),
+                    onClick = { viewModel.stopSearch() }
+                ) {
                     Icon(Icons.Default.Stop, contentDescription = "停止搜索")
                 }
             }
@@ -206,7 +238,7 @@ fun SearchContentScreen(
 
                     SearchContentState.EmptyResult -> {
                         EmptyMessageView(
-                            message = "没有找到相关内容",
+                            message = "没有找到相关内容!",
                             modifier = Modifier
                                 .fillMaxSize()
                                 .wrapContentSize()
@@ -323,5 +355,3 @@ fun SearchResultItem(
         }
     }
 }
-
-

+ 30 - 29
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -12,12 +12,9 @@ import androidx.compose.animation.core.animateDpAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.gestures.detectDragGestures
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -30,10 +27,12 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.*
 import androidx.compose.material3.*
+import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.runtime.*
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
@@ -332,10 +331,10 @@ fun ReplaceRuleScreen(
             Column {
                 MediumTopAppBar(
                     title = {
-                        val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) {
+                        val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) {
                             when {
                                 isUploading -> "正在上传..."
-                                inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}"
+                                inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}"
                                 else -> "替换规则"
                             }
                         }
@@ -434,8 +433,8 @@ fun ReplaceRuleScreen(
                         placeholder = stringResource(id = R.string.replace_purify_search)
                     )
                 }
-                AnimatedVisibility(visible = !inSelectionMode) {
-                    val allString = stringResource(R.string.all)
+                val allString = stringResource(R.string.all)
+                AnimatedVisibility(visible = groups.isNotEmpty()) {
                     PrimaryScrollableTabRow(
                         selectedTabIndex = selectedTabIndex,
                         edgePadding = 0.dp,
@@ -470,14 +469,21 @@ fun ReplaceRuleScreen(
             }
         },
         floatingActionButton = {
-            AnimatedVisibility(
-                visible = !inSelectionMode,
-                enter = fadeIn() + scaleIn(),
-                exit = fadeOut() + scaleOut()
+            TooltipBox(
+                positionProvider =
+                    TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+                tooltip = { PlainTooltip { Text("Localized description") } },
+                state = rememberTooltipState(),
             ) {
-                FloatingActionButton(onClick = {
-                    context.startActivity(ReplaceEditActivity.startIntent(context))
-                }) {
+                FloatingActionButton(
+                    modifier = Modifier.animateFloatingActionButton(
+                        visible = !inSelectionMode,
+                        alignment = Alignment.BottomEnd,
+                    ),
+                    onClick = {
+                        context.startActivity(ReplaceEditActivity.startIntent(context))
+                    }
+                ) {
                     Icon(Icons.Default.Add, contentDescription = "Add Rule")
                 }
             }
@@ -568,9 +574,8 @@ fun ReplaceRuleScreen(
             }
             AnimatedVisibility(
                 visible = inSelectionMode,
-                modifier = Modifier
-                    .align(Alignment.BottomCenter)
-                    .padding(bottom = 24.dp),
+                modifier =
+                    Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f),
                 enter = slideInVertically { it } + fadeIn(),
                 exit = slideOutVertically { it } + fadeOut()
             ) {
@@ -842,13 +847,9 @@ fun ReplaceRuleItem(
     )
 
     Card(
+        onClick = { onToggleSelection() },
         modifier = modifier
-            .fillMaxWidth()
-            .combinedClickable(
-                onClick = { onToggleSelection() },
-                interactionSource = remember { MutableInteractionSource() },
-                indication = null
-            ),
+            .fillMaxWidth(),
         shape = MaterialTheme.shapes.medium,
         colors = CardDefaults.cardColors(
             containerColor = containerColor
@@ -900,23 +901,23 @@ fun ReplaceRuleItem(
                             onDismissRequest = { showRuleMenu = false }
                         ) {
                             DropdownMenuItem(
-                                text = { Text("删除") },
+                                text = { Text("移至顶部") },
                                 onClick = {
-                                    onDelete()
+                                    onToTop()
                                     showRuleMenu = false
                                 }
                             )
                             DropdownMenuItem(
-                                text = { Text("置顶") },
+                                text = { Text("移至底部") },
                                 onClick = {
-                                    onToTop()
+                                    onToBottom()
                                     showRuleMenu = false
                                 }
                             )
                             DropdownMenuItem(
-                                text = { Text("置底") },
+                                text = { Text("删除") },
                                 onClick = {
-                                    onToBottom()
+                                    onDelete()
                                     showRuleMenu = false
                                 }
                             )

+ 26 - 10
app/src/main/java/io/legado/app/ui/widget/components/EmptyMessageView.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.widget.components
 
 import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedContent
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.LoadingIndicator
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -21,10 +24,12 @@ import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun EmptyMessageView(
     message: String,
     modifier: Modifier = Modifier,
+    isLoading: Boolean = false,
     faces: List<String> = listOf(
         "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
         "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -40,16 +45,25 @@ fun EmptyMessageView(
             .padding(16.dp),
         horizontalAlignment = Alignment.CenterHorizontally
     ) {
-        AnimatedTextLine(
-            text = currentFace,
-            fontSize = faceTextSize,
-            textAlign = TextAlign.Center,
-            modifier = Modifier
-                .clickable {
-                    currentFace = faces.random()
-                    onFaceClick?.invoke()
-                }
-        )
+        AnimatedContent(
+            targetState = isLoading,
+            label = "LoadingStateAnimation"
+        ) { loading ->
+            if (loading) {
+                LoadingIndicator()
+            } else {
+                AnimatedTextLine(
+                    text = currentFace,
+                    fontSize = faceTextSize,
+                    textAlign = TextAlign.Center,
+                    modifier = Modifier
+                        .clickable {
+                            currentFace = faces.random()
+                            onFaceClick?.invoke()
+                        }
+                )
+            }
+        }
 
         Spacer(modifier = Modifier.height(8.dp))
 
@@ -67,6 +81,7 @@ fun EmptyMessageView(
 fun EmptyMessageView(
     @StringRes messageResId: Int,
     modifier: Modifier = Modifier,
+    isLoading: Boolean = false,
     faces: List<String> = listOf(
         "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
         "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -78,6 +93,7 @@ fun EmptyMessageView(
     EmptyMessageView(
         message = message,
         modifier = modifier,
+        isLoading = isLoading,
         faces = faces,
         faceTextSize = faceTextSize,
         onFaceClick = onFaceClick

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt

@@ -103,7 +103,7 @@ fun SourceInputDialog(
             }
         },
         confirmButton = {
-            TextButton(
+            OutlinedButton(
                 onClick = { if (text.isNotBlank()) onConfirm(text) }
             ) { Text(stringResource(android.R.string.ok)) }
         },