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

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

HapeLee 10 месяцев назад
Родитель
Сommit
9e8ab55e02

+ 22 - 167
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<ActivityBookInfoEditBinding, BookInfoEditViewModel>(),
-    ChangeCoverDialog.CallBack {
+class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack {
 
-    private val selectCover = registerForActivityResult(SelectImageContract()) {
-        it.uri?.let { uri ->
-            coverChangeTo(uri)
-        }
-    }
-
-    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 val viewModel by viewModels<BookInfoEditViewModel>()
 
-    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()
-        }
     }
 
-    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)
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        intent.getStringExtra("bookUrl")?.let {
+            viewModel.loadBook(it)
         }
     }
 
-    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)
-            }
-        }
-    }
 }

+ 251 - 0
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<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
+                    }
+                )
+            }
+        }
+    }
+}

+ 85 - 8
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<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
+        }
+    }
+
+}