Просмотр исходного кода

[新增] 由Compose重写的书签界面,现在可以进行模糊搜索书签,可以展开与折叠书籍

HapeLee 11 месяцев назад
Родитель
Сommit
e8ebfa2c0e

+ 4 - 1
app/src/main/java/io/legado/app/App.kt

@@ -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")

+ 11 - 0
app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt

@@ -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)
 

+ 16 - 0
app/src/main/java/io/legado/app/di/bookmarkModule.kt

@@ -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()
+        )
+    }
+}

+ 10 - 70
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt

@@ -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))
-    }
-
 }

+ 394 - 0
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -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("取消")
+                }
+            }
+        )
+    }
+}

+ 138 - 39
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -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 toggleAllCollapse(currentKeys: Set<String>) {
+        val currentCollapsed = _collapsedGroups.value
+        if (currentCollapsed.containsAll(currentKeys) && currentKeys.isNotEmpty()) {
+            _collapsedGroups.value = emptySet()
+        } else {
+            _collapsedGroups.value = currentKeys
+        }
+    }
 
-    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 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())
+        }
+    }
 }

+ 7 - 10
app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt

@@ -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(
             )
         )
     }
-}
+}

+ 4 - 0
gradle/libs.versions.toml

@@ -2,6 +2,7 @@
 
 accompanistWebview = "0.36.0"
 animation = "1.9.4"
+biometric = "1.2.0"
 coilCompose = "2.7.0"
 composeBom = "2025.11.01"
 constraintlayoutCompose = "1.1.1"
@@ -71,6 +72,7 @@ collection = "1.5.0"
 zxingLite = "3.3.0"
 
 koin-bom = "4.1.1"
+biometricKtx = "1.1.0"
 [libraries]
 
 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-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-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" }
 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" }
 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" }
+androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" }
 
 [bundles]
 coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]