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

This commit is contained in:
HapeLee
2026-01-02 05:49:22 +08:00
parent ce0fc89ba0
commit 8362a0fe8b
11 changed files with 323 additions and 469 deletions
-5
View File
@@ -339,11 +339,6 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" /> android:hardwareAccelerated="true" />
<activity
android:name=".ui.about.ReadRecordActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" />
<!-- 字典管理 --> <!-- 字典管理 -->
<activity <activity
android:name=".ui.dict.rule.DictRuleActivity" android:name=".ui.dict.rule.DictRuleActivity"
@@ -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
}
}
@@ -4,6 +4,7 @@ import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.Bookmark 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.FastScrollLazyColumn
import io.legado.app.ui.widget.components.lazylist.Scroller import io.legado.app.ui.widget.components.lazylist.Scroller
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable @Composable
fun AllBookmarkScreen( fun AllBookmarkScreen(
viewModel: AllBookmarkViewModel = koinViewModel(), viewModel: AllBookmarkViewModel = koinViewModel(),
@@ -46,7 +50,7 @@ fun AllBookmarkScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val searchText by viewModel.searchQuery.collectAsState() val searchText by viewModel.searchQuery.collectAsState()
val bookmarksGrouped by viewModel.bookmarksState.collectAsState() val uiState by viewModel.bookmarksState.collectAsState()
val collapsedGroups by viewModel.collapsedGroups.collectAsState() val collapsedGroups by viewModel.collapsedGroups.collectAsState()
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -56,6 +60,7 @@ fun AllBookmarkScreen(
var pendingExportIsMd by remember { mutableStateOf(false) } var pendingExportIsMd by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val bookmarksGrouped = (uiState as? BookmarkUiState.Success)?.bookmarks ?: emptyMap()
val allKeys = bookmarksGrouped.keys val allKeys = bookmarksGrouped.keys
val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) } val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
@@ -137,54 +142,89 @@ fun AllBookmarkScreen(
SearchBarSection( SearchBarSection(
query = searchText, query = searchText,
onQueryChange = { viewModel.onSearchQueryChanged(it) }, onQueryChange = { viewModel.onSearchQueryChanged(it) },
placeholder = "模糊搜索" placeholder = "搜索..."
) )
} }
} }
} }
) { paddingValues -> ) { paddingValues ->
FastScrollLazyColumn( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
bookmarksGrouped.forEach { (headerKey, bookmarks) -> AnimatedContent(
targetState = uiState,
val isCollapsed = collapsedGroups.contains(headerKey.toString()) label = "bookmarkTransition"
) { state ->
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { when (state) {
BookAuthorHeader( BookmarkUiState.Loading -> {
bookTitle = headerKey.bookName, EmptyMessageView(
bookAuthor = headerKey.bookAuthor, message = "加载中...",
isCollapsed = isCollapsed, isLoading = true,
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
)
}
item(key = "content_${headerKey}") {
AnimatedVisibility(
visible = !isCollapsed,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier modifier = Modifier
.animateContentSize() .fillMaxSize()
) { )
bookmarks.forEach { bookmark -> }
BookmarkItem(
bookmark = bookmark, is BookmarkUiState.Success -> {
modifier = Modifier if (state.bookmarks.isEmpty()) {
.animateItem() EmptyMessageView(
.fillMaxWidth(), message = "没有书签!",
onClick = { modifier = Modifier
editingBookmark = bookmark .fillMaxSize()
showBottomSheet = true )
} else {
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize()
) {
state.bookmarks.forEach { (headerKey, bookmarks) ->
val isCollapsed = collapsedGroups.contains(headerKey.toString())
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 ?: "发生错误",
)
}
} }
} }
} }
@@ -15,14 +15,11 @@ import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@@ -39,7 +36,13 @@ data class BookmarkGroupHeader(
val bookName: String, val bookName: String,
val bookAuthor: 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( class AllBookmarkViewModel(
@@ -55,24 +58,28 @@ class AllBookmarkViewModel(
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val bookmarksState: StateFlow<Map<BookmarkGroupHeader, List<Bookmark>>> = _searchQuery val bookmarksState: StateFlow<BookmarkUiState> = _searchQuery
.debounce(300L) .debounce(300L)
.flatMapLatest { query -> .flatMapLatest { query ->
if (query.isBlank()) { val flow = if (query.isBlank()) {
bookmarkDao.flowAll() bookmarkDao.flowAll()
} else { } else {
bookmarkDao.flowSearchAll(query) 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) .flowOn(Dispatchers.IO)
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(5000), SharingStarted.WhileSubscribed(5000),
emptyMap() BookmarkUiState.Loading
) )
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) { fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
@@ -1318,7 +1318,7 @@ class ReadBookActivity : BaseReadBookActivity(),
val book = ReadBook.book ?: return val book = ReadBook.book ?: return
searchContentActivity.launch { searchContentActivity.launch {
putExtra("bookUrl", book.bookUrl) putExtra("bookUrl", book.bookUrl)
putExtra("searchWord", searchWord ?: viewModel.searchContentQuery) putExtra("searchWord", searchWord)
putExtra("searchResultIndex", viewModel.searchResultIndex) putExtra("searchResultIndex", viewModel.searchResultIndex)
viewModel.searchResultList?.first()?.let { viewModel.searchResultList?.first()?.let {
if (it.query == viewModel.searchContentQuery) { if (it.query == viewModel.searchContentQuery) {
@@ -1,8 +1,7 @@
package io.legado.app.ui.book.readRecord package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -182,128 +182,134 @@ fun ReadRecordScreen(
} }
) { padding -> ) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) { Column(modifier = Modifier.padding(padding).fillMaxSize()) {
val isEmpty = when (displayMode) { val contentState = when {
DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty() state.isLoading -> "LOADING"
DisplayMode.TIMELINE -> state.timelineRecords.isEmpty() (displayMode == DisplayMode.AGGREGATE && state.groupedRecords.isEmpty()) ||
DisplayMode.LATEST -> state.latestRecords.isEmpty() (displayMode == DisplayMode.TIMELINE && state.timelineRecords.isEmpty()) ||
(displayMode == DisplayMode.LATEST && state.latestRecords.isEmpty()) -> "EMPTY"
else -> "CONTENT"
} }
Crossfade( AnimatedContent(
targetState = isEmpty, targetState = contentState,
animationSpec = tween(durationMillis = 500), label = "MainContentAnimation"
label = "ContentCrossfade" ) { targetState ->
) { isListEmpty -> when (targetState) {
"LOADING" -> {
LazyColumn( EmptyMessageView(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.nestedScroll(scrollBehavior.nestedScrollConnection) message = "加载中"
) { )
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 { "EMPTY" -> {
if (isListEmpty){ EmptyMessageView(
Box( modifier = Modifier.fillMaxSize(),
modifier = Modifier message = "没有记录"
.fillMaxSize(), )
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "没有记录"
)
}
}
} }
when(displayMode){ "CONTENT" -> {
DisplayMode.AGGREGATE -> { LazyColumn(
state.groupedRecords.forEach { (date, details) -> modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
val dailyTotalTime = details.sumOf { it.readTime } ) {
item(key = "summary_card") {
stickyHeader { SummarySection(state, viewModel)
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 -> renderListByMode(displayMode, state, viewModel, onBookClick)
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 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()
)
} }
} }
} }
@@ -8,10 +8,14 @@ import io.legado.app.base.BaseComposeActivity
class SearchContentActivity : BaseComposeActivity() { class SearchContentActivity : BaseComposeActivity() {
private var bookUrl: String? = null private var bookUrl: String? = null
private var searchWord: String? = null
private var searchResultIndex: Int = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bookUrl = intent.getStringExtra("bookUrl") bookUrl = intent.getStringExtra("bookUrl")
searchWord = intent.getStringExtra("searchWord")
searchResultIndex = intent.getIntExtra("searchResultIndex", 0)
} }
@Composable @Composable
@@ -20,6 +24,7 @@ class SearchContentActivity : BaseComposeActivity() {
bookUrl?.let { bookUrl?.let {
SearchContentScreen( SearchContentScreen(
bookUrl = it, bookUrl = it,
searchWord = searchWord,
onBack = { finish() } onBack = { finish() }
) )
} }
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -31,9 +32,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.TopAppBarDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.text.buildAnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.AnimatedText 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.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.TextCard import io.legado.app.ui.widget.components.TextCard
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SearchContentScreen( fun SearchContentScreen(
bookUrl: String, bookUrl: String,
searchWord: String?,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SearchContentViewModel = koinViewModel() viewModel: SearchContentViewModel = koinViewModel()
) { ) {
@@ -73,8 +80,15 @@ fun SearchContentScreen(
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() 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) 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 { val contentState = when {
error != null -> SearchContentState.Error(error) error != null -> SearchContentState.Error(error)
isSearching -> SearchContentState.Loading isSearching -> SearchContentState.Loading
@@ -150,12 +161,22 @@ fun SearchContentScreen(
) { ) {
FilterChip( FilterChip(
selected = replaceEnabled, selected = replaceEnabled,
onClick = { replaceEnabled = !replaceEnabled }, onClick = {
replaceEnabled = !replaceEnabled
if (searchQuery.isNotBlank()) {
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
},
label = { Text("启用替换") } label = { Text("启用替换") }
) )
FilterChip( FilterChip(
selected = regexReplace, selected = regexReplace,
onClick = { regexReplace = !regexReplace }, onClick = {
regexReplace = !regexReplace
if (searchQuery.isNotBlank()) {
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
},
label = { Text("正则匹配") } label = { Text("正则匹配") }
) )
} }
@@ -168,8 +189,19 @@ fun SearchContentScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (isSearching) { TooltipBox(
FloatingActionButton(onClick = { viewModel.stopSearch() }) { 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 = "停止搜索") Icon(Icons.Default.Stop, contentDescription = "停止搜索")
} }
} }
@@ -206,7 +238,7 @@ fun SearchContentScreen(
SearchContentState.EmptyResult -> { SearchContentState.EmptyResult -> {
EmptyMessageView( EmptyMessageView(
message = "没有找到相关内容", message = "没有找到相关内容",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.wrapContentSize() .wrapContentSize()
@@ -323,5 +355,3 @@ fun SearchResultItem(
} }
} }
} }
@@ -12,12 +12,9 @@ import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -332,10 +331,10 @@ fun ReplaceRuleScreen(
Column { Column {
MediumTopAppBar( MediumTopAppBar(
title = { title = {
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds.size, rules.size) { val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) {
when { when {
isUploading -> "正在上传..." isUploading -> "正在上传..."
inSelectionMode -> "已选择 ${selectedRuleIds.size}/${rules.size}" inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}"
else -> "替换规则" else -> "替换规则"
} }
} }
@@ -434,8 +433,8 @@ fun ReplaceRuleScreen(
placeholder = stringResource(id = R.string.replace_purify_search) 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( PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
edgePadding = 0.dp, edgePadding = 0.dp,
@@ -470,14 +469,21 @@ fun ReplaceRuleScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( TooltipBox(
visible = !inSelectionMode, positionProvider =
enter = fadeIn() + scaleIn(), TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
exit = fadeOut() + scaleOut() tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(),
) { ) {
FloatingActionButton(onClick = { FloatingActionButton(
context.startActivity(ReplaceEditActivity.startIntent(context)) modifier = Modifier.animateFloatingActionButton(
}) { visible = !inSelectionMode,
alignment = Alignment.BottomEnd,
),
onClick = {
context.startActivity(ReplaceEditActivity.startIntent(context))
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Rule") Icon(Icons.Default.Add, contentDescription = "Add Rule")
} }
} }
@@ -568,9 +574,8 @@ fun ReplaceRuleScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = inSelectionMode, visible = inSelectionMode,
modifier = Modifier modifier =
.align(Alignment.BottomCenter) Modifier.align(Alignment.BottomCenter).offset(y = -ScreenOffset).zIndex(1f),
.padding(bottom = 24.dp),
enter = slideInVertically { it } + fadeIn(), enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut() exit = slideOutVertically { it } + fadeOut()
) { ) {
@@ -842,13 +847,9 @@ fun ReplaceRuleItem(
) )
Card( Card(
onClick = { onToggleSelection() },
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth(),
.combinedClickable(
onClick = { onToggleSelection() },
interactionSource = remember { MutableInteractionSource() },
indication = null
),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = containerColor containerColor = containerColor
@@ -900,26 +901,26 @@ fun ReplaceRuleItem(
onDismissRequest = { showRuleMenu = false } onDismissRequest = { showRuleMenu = false }
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("删除") }, text = { Text("移至顶部") },
onClick = {
onDelete()
showRuleMenu = false
}
)
DropdownMenuItem(
text = { Text("置顶") },
onClick = { onClick = {
onToTop() onToTop()
showRuleMenu = false showRuleMenu = false
} }
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("置底") }, text = { Text("移至底部") },
onClick = { onClick = {
onToBottom() onToBottom()
showRuleMenu = false showRuleMenu = false
} }
) )
DropdownMenuItem(
text = { Text("删除") },
onClick = {
onDelete()
showRuleMenu = false
}
)
} }
} }
} }
@@ -1,6 +1,7 @@
package io.legado.app.ui.widget.components package io.legado.app.ui.widget.components
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun EmptyMessageView( fun EmptyMessageView(
message: String, message: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isLoading: Boolean = false,
faces: List<String> = listOf( faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -40,16 +45,25 @@ fun EmptyMessageView(
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
AnimatedTextLine( AnimatedContent(
text = currentFace, targetState = isLoading,
fontSize = faceTextSize, label = "LoadingStateAnimation"
textAlign = TextAlign.Center, ) { loading ->
modifier = Modifier if (loading) {
.clickable { LoadingIndicator()
currentFace = faces.random() } else {
onFaceClick?.invoke() AnimatedTextLine(
} text = currentFace,
) fontSize = faceTextSize,
textAlign = TextAlign.Center,
modifier = Modifier
.clickable {
currentFace = faces.random()
onFaceClick?.invoke()
}
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -67,6 +81,7 @@ fun EmptyMessageView(
fun EmptyMessageView( fun EmptyMessageView(
@StringRes messageResId: Int, @StringRes messageResId: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isLoading: Boolean = false,
faces: List<String> = listOf( faces: List<String> = listOf(
"(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)", "(;′⌒`)", "(つ﹏⊂)", "(•̀ᴗ•́)و", "(๑•́ ₃ •̀๑)",
"(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)" "(눈‸눈)", "(ಥ﹏ಥ)", "(。•́︿•̀。)"
@@ -78,6 +93,7 @@ fun EmptyMessageView(
EmptyMessageView( EmptyMessageView(
message = message, message = message,
modifier = modifier, modifier = modifier,
isLoading = isLoading,
faces = faces, faces = faces,
faceTextSize = faceTextSize, faceTextSize = faceTextSize,
onFaceClick = onFaceClick onFaceClick = onFaceClick
@@ -103,7 +103,7 @@ fun SourceInputDialog(
} }
}, },
confirmButton = { confirmButton = {
TextButton( OutlinedButton(
onClick = { if (text.isNotBlank()) onConfirm(text) } onClick = { if (text.isNotBlank()) onConfirm(text) }
) { Text(stringResource(android.R.string.ok)) } ) { Text(stringResource(android.R.string.ok)) }
}, },