[新增] 简单用Compose重写了书籍信息编辑界面

This commit is contained in:
HapeLee
2025-12-19 01:59:46 +08:00
parent e3d6a063e6
commit 9e8ab55e02
3 changed files with 360 additions and 177 deletions
@@ -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<ActivityBookInfoEditBinding, BookInfoEditViewModel>(),
ChangeCoverDialog.CallBack {
class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack {
private val selectCover = registerForActivityResult(SelectImageContract()) {
it.uri?.let { uri ->
coverChangeTo(uri)
}
}
private val viewModel by viewModels<BookInfoEditViewModel>()
override val binding by viewBinding(ActivityBookInfoEditBinding::inflate)
override val viewModel by viewModels<BookInfoEditViewModel>()
// 从资源获取书籍类型数组
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)
}
}
}
}
@@ -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<String>,
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
}
)
}
}
}
}
@@ -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<Book>()
private val _bookData = MutableStateFlow<Book?>(null)
val bookData: StateFlow<Book?> = _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
}
}
}
}
fun updateBookState(
name: String,
author: String,
selectedType: String,
bookTypes: Array<String>,
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
}
}
}