[新增] 由Compose重写的书签界面,现在可以进行模糊搜索书签,可以展开与折叠书籍
This commit is contained in:
@@ -33,6 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule
|
||||
import io.legado.app.data.entities.rule.ExploreRule
|
||||
import io.legado.app.data.entities.rule.SearchRule
|
||||
import io.legado.app.di.appDatabaseModule
|
||||
import io.legado.app.di.bookmarkModule
|
||||
import io.legado.app.di.exploreModule
|
||||
import io.legado.app.di.readRecordModule
|
||||
import io.legado.app.help.AppFreezeMonitor
|
||||
@@ -63,6 +64,7 @@ import io.legado.app.utils.getPrefString
|
||||
import io.legado.app.utils.isDebuggable
|
||||
import kotlinx.coroutines.launch
|
||||
import org.chromium.base.ThreadUtils
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.GlobalContext.startKoin
|
||||
import splitties.init.appCtx
|
||||
import splitties.systemservices.notificationManager
|
||||
@@ -77,7 +79,8 @@ class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
startKoin {
|
||||
modules(appDatabaseModule, readRecordModule, exploreModule)
|
||||
androidContext(this@App)
|
||||
modules(appDatabaseModule, readRecordModule, exploreModule, bookmarkModule)
|
||||
}
|
||||
if (getPrefString("app_theme", "0") == "12") {
|
||||
if (AppConfig.customMode == "accent")
|
||||
|
||||
@@ -48,6 +48,17 @@ interface BookmarkDao {
|
||||
)
|
||||
fun search(bookName: String, bookAuthor: String, key: String): List<Bookmark>
|
||||
|
||||
// 模糊搜索
|
||||
@Query("""
|
||||
SELECT * FROM bookmarks
|
||||
WHERE (bookName LIKE '%'||:query||'%'
|
||||
OR bookText LIKE '%'||:query||'%'
|
||||
OR chapterName LIKE '%'||:query||'%'
|
||||
OR content LIKE '%'||:query||'%')
|
||||
ORDER BY bookName COLLATE LOCALIZED, bookAuthor COLLATE LOCALIZED, chapterIndex, chapterPos
|
||||
""")
|
||||
fun flowSearchAll(query: String): Flow<List<Bookmark>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(vararg bookmark: Bookmark)
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val bookmarkModule = module {
|
||||
|
||||
viewModel {
|
||||
AllBookmarkViewModel(
|
||||
androidApplication(),
|
||||
get()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,24 @@
|
||||
package io.legado.app.ui.book.bookmark
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.VMBaseActivity
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Bookmark
|
||||
import io.legado.app.databinding.ActivityAllBookmarkBinding
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.utils.applyNavigationBarPadding
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
|
||||
/**
|
||||
* 所有书签
|
||||
*/
|
||||
class AllBookmarkActivity : VMBaseActivity<ActivityAllBookmarkBinding, AllBookmarkViewModel>(),
|
||||
BookmarkAdapter.Callback {
|
||||
|
||||
override val viewModel by viewModels<AllBookmarkViewModel>()
|
||||
override val binding by viewBinding(ActivityAllBookmarkBinding::inflate)
|
||||
private val adapter by lazy {
|
||||
BookmarkAdapter(this, this)
|
||||
}
|
||||
private val exportDir = registerForActivityResult(HandleFileContract()) {
|
||||
it.uri?.let { uri ->
|
||||
when (it.requestCode) {
|
||||
1 -> viewModel.exportBookmark(uri)
|
||||
2 -> viewModel.exportBookmarkMd(uri)
|
||||
}
|
||||
class AllBookmarkActivity : BaseComposeActivity() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
MaterialTheme {
|
||||
AllBookmarkScreen(
|
||||
onBack = { finish() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initView()
|
||||
lifecycleScope.launch {
|
||||
appDb.bookmarkDao.flowAll().catch {
|
||||
AppLog.put("所有书签界面获取数据失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO).collect {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
setSupportActionBar(binding.topBar)
|
||||
binding.recyclerView.addItemDecoration(BookmarkDecoration(this,adapter))
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.applyNavigationBarPadding()
|
||||
}
|
||||
|
||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.bookmark, menu)
|
||||
return super.onCompatCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_export -> exportDir.launch {
|
||||
requestCode = 1
|
||||
}
|
||||
|
||||
R.id.menu_export_md -> exportDir.launch {
|
||||
requestCode = 2
|
||||
}
|
||||
}
|
||||
return super.onCompatOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onItemClick(bookmark: Bookmark, position: Int) {
|
||||
showDialogFragment(BookmarkDialog(bookmark, position))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package io.legado.app.ui.book.bookmark
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.UnfoldLess
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.AnimatedTextLine
|
||||
import io.legado.app.ui.widget.components.SearchBarSection
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AllBookmarkScreen(
|
||||
viewModel: AllBookmarkViewModel = koinViewModel(),
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val searchText by viewModel.searchQuery.collectAsState()
|
||||
val bookmarksGrouped by viewModel.bookmarksState.collectAsState()
|
||||
val collapsedGroups by viewModel.collapsedGroups.collectAsState()
|
||||
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var pendingExportIsMd by remember { mutableStateOf(false) }
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
val allKeys = bookmarksGrouped.keys
|
||||
val isAllCollapsed = allKeys.isNotEmpty() && collapsedGroups.containsAll(allKeys)
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
viewModel.exportBookmark(it, pendingExportIsMd)
|
||||
Toast.makeText(context, "开始导出...", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
val text = if (showSearch) "搜索" else "所有书签"
|
||||
AnimatedTextLine(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (bookmarksGrouped.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.toggleAllCollapse(allKeys) }) {
|
||||
Icon(
|
||||
imageVector = if (isAllCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
showSearch = !showSearch
|
||||
if (!showSearch) {
|
||||
viewModel.onSearchQueryChanged("")
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("导出 JSON") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
pendingExportIsMd = false
|
||||
exportLauncher.launch(null)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("导出 Markdown") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
pendingExportIsMd = true
|
||||
exportLauncher.launch(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
SearchBarSection(
|
||||
query = searchText,
|
||||
onQueryChange = { viewModel.onSearchQueryChanged(it) },
|
||||
placeholder = "模糊搜索"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
bookmarksGrouped.forEach { (headerTitle, bookmarks) ->
|
||||
|
||||
val isCollapsed = collapsedGroups.contains(headerTitle)
|
||||
|
||||
item(key = headerTitle) {
|
||||
Column {
|
||||
BookmarkHeader(
|
||||
text = headerTitle,
|
||||
isCollapsed = isCollapsed,
|
||||
onToggle = { viewModel.toggleGroupCollapse(headerTitle) }
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet && editingBookmark != null) {
|
||||
BookmarkEditSheet(
|
||||
bookmark = editingBookmark!!,
|
||||
onDismiss = {
|
||||
showBottomSheet = false
|
||||
editingBookmark = null
|
||||
},
|
||||
onSave = { updatedBookmark ->
|
||||
viewModel.updateBookmark(updatedBookmark)
|
||||
showBottomSheet = false
|
||||
},
|
||||
onDelete = { bookmarkToDelete ->
|
||||
viewModel.deleteBookmark(bookmarkToDelete)
|
||||
showBottomSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkHeader(
|
||||
text: String,
|
||||
isCollapsed: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 标题
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (isCollapsed) 0f else 180f,
|
||||
label = "arrowRotation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(rotation),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkItem(
|
||||
bookmark: Bookmark,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = bookmark.chapterName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
if (bookmark.bookText.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = bookmark.bookText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (bookmark.content.isNotEmpty()) {
|
||||
Text(
|
||||
text = bookmark.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BookmarkEditSheet(
|
||||
bookmark: Bookmark,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Bookmark) -> Unit,
|
||||
onDelete: (Bookmark) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
var bookText by remember { mutableStateOf(bookmark.bookText) }
|
||||
var content by remember { mutableStateOf(bookmark.content) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Text(
|
||||
text = bookmark.chapterName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bookText,
|
||||
onValueChange = { bookText = it },
|
||||
label = { Text("原文") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 10
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
label = { Text("摘要/笔记") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteConfirmDialog = true },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val newBookmark = bookmark.apply {
|
||||
this.bookText = bookText
|
||||
this.content = content
|
||||
}
|
||||
onSave(newBookmark)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("保存")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirmDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmDialog = false },
|
||||
title = { Text("确认删除") },
|
||||
text = { Text("你确定要删除这条书签吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmDialog = false
|
||||
onDelete(bookmark)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmDialog = false }
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,66 +2,165 @@ package io.legado.app.ui.book.bookmark
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.appDb
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.data.dao.BookmarkDao
|
||||
import io.legado.app.data.entities.Bookmark
|
||||
import io.legado.app.utils.FileDoc
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.createFileIfNotExist
|
||||
import io.legado.app.utils.openOutputStream
|
||||
import io.legado.app.utils.toastOnUi
|
||||
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
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AllBookmarkViewModel(application: Application) : BaseViewModel(application) {
|
||||
class AllBookmarkViewModel(
|
||||
application: Application,
|
||||
private val bookmarkDao: BookmarkDao
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery = _searchQuery.asStateFlow()
|
||||
|
||||
/**
|
||||
* 导出书签
|
||||
*/
|
||||
fun exportBookmark(treeUri: Uri) {
|
||||
execute {
|
||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
||||
val fileName = "bookmark-${dateFormat.format(Date())}.json"
|
||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
||||
dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow().use {
|
||||
GSON.writeToOutputStream(it, appDb.bookmarkDao.all)
|
||||
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
||||
val collapsedGroups = _collapsedGroups.asStateFlow()
|
||||
|
||||
private val refreshTrigger = MutableSharedFlow<Unit>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) // FlowPreview 用于 debounce
|
||||
val bookmarksState: StateFlow<Map<String, List<Bookmark>>> = combine(
|
||||
refreshTrigger.onStart { emit(Unit) },
|
||||
_searchQuery
|
||||
) { _, query -> query }
|
||||
.debounce(300L)
|
||||
.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
bookmarkDao.flowAll()
|
||||
} else {
|
||||
bookmarkDao.flowSearchAll(query)
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
|
||||
}.onSuccess {
|
||||
context.toastOnUi("导出成功")
|
||||
}
|
||||
.map { list ->
|
||||
list.groupBy { "${it.bookName}(${it.bookAuthor})" }
|
||||
}
|
||||
.catch { e -> e.printStackTrace() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
emptyMap()
|
||||
)
|
||||
|
||||
private fun refreshBookmarks() {
|
||||
viewModelScope.launch {
|
||||
refreshTrigger.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleGroupCollapse(groupKey: String) {
|
||||
val current = _collapsedGroups.value
|
||||
if (current.contains(groupKey)) {
|
||||
_collapsedGroups.value = current - groupKey
|
||||
} else {
|
||||
_collapsedGroups.value = current + groupKey // 折叠
|
||||
}
|
||||
}
|
||||
|
||||
fun exportBookmarkMd(treeUri: Uri) {
|
||||
execute {
|
||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
||||
val fileName = "bookmark-${dateFormat.format(Date())}.md"
|
||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
||||
val fileDoc = dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow()
|
||||
fileDoc.use { outputStream ->
|
||||
var name = ""
|
||||
var author = ""
|
||||
appDb.bookmarkDao.all.forEach {
|
||||
if (it.bookName != name && it.bookAuthor != author) {
|
||||
name = it.bookName
|
||||
author = it.bookAuthor
|
||||
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
|
||||
fun toggleAllCollapse(currentKeys: Set<String>) {
|
||||
val currentCollapsed = _collapsedGroups.value
|
||||
if (currentCollapsed.containsAll(currentKeys) && currentKeys.isNotEmpty()) {
|
||||
_collapsedGroups.value = emptySet()
|
||||
} else {
|
||||
_collapsedGroups.value = currentKeys
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun updateBookmark(bookmark: Bookmark) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
bookmarkDao.insert(bookmark)
|
||||
}
|
||||
refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBookmark(bookmark: Bookmark) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
bookmarkDao.delete(bookmark)
|
||||
}
|
||||
refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
fun exportBookmark(treeUri: Uri, isMarkdown: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
||||
val suffix = if (isMarkdown) "md" else "json"
|
||||
val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
|
||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
||||
val fileDoc = dirDoc.createFileIfNotExist(fileName)
|
||||
|
||||
fileDoc.openOutputStream().getOrThrow().use { outputStream ->
|
||||
if (isMarkdown) {
|
||||
writeMarkdown(outputStream, bookmarkDao.all)
|
||||
} else {
|
||||
GSON.writeToOutputStream(outputStream, bookmarkDao.all)
|
||||
}
|
||||
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
|
||||
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
|
||||
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
context.toastOnUi("导出成功")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}.onError {
|
||||
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
|
||||
}.onSuccess {
|
||||
context.toastOnUi("导出成功")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeMarkdown(outputStream: java.io.OutputStream, bookmarks: List<Bookmark>) {
|
||||
var name = ""
|
||||
var author = ""
|
||||
bookmarks.forEach {
|
||||
if (it.bookName != name && it.bookAuthor != author) {
|
||||
name = it.bookName
|
||||
author = it.bookAuthor
|
||||
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
|
||||
}
|
||||
outputStream.write("#### ${it.chapterName}\n\n".toByteArray())
|
||||
outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray())
|
||||
outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.legado.app.ui.widget.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -16,7 +15,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,7 +23,8 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
fun SearchBarSection(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String = "搜索书名"
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -38,14 +37,12 @@ fun SearchBarSection(
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("搜索书名…") },
|
||||
placeholder = { Text(placeholder) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||
trailingIcon = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = null)
|
||||
}
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, null)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -59,4 +56,4 @@ fun SearchBarSection(
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user