diff --git a/app/src/main/java/io/legado/app/App.kt b/app/src/main/java/io/legado/app/App.kt index edce813d5..8620ace02 100644 --- a/app/src/main/java/io/legado/app/App.kt +++ b/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") diff --git a/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt b/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt index 7940139ff..b8bf62d2b 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt +++ b/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 + // 模糊搜索 + @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> + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg bookmark: Bookmark) diff --git a/app/src/main/java/io/legado/app/di/bookmarkModule.kt b/app/src/main/java/io/legado/app/di/bookmarkModule.kt new file mode 100644 index 000000000..2902fd179 --- /dev/null +++ b/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() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt index 51c2f5b40..3c843f77e 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt +++ b/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(), - BookmarkAdapter.Callback { - - override val viewModel by viewModels() - 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)) - } - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt new file mode 100644 index 000000000..9a3d4d5c7 --- /dev/null +++ b/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(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("取消") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt index b2557d3b6..ae1bb4635 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt +++ b/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>(emptySet()) + val collapsedGroups = _collapsedGroups.asStateFlow() + + private val refreshTrigger = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) // FlowPreview 用于 debounce + val bookmarksState: StateFlow>> = 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) { + 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() + 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) { + 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()) + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index 1bbef2630..90e6b298c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/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( ) ) } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90af8e6c4..9b7b4edb7 100644 --- a/gradle/libs.versions.toml +++ b/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"]