[新增] 由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.ExploreRule
|
||||||
import io.legado.app.data.entities.rule.SearchRule
|
import io.legado.app.data.entities.rule.SearchRule
|
||||||
import io.legado.app.di.appDatabaseModule
|
import io.legado.app.di.appDatabaseModule
|
||||||
|
import io.legado.app.di.bookmarkModule
|
||||||
import io.legado.app.di.exploreModule
|
import io.legado.app.di.exploreModule
|
||||||
import io.legado.app.di.readRecordModule
|
import io.legado.app.di.readRecordModule
|
||||||
import io.legado.app.help.AppFreezeMonitor
|
import io.legado.app.help.AppFreezeMonitor
|
||||||
@@ -63,6 +64,7 @@ import io.legado.app.utils.getPrefString
|
|||||||
import io.legado.app.utils.isDebuggable
|
import io.legado.app.utils.isDebuggable
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.chromium.base.ThreadUtils
|
import org.chromium.base.ThreadUtils
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.GlobalContext.startKoin
|
import org.koin.core.context.GlobalContext.startKoin
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import splitties.systemservices.notificationManager
|
import splitties.systemservices.notificationManager
|
||||||
@@ -77,7 +79,8 @@ class App : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
startKoin {
|
startKoin {
|
||||||
modules(appDatabaseModule, readRecordModule, exploreModule)
|
androidContext(this@App)
|
||||||
|
modules(appDatabaseModule, readRecordModule, exploreModule, bookmarkModule)
|
||||||
}
|
}
|
||||||
if (getPrefString("app_theme", "0") == "12") {
|
if (getPrefString("app_theme", "0") == "12") {
|
||||||
if (AppConfig.customMode == "accent")
|
if (AppConfig.customMode == "accent")
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ interface BookmarkDao {
|
|||||||
)
|
)
|
||||||
fun search(bookName: String, bookAuthor: String, key: String): List<Bookmark>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insert(vararg bookmark: Bookmark)
|
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
|
package io.legado.app.ui.book.bookmark
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.compose.material3.MaterialTheme
|
||||||
import android.view.MenuItem
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.activity.viewModels
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 所有书签
|
* 所有书签
|
||||||
*/
|
*/
|
||||||
class AllBookmarkActivity : VMBaseActivity<ActivityAllBookmarkBinding, AllBookmarkViewModel>(),
|
class AllBookmarkActivity : BaseComposeActivity() {
|
||||||
BookmarkAdapter.Callback {
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
override val viewModel by viewModels<AllBookmarkViewModel>()
|
MaterialTheme {
|
||||||
override val binding by viewBinding(ActivityAllBookmarkBinding::inflate)
|
AllBookmarkScreen(
|
||||||
private val adapter by lazy {
|
onBack = { finish() }
|
||||||
BookmarkAdapter(this, this)
|
)
|
||||||
}
|
|
||||||
private val exportDir = registerForActivityResult(HandleFileContract()) {
|
|
||||||
it.uri?.let { uri ->
|
|
||||||
when (it.requestCode) {
|
|
||||||
1 -> viewModel.exportBookmark(uri)
|
|
||||||
2 -> viewModel.exportBookmarkMd(uri)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import io.legado.app.base.BaseViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import io.legado.app.constant.AppLog
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.data.appDb
|
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.FileDoc
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.utils.GSON
|
||||||
import io.legado.app.utils.createFileIfNotExist
|
import io.legado.app.utils.createFileIfNotExist
|
||||||
import io.legado.app.utils.openOutputStream
|
import io.legado.app.utils.openOutputStream
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import io.legado.app.utils.writeToOutputStream
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
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()
|
||||||
|
|
||||||
/**
|
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
|
||||||
* 导出书签
|
val collapsedGroups = _collapsedGroups.asStateFlow()
|
||||||
*/
|
|
||||||
fun exportBookmark(treeUri: Uri) {
|
private val refreshTrigger = MutableSharedFlow<Unit>(
|
||||||
execute {
|
replay = 1,
|
||||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
val fileName = "bookmark-${dateFormat.format(Date())}.json"
|
)
|
||||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
|
||||||
dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow().use {
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) // FlowPreview 用于 debounce
|
||||||
GSON.writeToOutputStream(it, appDb.bookmarkDao.all)
|
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)
|
.map { list ->
|
||||||
}.onSuccess {
|
list.groupBy { "${it.bookName}(${it.bookAuthor})" }
|
||||||
context.toastOnUi("导出成功")
|
}
|
||||||
|
.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) {
|
fun toggleAllCollapse(currentKeys: Set<String>) {
|
||||||
execute {
|
val currentCollapsed = _collapsedGroups.value
|
||||||
val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
|
if (currentCollapsed.containsAll(currentKeys) && currentKeys.isNotEmpty()) {
|
||||||
val fileName = "bookmark-${dateFormat.format(Date())}.md"
|
_collapsedGroups.value = emptySet()
|
||||||
val dirDoc = FileDoc.fromUri(treeUri, true)
|
} else {
|
||||||
val fileDoc = dirDoc.createFileIfNotExist(fileName).openOutputStream().getOrThrow()
|
_collapsedGroups.value = currentKeys
|
||||||
fileDoc.use { outputStream ->
|
}
|
||||||
var name = ""
|
}
|
||||||
var author = ""
|
|
||||||
appDb.bookmarkDao.all.forEach {
|
fun onSearchQueryChanged(query: String) {
|
||||||
if (it.bookName != name && it.bookAuthor != author) {
|
_searchQuery.value = query
|
||||||
name = it.bookName
|
}
|
||||||
author = it.bookAuthor
|
|
||||||
outputStream.write("## ${it.bookName} ${it.bookAuthor}\n\n".toByteArray())
|
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
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -16,7 +15,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -25,7 +23,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
@Composable
|
@Composable
|
||||||
fun SearchBarSection(
|
fun SearchBarSection(
|
||||||
query: String,
|
query: String,
|
||||||
onQueryChange: (String) -> Unit
|
onQueryChange: (String) -> Unit,
|
||||||
|
placeholder: String = "搜索书名"
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -38,14 +37,12 @@ fun SearchBarSection(
|
|||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text("搜索书名…") },
|
placeholder = { Text(placeholder) },
|
||||||
leadingIcon = { Icon(Icons.Default.Search, null) },
|
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
if (query.isNotEmpty()) {
|
||||||
if (query.isNotEmpty()) {
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
IconButton(onClick = { onQueryChange("") }) {
|
Icon(Icons.Default.Clear, null)
|
||||||
Icon(Icons.Default.Clear, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -59,4 +56,4 @@ fun SearchBarSection(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
accompanistWebview = "0.36.0"
|
accompanistWebview = "0.36.0"
|
||||||
animation = "1.9.4"
|
animation = "1.9.4"
|
||||||
|
biometric = "1.2.0"
|
||||||
coilCompose = "2.7.0"
|
coilCompose = "2.7.0"
|
||||||
composeBom = "2025.11.01"
|
composeBom = "2025.11.01"
|
||||||
constraintlayoutCompose = "1.1.1"
|
constraintlayoutCompose = "1.1.1"
|
||||||
@@ -71,6 +72,7 @@ collection = "1.5.0"
|
|||||||
zxingLite = "3.3.0"
|
zxingLite = "3.3.0"
|
||||||
|
|
||||||
koin-bom = "4.1.1"
|
koin-bom = "4.1.1"
|
||||||
|
biometricKtx = "1.1.0"
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|
||||||
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
|
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" }
|
||||||
@@ -82,6 +84,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose" }
|
|||||||
androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.9.1" }
|
androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.9.1" }
|
||||||
#androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version = "1.3.1" }
|
#androidx-annotation-experimental = { group = "androidx.annotation", name = "annotation-experimental", version = "1.3.1" }
|
||||||
|
|
||||||
|
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
|
||||||
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
|
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
|
||||||
androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
|
androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
@@ -211,6 +214,7 @@ material = { module = "com.google.android.material:material", version.ref = "mat
|
|||||||
mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
|
||||||
renderscript-intrinsics-replacement-toolkit = { module = "com.github.TomasValenta:renderscript-intrinsics-replacement-toolkit", version = "8eaa829ddd" }
|
renderscript-intrinsics-replacement-toolkit = { module = "com.github.TomasValenta:renderscript-intrinsics-replacement-toolkit", version = "8eaa829ddd" }
|
||||||
zxing-lite = { module = "com.github.jenly1314:zxing-lite", version.ref = "zxingLite" }
|
zxing-lite = { module = "com.github.jenly1314:zxing-lite", version.ref = "zxingLite" }
|
||||||
|
androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
||||||
|
|||||||
Reference in New Issue
Block a user