[优化] 优化书签显示
This commit is contained in:
4 files changed
+105
-50
No files matched your search
@@ -34,7 +34,6 @@ 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.AnimatedTextLine
|
|
||||||
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
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ fun AllBookmarkScreen(
|
|||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
|
||||||
val allKeys = bookmarksGrouped.keys
|
val allKeys = bookmarksGrouped.keys
|
||||||
val isAllCollapsed = allKeys.isNotEmpty() && collapsedGroups.containsAll(allKeys)
|
val isAllCollapsed = allKeys.isNotEmpty() && allKeys.all { collapsedGroups.contains(it.toString()) }
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
@@ -74,9 +73,8 @@ fun AllBookmarkScreen(
|
|||||||
Column {
|
Column {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
val text = if (showSearch) "搜索" else "所有书签"
|
Text(
|
||||||
AnimatedTextLine(
|
text = "所有书签",
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -149,17 +147,20 @@ fun AllBookmarkScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
bookmarksGrouped.forEach { (headerTitle, bookmarks) ->
|
bookmarksGrouped.forEach { (headerKey, bookmarks) ->
|
||||||
|
|
||||||
val isCollapsed = collapsedGroups.contains(headerTitle)
|
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
||||||
|
|
||||||
item(key = headerTitle) {
|
stickyHeader(key = headerKey.toString()) {
|
||||||
Column {
|
BookAuthorHeader(
|
||||||
BookmarkHeader(
|
bookTitle = headerKey.bookName,
|
||||||
text = headerTitle,
|
bookAuthor = headerKey.bookAuthor,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
onToggle = { viewModel.toggleGroupCollapse(headerTitle) }
|
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "content_${headerKey}") {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !isCollapsed,
|
visible = !isCollapsed,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
@@ -170,7 +171,6 @@ fun AllBookmarkScreen(
|
|||||||
.animateContentSize()
|
.animateContentSize()
|
||||||
) {
|
) {
|
||||||
bookmarks.forEach { bookmark ->
|
bookmarks.forEach { bookmark ->
|
||||||
|
|
||||||
BookmarkItem(
|
BookmarkItem(
|
||||||
bookmark = bookmark,
|
bookmark = bookmark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -187,7 +187,6 @@ fun AllBookmarkScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (showBottomSheet && editingBookmark != null) {
|
if (showBottomSheet && editingBookmark != null) {
|
||||||
BookmarkEditSheet(
|
BookmarkEditSheet(
|
||||||
@@ -210,8 +209,9 @@ fun AllBookmarkScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BookmarkHeader(
|
fun BookAuthorHeader(
|
||||||
text: String,
|
bookTitle: String,
|
||||||
|
bookAuthor: String,
|
||||||
isCollapsed: Boolean,
|
isCollapsed: Boolean,
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -226,16 +226,31 @@ fun BookmarkHeader(
|
|||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 标题
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = bookTitle,
|
||||||
style = MaterialTheme.typography.titleSmall.copy(
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
),
|
),
|
||||||
modifier = Modifier.weight(1f)
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = bookAuthor,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val rotation by animateFloatAsState(
|
val rotation by animateFloatAsState(
|
||||||
targetValue = if (isCollapsed) 0f else 180f,
|
targetValue = if (isCollapsed) 0f else 180f,
|
||||||
label = "arrowRotation"
|
label = "arrowRotation"
|
||||||
@@ -243,7 +258,7 @@ fun BookmarkHeader(
|
|||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.KeyboardArrowDown,
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
contentDescription = null,
|
contentDescription = if (isCollapsed) "展开书签" else "折叠书签",
|
||||||
modifier = Modifier.rotate(rotation),
|
modifier = Modifier.rotate(rotation),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class BookmarkGroupHeader(
|
||||||
|
val bookName: String,
|
||||||
|
val bookAuthor: String
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "$bookName|${bookAuthor}"
|
||||||
|
}
|
||||||
|
|
||||||
class AllBookmarkViewModel(
|
class AllBookmarkViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val bookmarkDao: BookmarkDao
|
private val bookmarkDao: BookmarkDao
|
||||||
@@ -46,16 +53,9 @@ class AllBookmarkViewModel(
|
|||||||
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val collapsedGroups = _collapsedGroups.asStateFlow()
|
val collapsedGroups = _collapsedGroups.asStateFlow()
|
||||||
|
|
||||||
private val refreshTrigger = MutableSharedFlow<Unit>(
|
|
||||||
replay = 1,
|
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) // FlowPreview 用于 debounce
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
val bookmarksState: StateFlow<Map<String, List<Bookmark>>> = combine(
|
val bookmarksState: StateFlow<Map<BookmarkGroupHeader, List<Bookmark>>> = _searchQuery
|
||||||
refreshTrigger.onStart { emit(Unit) },
|
|
||||||
_searchQuery
|
|
||||||
) { _, query -> query }
|
|
||||||
.debounce(300L)
|
.debounce(300L)
|
||||||
.flatMapLatest { query ->
|
.flatMapLatest { query ->
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
@@ -65,7 +65,7 @@ class AllBookmarkViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { list ->
|
.map { list ->
|
||||||
list.groupBy { "${it.bookName}(${it.bookAuthor})" }
|
list.groupBy { BookmarkGroupHeader(it.bookName, it.bookAuthor) }
|
||||||
}
|
}
|
||||||
.catch { e -> e.printStackTrace() }
|
.catch { e -> e.printStackTrace() }
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
@@ -75,27 +75,23 @@ class AllBookmarkViewModel(
|
|||||||
emptyMap()
|
emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun refreshBookmarks() {
|
fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
|
||||||
viewModelScope.launch {
|
val stringKey = groupKey.toString()
|
||||||
refreshTrigger.emit(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleGroupCollapse(groupKey: String) {
|
|
||||||
val current = _collapsedGroups.value
|
val current = _collapsedGroups.value
|
||||||
if (current.contains(groupKey)) {
|
if (current.contains(stringKey)) {
|
||||||
_collapsedGroups.value = current - groupKey
|
_collapsedGroups.value = current - stringKey
|
||||||
} else {
|
} else {
|
||||||
_collapsedGroups.value = current + groupKey // 折叠
|
_collapsedGroups.value = current + stringKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleAllCollapse(currentKeys: Set<String>) {
|
fun toggleAllCollapse(currentKeys: Set<BookmarkGroupHeader>) {
|
||||||
|
val stringKeys = currentKeys.map { it.toString() }.toSet()
|
||||||
val currentCollapsed = _collapsedGroups.value
|
val currentCollapsed = _collapsedGroups.value
|
||||||
if (currentCollapsed.containsAll(currentKeys) && currentKeys.isNotEmpty()) {
|
if (currentCollapsed.containsAll(stringKeys) && currentKeys.isNotEmpty()) {
|
||||||
_collapsedGroups.value = emptySet()
|
_collapsedGroups.value = emptySet()
|
||||||
} else {
|
} else {
|
||||||
_collapsedGroups.value = currentKeys
|
_collapsedGroups.value = stringKeys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +104,6 @@ class AllBookmarkViewModel(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
bookmarkDao.insert(bookmark)
|
bookmarkDao.insert(bookmark)
|
||||||
}
|
}
|
||||||
refreshBookmarks()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +112,6 @@ class AllBookmarkViewModel(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
bookmarkDao.delete(bookmark)
|
bookmarkDao.delete(bookmark)
|
||||||
}
|
}
|
||||||
refreshBookmarks()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import io.legado.app.ui.widget.components.Calendar
|
|||||||
import io.legado.app.ui.widget.components.Cover
|
import io.legado.app.ui.widget.components.Cover
|
||||||
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.SectionHeader
|
||||||
import io.legado.app.utils.StringUtils.formatFriendlyDate
|
import io.legado.app.utils.StringUtils.formatFriendlyDate
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -494,10 +495,9 @@ fun DateHeader(
|
|||||||
dailyTotalTime: Long? = null
|
dailyTotalTime: Long? = null
|
||||||
) {
|
) {
|
||||||
val dateText = formatFriendlyDate(date)
|
val dateText = formatFriendlyDate(date)
|
||||||
Surface(
|
SectionHeader(
|
||||||
modifier = Modifier.fillMaxWidth()
|
titleContent = {
|
||||||
) {
|
Column {
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = dateText,
|
text = dateText,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -513,7 +513,10 @@ fun DateHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
detailContent = null,
|
||||||
|
horizontalArrangement = Arrangement.Start
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionHeader(
|
||||||
|
// 左侧主标题内容
|
||||||
|
titleContent: @Composable () -> Unit,
|
||||||
|
// 右侧或副标题内容
|
||||||
|
detailContent: @Composable (() -> Unit)? = null,
|
||||||
|
// 主要内容的排列方式
|
||||||
|
verticalArrangement: Arrangement.Vertical = Arrangement.Center,
|
||||||
|
// 定义水平对齐方式
|
||||||
|
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
|
||||||
|
// 根容器的 Padding
|
||||||
|
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(contentPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = horizontalArrangement
|
||||||
|
) {
|
||||||
|
titleContent()
|
||||||
|
detailContent?.let {
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user