From 9e8ab55e02f5b242f6ae40615750c3c04b9cfc51 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Fri, 19 Dec 2025 01:59:46 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E7=94=A8Compose=E9=87=8D=E5=86=99=E4=BA=86=E4=B9=A6=E7=B1=8D?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=BC=96=E8=BE=91=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/book/info/edit/BookInfoEditActivity.kt | 193 ++------------ .../ui/book/info/edit/BookInfoEditScreen.kt | 251 ++++++++++++++++++ .../book/info/edit/BookInfoEditViewModel.kt | 93 ++++++- 3 files changed, 360 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt index da1035d71..49bc1a852 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt @@ -1,192 +1,47 @@ package io.legado.app.ui.book.info.edit -import android.net.Uri import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.widget.ArrayAdapter +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.view.WindowInsetsCompat -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.BookType -import io.legado.app.data.entities.Book -import io.legado.app.databinding.ActivityBookInfoEditBinding -import io.legado.app.help.book.BookHelp -import io.legado.app.help.book.addType -import io.legado.app.help.book.isAudio -import io.legado.app.help.book.isImage -import io.legado.app.help.book.isLocal -import io.legado.app.help.book.removeType +import androidx.compose.runtime.Composable +import io.legado.app.base.AppTheme +import io.legado.app.base.BaseComposeActivity import io.legado.app.ui.book.changecover.ChangeCoverDialog -import io.legado.app.utils.FileUtils -import io.legado.app.utils.MD5Utils -import io.legado.app.utils.SelectImageContract -import io.legado.app.utils.externalFiles -import io.legado.app.utils.inputStream -import io.legado.app.utils.launch -import io.legado.app.utils.readUri -import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import splitties.init.appCtx -import splitties.views.bottomPadding -import java.io.FileOutputStream -class BookInfoEditActivity : - VMBaseActivity(), - ChangeCoverDialog.CallBack { +class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack { - private val selectCover = registerForActivityResult(SelectImageContract()) { - it.uri?.let { uri -> - coverChangeTo(uri) - } - } + private val viewModel by viewModels() - override val binding by viewBinding(ActivityBookInfoEditBinding::inflate) - override val viewModel by viewModels() - - // 从资源获取书籍类型数组 - private val bookTypes = arrayOf("文本", "音频", "图片") - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.bookData.observe(this) { upView(it) } - if (viewModel.bookData.value == null) { - intent.getStringExtra("bookUrl")?.let { - viewModel.loadBook(it) - } - } - initView() - initEvent() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_info_edit, menu) - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_save -> saveData() - } - return super.onCompatOptionsItemSelected(item) - } - - private fun initView() { - binding.root.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> - val typeMask = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() - val insets = windowInsets.getInsets(typeMask) - view.bottomPadding = insets.bottom - windowInsets - } - initAutoCompleteTextView() - } - - private fun initAutoCompleteTextView() = binding.run { - // 创建适配器 - val adapter = ArrayAdapter( - this@BookInfoEditActivity, - android.R.layout.simple_dropdown_item_1line, bookTypes - ) - actvType.setAdapter(adapter) - actvType.setOnClickListener { - actvType.showDropDown() - } - actvType.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - actvType.showDropDown() - } - } - } - - private fun initEvent() = binding.run { - tvChangeCover.setOnClickListener { - viewModel.bookData.value?.let { - showDialogFragment( - ChangeCoverDialog(it.name, it.author) + @Composable + override fun Content() { + setContent { + AppTheme { + BookInfoEditScreen( + viewModel = viewModel, + onBack = { finish() }, + onSave = ::saveData ) } } - tvSelectCover.setOnClickListener { - selectCover.launch() - } - tvRefreshCover.setOnClickListener { - viewModel.book?.customCoverUrl = tieCoverUrl.text?.toString() - upCover() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + intent.getStringExtra("bookUrl")?.let { + viewModel.loadBook(it) } } - private fun upView(book: Book) = binding.run { - tieBookName.setText(book.name) - tieBookAuthor.setText(book.author) - val selectedType = when { - book.isImage -> 2 - book.isAudio -> 1 - else -> 0 - } - actvType.setText(bookTypes.getOrNull(selectedType) ?: bookTypes[0], false) - tieCoverUrl.setText(book.getDisplayCover()) - tieBookIntro.setText(book.getDisplayIntro()) - upCover() - } - - private fun upCover() { - viewModel.book?.let { - binding.ivCover.load(it.getDisplayCover(), it.name, it.author, false, it.origin) - } - } - - private fun saveData() = binding.run { - val book = viewModel.book ?: return@run - val oldBook = book.copy() - book.name = tieBookName.text?.toString() ?: "" - book.author = tieBookAuthor.text?.toString() ?: "" - val local = if (book.isLocal) BookType.local else 0 - val selectedType = actvType.text.toString() - val bookType = when (selectedType) { - bookTypes[2] -> BookType.image or local - bookTypes[1] -> BookType.audio or local - else -> BookType.text or local - } - book.removeType(BookType.local, BookType.image, BookType.audio, BookType.text) - book.addType(bookType) - val customCoverUrl = tieCoverUrl.text?.toString() - book.customCoverUrl = if (customCoverUrl == book.coverUrl) null else customCoverUrl - val customIntro = tieBookIntro.text?.toString() - book.customIntro = if (customIntro == book.intro) null else customIntro - BookHelp.updateCacheFolder(oldBook, book) - viewModel.saveBook(book) { + private fun saveData() { + viewModel.saveBook { setResult(RESULT_OK) finish() } } override fun coverChangeTo(coverUrl: String) { - viewModel.book?.customCoverUrl = coverUrl - binding.tieCoverUrl.setText(coverUrl) - upCover() + // 更新封面 URL + viewModel.updateCoverUrl(coverUrl) } - private fun coverChangeTo(uri: Uri) { - readUri(uri) { fileDoc, inputStream -> - runCatching { - inputStream.use { - var file = this.externalFiles - val suffix = fileDoc.name.substringAfterLast(".") - val fileName = uri.inputStream(this).getOrThrow().use { - MD5Utils.md5Encode(it) + ".$suffix" - } - file = FileUtils.createFileIfNotExist(file, "covers", fileName) - FileOutputStream(file).use { outputStream -> - inputStream.copyTo(outputStream) - } - coverChangeTo(file.absolutePath) - } - }.onFailure { - appCtx.toastOnUi(it.localizedMessage) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt new file mode 100644 index 000000000..103d2de62 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt @@ -0,0 +1,251 @@ +package io.legado.app.ui.book.info.edit + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Replay +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.help.book.isAudio +import io.legado.app.help.book.isImage +import io.legado.app.ui.book.changecover.ChangeCoverDialog +import io.legado.app.ui.widget.components.Cover +import io.legado.app.utils.SelectImageContract +import io.legado.app.utils.launch +import io.legado.app.utils.showDialogFragment + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BookInfoEditScreen( + viewModel: BookInfoEditViewModel, + onBack: () -> Unit, + onSave: () -> Unit +) { + val book by viewModel.bookData.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(text = stringResource(id = R.string.book_info_edit)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + FilledTonalButton(onClick = onSave) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = stringResource(id = R.string.action_save) + ) + } + }, + scrollBehavior = scrollBehavior + ) + }, + content = { paddingValues -> + book?.let { + BookInfoEditContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + book = it, + viewModel = viewModel + ) + } + } + ) +} + +@Composable +fun BookInfoEditContent( + modifier: Modifier = Modifier, + book: Book, + viewModel: BookInfoEditViewModel +) { + val context = LocalContext.current + var name by remember(book.name) { mutableStateOf(book.name) } + var author by remember(book.author) { mutableStateOf(book.author) } + var coverUrl by remember(book.customCoverUrl) { mutableStateOf(book.getDisplayCover()) } + var intro by remember(book.customIntro) { mutableStateOf(book.getDisplayIntro()) } + var remark by remember(book.remark) { mutableStateOf(book.remark) } + + val bookTypes = arrayOf("文本", "音频", "图片") + val selectedTypeIndex = when { + book.isImage -> 2 + book.isAudio -> 1 + else -> 0 + } + var selectedType by remember { mutableStateOf(bookTypes[selectedTypeIndex]) } + + val selectCover = rememberLauncherForActivityResult(SelectImageContract()) { + it.uri?.let { uri -> + viewModel.coverChangeTo(context, uri) { newCoverUrl -> + coverUrl = newCoverUrl + } + } + } + + LaunchedEffect(coverUrl) { + viewModel.book?.customCoverUrl = coverUrl + } + + Column( + modifier = modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Cover( + path = coverUrl, + modifier = Modifier + .fillMaxWidth(0.3f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { + (context as? BookInfoEditActivity)?.showDialogFragment(ChangeCoverDialog(book.name, book.author)) + }) { + Text("网络搜索") + } + FilledTonalButton(onClick = { selectCover.launch() }) { + Text("本地选择") + } + FilledTonalButton(onClick = { coverUrl = book.coverUrl ?: "" }) { + Icon( + Icons.Default.Replay, + contentDescription = stringResource(id = R.string.default_cover) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("书名") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = author, + onValueChange = { author = it }, + label = { Text("作者") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + BookTypeDropdown( + bookTypes = bookTypes, + selectedType = selectedType, + onTypeSelected = { selectedType = it } + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = coverUrl ?: "", + onValueChange = { coverUrl = it }, + label = { Text("封面链接") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = intro ?: "", + onValueChange = { intro = it }, + label = { Text("简介") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 5 + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = remark ?: "", + onValueChange = { remark = it }, + label = { Text("备注") }, + modifier = Modifier.fillMaxWidth() + ) + } + + LaunchedEffect(name, author, selectedType, coverUrl, intro, remark) { + viewModel.updateBookState(name, author, selectedType, bookTypes, coverUrl, intro, remark) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookTypeDropdown( + bookTypes: Array, + selectedType: String, + onTypeSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryEditable, + true + ), + readOnly = true, + value = selectedType, + onValueChange = {}, + label = { Text("书籍类型") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + bookTypes.forEach { selectionOption -> + DropdownMenuItem( + text = { Text(selectionOption) }, + onClick = { + onTypeSelected(selectionOption) + expanded = false + } + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt index ebb4e6cec..bd952323d 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt @@ -1,33 +1,50 @@ package io.legado.app.ui.book.info.edit import android.app.Application +import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.lifecycle.MutableLiveData +import android.net.Uri import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog +import io.legado.app.constant.BookType import io.legado.app.data.appDb import io.legado.app.data.entities.Book +import io.legado.app.help.book.BookHelp +import io.legado.app.help.book.addType +import io.legado.app.help.book.isLocal +import io.legado.app.help.book.removeType import io.legado.app.model.ReadBook +import io.legado.app.utils.FileUtils +import io.legado.app.utils.MD5Utils +import io.legado.app.utils.inputStream +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.FileOutputStream class BookInfoEditViewModel(application: Application) : BaseViewModel(application) { var book: Book? = null - val bookData = MutableLiveData() + private val _bookData = MutableStateFlow(null) + val bookData: StateFlow = _bookData.asStateFlow() fun loadBook(bookUrl: String) { execute { book = appDb.bookDao.getBook(bookUrl) book?.let { - bookData.postValue(it) + _bookData.value = it } } } - fun saveBook(book: Book, success: (() -> Unit)?) { + fun saveBook(success: (() -> Unit)?) { execute { - if (ReadBook.book?.bookUrl == book.bookUrl) { - ReadBook.book = book + book?.let { book -> + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.book = book + } + appDb.bookDao.update(book) } - appDb.bookDao.update(book) }.onSuccess { success?.invoke() }.onError { @@ -38,4 +55,64 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio } } } -} \ No newline at end of file + + fun updateBookState( + name: String, + author: String, + selectedType: String, + bookTypes: Array, + coverUrl: String?, + intro: String?, + remark: String? + ) { + book?.let { book -> + val oldBook = book.copy() + book.name = name + book.author = author + book.remark = remark + val local = if (book.isLocal) BookType.local else 0 + val bookType = when (selectedType) { + bookTypes[2] -> BookType.image or local + bookTypes[1] -> BookType.audio or local + else -> BookType.text or local + } + book.removeType(BookType.local, BookType.image, BookType.audio, BookType.text) + book.addType(bookType) + book.customCoverUrl = if (coverUrl == book.coverUrl) null else coverUrl + book.customIntro = if (intro == book.intro) null else intro + BookHelp.updateCacheFolder(oldBook, book) + } + } + + fun coverChangeTo(context: Context, uri: Uri, onFinally: (coverUrl: String) -> Unit) { + execute { + runCatching { + context.externalCacheDir?.let { externalCacheDir -> + val file = File(externalCacheDir, "covers") + val suffix = context.contentResolver.getType(uri)?.substringAfterLast("/") ?: "jpg" + val fileName = uri.inputStream(context).getOrThrow().use { MD5Utils.md5Encode(it) } + ".$suffix" + val coverFile = FileUtils.createFileIfNotExist(file, fileName) + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(coverFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + onFinally(coverFile.absolutePath) + } ?: run { + AppLog.put("External cache directory is null", Throwable("Null directory"), true) + } + }.onFailure { + AppLog.put("书籍封面保存失败\n$it", it, true) + } + } + } + + + fun updateCoverUrl(coverUrl: String) { + book?.let { + val updatedBook = it.copy(customCoverUrl = coverUrl) + _bookData.value = updatedBook + } + } + +}