Browse Source

feat: 重写书源编辑界面

HapeLee 3 months ago
parent
commit
078b11efb5

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -221,6 +221,7 @@ import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
 import io.legado.app.ui.book.source.manage.BookSourceViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
@@ -449,6 +450,7 @@ val appModule = module {
     viewModelOf(::DictViewModel)
     viewModelOf(::RssSourceViewModel)
     viewModelOf(::BookSourceViewModel)
+    viewModelOf(::BookSourceEditViewModel)
     viewModelOf(::RssSortViewModel)
     viewModelOf(::RssArticlesViewModel)
     viewModelOf(::ReadRssViewModel)

+ 90 - 691
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt

@@ -1,733 +1,132 @@
 package io.legado.app.ui.book.source.edit
 
 import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.widget.EditText
-import androidx.activity.viewModels
-import androidx.annotation.RequiresApi
+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.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
-import com.google.android.material.tabs.TabLayout
-import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.BookSourceType
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.rule.BookInfoRule
-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.data.entities.rule.TocRule
-import io.legado.app.databinding.ActivityBookSourceEditBinding
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
 import io.legado.app.ui.about.AppLogDialog
 import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
-import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.dialog.UrlOptionDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.ui.widget.keyboard.KeyboardToolPop
-import io.legado.app.ui.widget.recycler.NoChildScrollLinearLayoutManager
-import io.legado.app.ui.widget.text.EditEntity
 import io.legado.app.utils.GSON
-import io.legado.app.utils.imeHeight
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.launch
-import io.legado.app.utils.navigationBarHeight
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getClipText
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.share
-import io.legado.app.utils.shareWithQr
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.showHelp
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.views.bottomPadding
-
-class BookSourceEditActivity :
-    VMBaseActivity<ActivityBookSourceEditBinding, BookSourceEditViewModel>(),
-    KeyboardToolPop.CallBack,
-    VariableDialog.Callback {
-
-    override val binding by viewBinding(ActivityBookSourceEditBinding::inflate)
-    override val viewModel by viewModels<BookSourceEditViewModel>()
-
-    private val adapter by lazy { BookSourceEditAdapter() }
-    private val sourceEntities: ArrayList<EditEntity> = ArrayList()
-    private val searchEntities: ArrayList<EditEntity> = ArrayList()
-    private val exploreEntities: ArrayList<EditEntity> = ArrayList()
-    private val homepageEntities: ArrayList<EditEntity> = ArrayList()
-    private val infoEntities: ArrayList<EditEntity> = ArrayList()
-    private val tocEntities: ArrayList<EditEntity> = ArrayList()
-    private val contentEntities: ArrayList<EditEntity> = ArrayList()
-
-    //    private val reviewEntities: ArrayList<EditEntity> = ArrayList()
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        viewModel.importSource(it) { source ->
-            upSourceView(source)
-        }
-    }
-    private val selectDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            if (uri.isContentScheme()) {
-                sendText(uri.toString())
-            } else {
-                sendText(uri.path.toString())
-            }
-        }
-    }
-
-    private val softKeyboardTool by lazy {
-        KeyboardToolPop(this, lifecycleScope, binding.root, this)
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        softKeyboardTool.attachToWindow(window)
-        initView()
-        viewModel.initData(intent) {
-            upSourceView(viewModel.bookSource)
-        }
-    }
-
-    override fun onPostCreate(savedInstanceState: Bundle?) {
-        super.onPostCreate(savedInstanceState)
-        if (!LocalConfig.ruleHelpVersionIsLast) {
-            showHelp("ruleHelp")
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.source_edit, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !getSource().loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_save -> viewModel.save(getSource()) {
-                setResult(RESULT_OK, Intent().putExtra("origin", it.bookSourceUrl))
-                finish()
-            }
-
-            R.id.menu_debug_source -> viewModel.save(getSource()) { source ->
-                startActivity<BookSourceDebugActivity> {
-                    putExtra("key", source.bookSourceUrl)
-                }
-            }
-
-            R.id.menu_clear_cookie -> viewModel.clearCookie(getSource().bookSourceUrl)
-            R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete
-            R.id.menu_copy_source -> sendToClip(GSON.toJson(getSource()))
-            R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) }
-            R.id.menu_qr_code_camera -> qrCodeResult.launch()
-            R.id.menu_share_str -> share(GSON.toJson(getSource()))
-            R.id.menu_share_qr -> shareWithQr(
-                GSON.toJson(getSource()),
-                getString(R.string.share_book_source),
-                ErrorCorrectionLevel.L
-            )
-
-            R.id.menu_log -> showDialogFragment<AppLogDialog>()
-            R.id.menu_help -> showHelp("ruleHelp")
-            R.id.menu_login -> viewModel.save(getSource()) { source ->
-                startActivity<SourceLoginActivity> {
-                    putExtra("type", "bookSource")
-                    putExtra("key", source.bookSourceUrl)
-                }
-            }
-
-            R.id.menu_set_source_variable -> setSourceVariable()
-            R.id.menu_search -> viewModel.save(getSource()) { source ->
-                startActivity<SearchActivity> {
-                    putExtra("searchScope", SearchScope(source).toString())
-                }
-            }
-
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initView() {
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_base)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_search)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_find)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_homepage)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_info)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_toc)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_content)
-        })
-//        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.layoutManager = NoChildScrollLinearLayoutManager(this)
-        binding.recyclerView.adapter = adapter
-//        binding.tabLayout.setBackgroundColor(backgroundColor)
-//        binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
-        binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
-            override fun onTabReselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabUnselected(tab: TabLayout.Tab?) {
+import org.koin.androidx.compose.koinViewModel
+
+class BookSourceEditActivity : BaseComposeActivity(), VariableDialog.Callback {
+
+    @Composable
+    override fun Content() {
+        val viewModel = koinViewModel<BookSourceEditViewModel>()
+        val state by viewModel.uiState.collectAsStateWithLifecycle()
+        var menuExpanded by remember { mutableStateOf(false) }
+
+        LaunchedEffect(Unit) {
+            viewModel.onIntent(BookSourceEditIntent.Load(intent.getStringExtra("sourceUrl")))
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is BookSourceEditEffect.Finish -> {
+                        if (effect.sourceUrl.isNotEmpty()) {
+                            setResult(RESULT_OK, Intent().putExtra("origin", effect.sourceUrl))
+                        }
+                        finish()
+                    }
 
-            }
+                    is BookSourceEditEffect.OpenDebug -> startActivity<BookSourceDebugActivity> {
+                        putExtra(
+                            "key",
+                            effect.sourceUrl
+                        )
+                    }
 
-            override fun onTabSelected(tab: TabLayout.Tab?) {
-                setEditEntities(tab?.position)
-            }
-        })
-        binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
-            val navigationBarHeight = windowInsets.navigationBarHeight
-            val imeHeight = windowInsets.imeHeight
-            view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0
-            softKeyboardTool.initialPadding = imeHeight
-            windowInsets
-        }
-    }
+                    is BookSourceEditEffect.OpenLogin -> startActivity<SourceLoginActivity> {
+                        putExtra("type", "bookSource"); putExtra("key", effect.sourceUrl)
+                    }
 
-    override fun finish() {
-        val source = getSource()
-        if (!source.equal(viewModel.bookSource ?: BookSource())) {
-            alert(R.string.exit) {
-                setMessage(R.string.exit_no_save)
-                positiveButton(R.string.yes)
-                negativeButton(R.string.no) {
-                    super.finish()
-                }
-            }
-        } else {
-            super.finish()
-        }
-    }
+                    is BookSourceEditEffect.OpenSearch -> GSON.fromJsonObject<BookSource>(effect.sourceJson)
+                        .getOrNull()?.let { source ->
+                        startActivity<SearchActivity> {
+                            putExtra(
+                                "searchScope",
+                                SearchScope(source).toString()
+                            )
+                        }
+                    }
 
-    override fun onDestroy() {
-        super.onDestroy()
-        softKeyboardTool.dismiss()
-    }
+                    is BookSourceEditEffect.CopyText -> sendToClip(effect.text)
+                    is BookSourceEditEffect.ShareText -> share(effect.text)
+                    BookSourceEditEffect.ReadClipboard -> {
+                        val text = getClipText()
+                        if (text.isNullOrBlank()) toastOnUi("剪贴板为空")
+                        else viewModel.onIntent(BookSourceEditIntent.ImportText(text))
+                    }
 
-    private fun setEditEntities(tabPosition: Int?) {
-        adapter.editEntities = when (tabPosition) {
-            1 -> searchEntities
-            2 -> exploreEntities
-            3 -> homepageEntities
-            4 -> infoEntities
-            5 -> tocEntities
-            6 -> contentEntities
-            else -> sourceEntities
-        }
-        binding.recyclerView.scrollToPosition(0)
-    }
+                    BookSourceEditEffect.ConfirmDiscard -> alert(R.string.exit) {
+                        setMessage(R.string.exit_no_save)
+                        positiveButton(R.string.yes) { viewModel.onIntent(BookSourceEditIntent.DiscardChanges) }
+                        negativeButton(R.string.no)
+                    }
 
-    private fun upSourceView(bookSource: BookSource?) {
-        val bs = bookSource ?: BookSource()
-        bs.let {
-            binding.cbIsEnable.isChecked = it.enabled
-            binding.cbIsEnableExplore.isChecked = it.enabledExplore
-            binding.cbIsEnableCookie.isChecked = it.enabledCookieJar ?: false
-            binding.spType.setSelection(
-                when (it.bookSourceType) {
-                    BookSourceType.file -> 3
-                    BookSourceType.image -> 2
-                    BookSourceType.audio -> 1
-                    else -> 0
+                    BookSourceEditEffect.OpenLog -> showDialogFragment<AppLogDialog>()
+                    BookSourceEditEffect.OpenHelp -> showHelp("ruleHelp")
+                    is BookSourceEditEffect.OpenVariable -> openVariable(effect.sourceUrl)
+                    is BookSourceEditEffect.ShowMessage -> toastOnUi(effect.message)
                 }
-            )
-            binding.cbIsEventListener.isChecked = it.eventListener
-            binding.cbIsCustomButton.isChecked = it.customButton
-        }
-        // 基本信息
-        sourceEntities.clear()
-        sourceEntities.apply {
-            add(EditEntity("bookSourceUrl", bs.bookSourceUrl, R.string.source_url))
-            add(EditEntity("bookSourceName", bs.bookSourceName, R.string.source_name))
-            add(EditEntity("bookSourceGroup", bs.bookSourceGroup, R.string.source_group))
-            add(EditEntity("bookSourceComment", bs.bookSourceComment, R.string.comment))
-            add(EditEntity("loginUrl", bs.loginUrl, R.string.login_url))
-            add(EditEntity("loginUi", bs.loginUi, R.string.login_ui))
-            add(EditEntity("loginCheckJs", bs.loginCheckJs, R.string.login_check_js))
-            add(EditEntity("coverDecodeJs", bs.coverDecodeJs, R.string.cover_decode_js))
-            add(EditEntity("bookUrlPattern", bs.bookUrlPattern, R.string.book_url_pattern))
-            add(EditEntity("header", bs.header, R.string.source_http_header))
-            add(EditEntity("variableComment", bs.variableComment, R.string.variable_comment))
-            add(EditEntity("concurrentRate", bs.concurrentRate, R.string.concurrent_rate))
-            add(EditEntity("jsLib", bs.jsLib, "jsLib"))
-        }
-        // 搜索
-        val sr = bs.getSearchRule()
-        searchEntities.clear()
-        searchEntities.apply {
-            add(EditEntity("searchUrl", bs.searchUrl, R.string.r_search_url))
-            add(EditEntity("checkKeyWord", sr.checkKeyWord, R.string.check_key_word))
-            add(EditEntity("bookList", sr.bookList, R.string.r_book_list))
-            add(EditEntity("name", sr.name, R.string.r_book_name))
-            add(EditEntity("author", sr.author, R.string.r_author))
-            add(EditEntity("kind", sr.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", sr.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", sr.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", sr.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", sr.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("bookUrl", sr.bookUrl, R.string.r_book_url))
-        }
-        // 发现
-        val er = bs.getExploreRule()
-        exploreEntities.clear()
-        exploreEntities.apply {
-            add(EditEntity("exploreUrl", bs.exploreUrl, R.string.r_find_url))
-            add(EditEntity("bookList", er.bookList, R.string.r_book_list))
-            add(EditEntity("name", er.name, R.string.r_book_name))
-            add(EditEntity("author", er.author, R.string.r_author))
-            add(EditEntity("kind", er.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", er.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", er.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", er.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url))
-        }
-        // 主页模块
-        homepageEntities.clear()
-        homepageEntities.apply {
-            add(EditEntity("homepageModules", bs.homepageModules, R.string.homepage_modules))
-        }
-        // 详情页
-        val ir = bs.getBookInfoRule()
-        infoEntities.clear()
-        infoEntities.apply {
-            add(EditEntity("init", ir.init, R.string.rule_book_info_init))
-            add(EditEntity("name", ir.name, R.string.r_book_name))
-            add(EditEntity("author", ir.author, R.string.r_author))
-            add(EditEntity("kind", ir.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", ir.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", ir.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", ir.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", ir.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("tocUrl", ir.tocUrl, R.string.rule_toc_url))
-            add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name))
-            add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule))
-            add(EditEntity("relatedBooks", ir.relatedBooks, R.string.related_books))
-        }
-        // 目录页
-        val tr = bs.getTocRule()
-        tocEntities.clear()
-        tocEntities.apply {
-            add(EditEntity("preUpdateJs", tr.preUpdateJs, R.string.pre_update_js))
-            add(EditEntity("chapterList", tr.chapterList, R.string.rule_chapter_list))
-            add(EditEntity("chapterName", tr.chapterName, R.string.rule_chapter_name))
-            add(EditEntity("chapterUrl", tr.chapterUrl, R.string.rule_chapter_url))
-            add(EditEntity("formatJs", tr.formatJs, R.string.format_js_rule))
-            add(EditEntity("isVolume", tr.isVolume, R.string.rule_is_volume))
-            add(EditEntity("updateTime", tr.updateTime, R.string.rule_update_time))
-            add(EditEntity("isVip", tr.isVip, R.string.rule_is_vip))
-            add(EditEntity("isPay", tr.isPay, R.string.rule_is_pay))
-            add(EditEntity("nextTocUrl", tr.nextTocUrl, R.string.rule_next_toc_url))
-        }
-        // 正文页
-        val cr = bs.getContentRule()
-        contentEntities.clear()
-        contentEntities.apply {
-            add(EditEntity("content", cr.content, R.string.rule_book_content))
-            add(EditEntity("subContent", cr.subContent, R.string.rule_sub_content))
-            add(EditEntity("title", cr.title, R.string.rule_chapter_name))
-            add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content))
-            add(EditEntity("webJs", cr.webJs, R.string.rule_web_js))
-            add(EditEntity("sourceRegex", cr.sourceRegex, R.string.rule_source_regex))
-            add(EditEntity("replaceRegex", cr.replaceRegex, R.string.rule_replace_regex))
-            add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style))
-            add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode))
-            add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action))
-            add(EditEntity("callBackJs", cr.callBackJs, R.string.rule_call_back))
-        }
-
-//        val rr = bs.getReviewRule()
-//        reviewEntities.clear()
-//        reviewEntities.apply {
-//            add(EditEntity("reviewUrl", rr.reviewUrl, R.string.rule_review_url))
-//            add(EditEntity("avatarRule", rr.avatarRule, R.string.rule_avatar))
-//            add(EditEntity("contentRule", rr.contentRule, R.string.rule_review_content))
-//            add(EditEntity("postTimeRule", rr.postTimeRule, R.string.rule_post_time))
-//            add(EditEntity("reviewQuoteUrl", rr.reviewQuoteUrl, R.string.rule_review_quote))
-//            add(EditEntity("voteUpUrl", rr.voteUpUrl, R.string.review_vote_up))
-//            add(EditEntity("voteDownUrl", rr.voteDownUrl, R.string.review_vote_down))
-//            add(EditEntity("postReviewUrl", rr.postReviewUrl, R.string.post_review_url))
-//            add(EditEntity("postQuoteUrl", rr.postQuoteUrl, R.string.post_quote_url))
-//            add(EditEntity("deleteUrl", rr.deleteUrl, R.string.delete_review_url))
-//        }
-        binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0))
-        setEditEntities(0)
-    }
-
-    private fun getSource(): BookSource {
-        val source = viewModel.bookSource?.copy() ?: BookSource()
-        source.enabled = binding.cbIsEnable.isChecked
-        source.enabledExplore = binding.cbIsEnableExplore.isChecked
-        source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
-        source.bookSourceType = when (binding.spType.selectedItemPosition) {
-            3 -> BookSourceType.file
-            2 -> BookSourceType.image
-            1 -> BookSourceType.audio
-            else -> BookSourceType.default
-        }
-        source.eventListener = binding.cbIsEventListener.isChecked
-        source.customButton = binding.cbIsCustomButton.isChecked
-        val searchRule = SearchRule()
-        val exploreRule = ExploreRule()
-        val bookInfoRule = BookInfoRule()
-        val tocRule = TocRule()
-        val contentRule = ContentRule()
-//        val reviewRule = ReviewRule()
-        sourceEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "bookSourceUrl" -> source.bookSourceUrl = it.value ?: ""
-                "bookSourceName" -> source.bookSourceName = it.value ?: ""
-                "bookSourceGroup" -> source.bookSourceGroup = it.value
-                "loginUrl" -> source.loginUrl = it.value
-                "loginUi" -> source.loginUi = it.value
-                "loginCheckJs" -> source.loginCheckJs = it.value
-                "coverDecodeJs" -> source.coverDecodeJs = it.value
-                "bookUrlPattern" -> source.bookUrlPattern = it.value
-                "header" -> source.header = it.value
-                "bookSourceComment" -> source.bookSourceComment = it.value
-                "concurrentRate" -> source.concurrentRate = it.value
-                "variableComment" -> source.variableComment = it.value
-                "jsLib" -> source.jsLib = it.value
             }
         }
-        searchEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "searchUrl" -> source.searchUrl = it.value
-                "checkKeyWord" -> searchRule.checkKeyWord = it.value
-                "bookList" -> searchRule.bookList = it.value
-                "name" -> searchRule.name =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "author" -> searchRule.author =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "kind" -> searchRule.kind =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
 
-                "intro" -> searchRule.intro =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-//                "updateTime" -> searchRule.updateTime =
-//                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "wordCount" -> searchRule.wordCount =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "lastChapter" -> searchRule.lastChapter =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "coverUrl" -> searchRule.coverUrl =
-                    viewModel.ruleComplete(it.value, searchRule.bookList, 3)
-
-                "bookUrl" -> searchRule.bookUrl =
-                    viewModel.ruleComplete(it.value, searchRule.bookList, 2)
-            }
-        }
-        exploreEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "exploreUrl" -> source.exploreUrl = it.value
-                "bookList" -> exploreRule.bookList = it.value
-                "name" -> exploreRule.name =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "author" -> exploreRule.author =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "kind" -> exploreRule.kind =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "intro" -> exploreRule.intro =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-//                "updateTime" -> exploreRule.updateTime =
-//                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "wordCount" -> exploreRule.wordCount =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "lastChapter" -> exploreRule.lastChapter =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "coverUrl" -> exploreRule.coverUrl =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList, 3)
-
-                "bookUrl" -> exploreRule.bookUrl =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList, 2)
-            }
-        }
-        homepageEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "homepageModules" -> source.homepageModules = it.value
-            }
-        }
-        infoEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "init" -> bookInfoRule.init = it.value
-                "name" -> bookInfoRule.name = viewModel.ruleComplete(it.value, bookInfoRule.init)
-                "author" -> bookInfoRule.author =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "kind" -> bookInfoRule.kind =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "intro" -> bookInfoRule.intro =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-//                "updateTime" -> bookInfoRule.updateTime =
-//                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "wordCount" -> bookInfoRule.wordCount =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "lastChapter" -> bookInfoRule.lastChapter =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "coverUrl" -> bookInfoRule.coverUrl =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init, 3)
-
-                "tocUrl" -> bookInfoRule.tocUrl =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init, 2)
-
-                "canReName" -> bookInfoRule.canReName = it.value
-                "downloadUrls" -> bookInfoRule.downloadUrls =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-                "relatedBooks" -> bookInfoRule.relatedBooks = it.value
-            }
-        }
-        tocEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "preUpdateJs" -> tocRule.preUpdateJs = it.value
-                "chapterList" -> tocRule.chapterList = it.value
-                "chapterName" -> tocRule.chapterName =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList)
-
-                "chapterUrl" -> tocRule.chapterUrl =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList, 2)
-
-                "formatJs" -> tocRule.formatJs = it.value
-                "isVolume" -> tocRule.isVolume = it.value
-                "updateTime" -> tocRule.updateTime = it.value
-                "isVip" -> tocRule.isVip = it.value
-                "isPay" -> tocRule.isPay = it.value
-                "nextTocUrl" -> tocRule.nextTocUrl =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList, 2)
-            }
-        }
-        contentEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "content" -> contentRule.content = viewModel.ruleComplete(it.value)
-                "subContent" -> contentRule.subContent = viewModel.ruleComplete(it.value)
-                "title" -> contentRule.title = viewModel.ruleComplete(it.value)
-                "nextContentUrl" -> contentRule.nextContentUrl =
-                    viewModel.ruleComplete(it.value, type = 2)
-
-                "webJs" -> contentRule.webJs = it.value
-                "sourceRegex" -> contentRule.sourceRegex = it.value
-                "replaceRegex" -> contentRule.replaceRegex = it.value
-                "imageStyle" -> contentRule.imageStyle = it.value
-                "imageDecode" -> contentRule.imageDecode = it.value
-                "payAction" -> contentRule.payAction = it.value
-                "callBackJs" -> contentRule.callBackJs = it.value
-            }
-        }
-//        reviewEntities.forEach {
-//            when (it.key) {
-//                "reviewUrl" -> reviewRule.reviewUrl = it.value
-//                "avatarRule" -> reviewRule.avatarRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 3)
-//
-//                "contentRule" -> reviewRule.contentRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl)
-//
-//                "postTimeRule" -> reviewRule.postTimeRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl)
-//
-//                "reviewQuoteUrl" -> reviewRule.reviewQuoteUrl =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 2)
-//
-//                "voteUpUrl" -> reviewRule.voteUpUrl = it.value
-//                "voteDownUrl" -> reviewRule.voteDownUrl = it.value
-//                "postReviewUrl" -> reviewRule.postReviewUrl = it.value
-//                "postQuoteUrl" -> reviewRule.postQuoteUrl = it.value
-//                "deleteUrl" -> reviewRule.deleteUrl = it.value
-//            }
-//        }
-        source.ruleSearch = searchRule
-        source.ruleExplore = exploreRule
-        source.ruleBookInfo = bookInfoRule
-        source.ruleToc = tocRule
-        source.ruleContent = contentRule
-//        source.ruleReview = reviewRule
-        return source
-    }
-
-    @RequiresApi(Build.VERSION_CODES.M)
-    override fun onUndoClicked() {
-        val editText = window.decorView.findFocus()
-        if (editText is EditText) {
-            editText.onTextContextMenuItem(android.R.id.undo)
-        }
-    }
-
-    @RequiresApi(Build.VERSION_CODES.M)
-    override fun onRedoClicked() {
-        val editText = window.decorView.findFocus()
-        if (editText is EditText) {
-            editText.onTextContextMenuItem(android.R.id.redo)
-        }
-    }
-
-    private fun alertGroups() {
-        lifecycleScope.launch {
-            val groups = withContext(IO) {
-                appDb.bookSourceDao.allGroups()
-            }
-            selector(groups) { _, s, _ ->
-                sendText(s)
-            }
-        }
-    }
-
-    override fun helpActions(): List<SelectItem<String>> {
-        val helpActions = arrayListOf(
-            SelectItem("插入URL参数", "urlOption"),
-            SelectItem("书源教程", "ruleHelp"),
-            SelectItem("js教程", "jsHelp"),
-            SelectItem("正则教程", "regexHelp"),
+        BookSourceEditScreen(
+            state = state,
+            menuExpanded = menuExpanded,
+            onMenuExpandedChange = { menuExpanded = it },
+            onIntent = viewModel::onIntent,
         )
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            when (view.getTag(R.id.tag)) {
-                "bookSourceGroup" -> {
-                    helpActions.add(
-                        SelectItem("插入分组", "addGroup")
-                    )
-                }
-
-                else -> {
-                    helpActions.add(
-                        SelectItem("选择文件", "selectFile")
-                    )
-                }
-            }
-        }
-        return helpActions
     }
 
-    override fun onHelpActionSelect(action: String) {
-        when (action) {
-            "addGroup" -> alertGroups()
-            "urlOption" -> UrlOptionDialog(this) { sendText(it) }.show()
-            "ruleHelp" -> showHelp("ruleHelp")
-            "jsHelp" -> showHelp("jsHelp")
-            "regexHelp" -> showHelp("regexHelp")
-            "selectFile" -> selectDoc.launch {
-                mode = HandleFileContract.FILE
-            }
-        }
-    }
-
-    override fun sendText(text: String) {
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            var start = view.selectionStart
-            var end = view.selectionEnd
-            if (start > end) {
-                val temp = start
-                start = end
-                end = temp
-            }
-            if (text.isNotEmpty()) {
-                val edit = view.editableText//获取EditText的文字
-                if (start < 0 || start >= edit.length) {
-                    edit.append(text)
-                } else {
-                    edit.replace(start, end, text)//光标所在位置插入文字
-                }
-            }
-            if (adapter.editEntityMaxLine >= 999) {
-                view.post {
-                    val editTextLocation = IntArray(2)
-                    view.getLocationOnScreen(editTextLocation)
-                    val recyclerViewLocation = IntArray(2)
-                    binding.recyclerView.getLocationOnScreen(recyclerViewLocation)
-                    val layout = view.layout
-                    if (layout != null) {
-                        val line = layout.getLineForOffset(end)
-                        val cursorYInEditText = layout.getLineTop(line)
-                        val cursorYOnScreen = editTextLocation[1] + cursorYInEditText
-                        val cursorYInRecyclerView = cursorYOnScreen - recyclerViewLocation[1]
-                        val recyclerViewBottom = binding.recyclerView.height - 120
-                        if (cursorYInRecyclerView !in 0..recyclerViewBottom) {
-                            val scrollDistance = cursorYInRecyclerView - recyclerViewBottom / 3
-                            if (scrollDistance > 0 && binding.recyclerView.canScrollVertically(1) || scrollDistance < 0 && binding.recyclerView.canScrollVertically(-1)) {
-                                binding.recyclerView.smoothScrollBy(0, scrollDistance)
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    private fun setSourceVariable() {
-        viewModel.save(getSource()) { source ->
-            lifecycleScope.launch {
-                val comment =
-                    source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-                val variable = withContext(IO) { source.getVariable() }
-                showDialogFragment(
-                    VariableDialog(
-                        getString(R.string.set_source_variable),
-                        source.getKey(),
-                        variable,
-                        comment
-                    )
+    private fun openVariable(sourceUrl: String) {
+        lifecycleScope.launch {
+            val source =
+                withContext(Dispatchers.IO) { appDb.bookSourceDao.getBookSource(sourceUrl) }
+                    ?: return@launch
+            val comment =
+                source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
+            val variable = withContext(Dispatchers.IO) { source.getVariable() }
+            showDialogFragment(
+                VariableDialog(
+                    getString(R.string.set_source_variable),
+                    source.getKey(),
+                    variable,
+                    comment
                 )
-            }
+            )
         }
     }
 
     override fun setVariable(key: String, variable: String?) {
-        viewModel.bookSource?.setVariable(variable)
+        lifecycleScope.launch(Dispatchers.IO) {
+            appDb.bookSourceDao.getBookSource(key)?.setVariable(variable)
+        }
     }
-
 }

+ 0 - 100
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt

@@ -1,100 +0,0 @@
-package io.legado.app.ui.book.source.edit
-
-import android.annotation.SuppressLint
-import android.text.Editable
-import android.text.TextWatcher
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.databinding.ItemSourceEditBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.widget.code.addJsPattern
-import io.legado.app.ui.widget.code.addJsonPattern
-import io.legado.app.ui.widget.code.addLegadoPattern
-import io.legado.app.ui.widget.text.EditEntity
-
-class BookSourceEditAdapter : RecyclerView.Adapter<BookSourceEditAdapter.MyViewHolder>() {
-
-    val editEntityMaxLine = AppConfig.sourceEditMaxLine
-
-    var editEntities: ArrayList<EditEntity> = ArrayList()
-        @SuppressLint("NotifyDataSetChanged")
-        set(value) {
-            field = value
-            notifyDataSetChanged()
-        }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
-        val binding = ItemSourceEditBinding
-            .inflate(LayoutInflater.from(parent.context), parent, false)
-        binding.editText.addLegadoPattern()
-        binding.editText.addJsonPattern()
-        binding.editText.addJsPattern()
-        return MyViewHolder(binding)
-    }
-
-    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
-        holder.bind(editEntities[position])
-    }
-
-    override fun getItemCount(): Int {
-        return editEntities.size
-    }
-
-    inner class MyViewHolder(val binding: ItemSourceEditBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            editText.setTag(R.id.tag, editEntity.key)
-            editText.maxLines = editEntityMaxLine
-            if (editText.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        editText.isCursorVisible = false
-                        editText.isCursorVisible = true
-                        editText.isFocusable = true
-                        editText.isFocusableInTouchMode = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {
-
-                    }
-                }
-                editText.addOnAttachStateChangeListener(listener)
-                editText.setTag(R.id.tag1, listener)
-            }
-            editText.getTag(R.id.tag2)?.let {
-                if (it is TextWatcher) {
-                    editText.removeTextChangedListener(it)
-                }
-            }
-            editText.setText(editEntity.value)
-            textInputLayout.hint = editEntity.hint
-            editText.hint = editEntity.hint
-            val textWatcher = object : TextWatcher {
-                override fun beforeTextChanged(
-                    s: CharSequence,
-                    start: Int,
-                    count: Int,
-                    after: Int
-                ) {
-
-                }
-
-                override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
-
-                }
-
-                override fun afterTextChanged(s: Editable?) {
-                    editEntity.value = (s?.toString())
-                }
-            }
-            editText.addTextChangedListener(textWatcher)
-            editText.setTag(R.id.tag2, textWatcher)
-        }
-    }
-
-
-}

+ 83 - 0
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditContract.kt

@@ -0,0 +1,83 @@
+package io.legado.app.ui.book.source.edit
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+
+enum class BookSourceEditTab(@StringRes val titleRes: Int) {
+    Base(io.legado.app.R.string.source_tab_base),
+    Search(io.legado.app.R.string.source_tab_search),
+    Explore(io.legado.app.R.string.source_tab_find),
+    Info(io.legado.app.R.string.source_tab_info),
+    Toc(io.legado.app.R.string.source_tab_toc),
+    Content(io.legado.app.R.string.source_tab_content),
+}
+
+@Immutable
+data class BookSourceEditFieldUi(
+    val path: String,
+    @StringRes val labelRes: Int? = null,
+    val label: String? = null,
+    val value: String = "",
+)
+
+@Stable
+data class BookSourceEditUiState(
+    val loading: Boolean = true,
+    val saving: Boolean = false,
+    val selectedTab: BookSourceEditTab = BookSourceEditTab.Base,
+    val fieldGroups: ImmutableMap<BookSourceEditTab, ImmutableList<BookSourceEditFieldUi>> = persistentMapOf(),
+    val enabled: Boolean = true,
+    val enabledExplore: Boolean = true,
+    val enabledCookieJar: Boolean = true,
+    val eventListener: Boolean = false,
+    val customButton: Boolean = false,
+    val bookSourceType: Int = 0,
+    val autoComplete: Boolean = false,
+    val dirty: Boolean = false,
+)
+
+sealed interface BookSourceEditIntent {
+    data class Load(val sourceUrl: String?) : BookSourceEditIntent
+    data class SelectTab(val tab: BookSourceEditTab) : BookSourceEditIntent
+    data class UpdateField(val path: String, val value: String) : BookSourceEditIntent
+    data class SetEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetExploreEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetCookieJarEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetEventListener(val value: Boolean) : BookSourceEditIntent
+    data class SetCustomButton(val value: Boolean) : BookSourceEditIntent
+    data class SetSourceType(val value: Int) : BookSourceEditIntent
+    data class ImportText(val text: String) : BookSourceEditIntent
+    data object ToggleAutoComplete : BookSourceEditIntent
+    data object Save : BookSourceEditIntent
+    data object SaveAndDebug : BookSourceEditIntent
+    data object SaveAndLogin : BookSourceEditIntent
+    data object SaveAndSearch : BookSourceEditIntent
+    data object Copy : BookSourceEditIntent
+    data object Share : BookSourceEditIntent
+    data object Paste : BookSourceEditIntent
+    data object ClearCookie : BookSourceEditIntent
+    data object ShowLog : BookSourceEditIntent
+    data object ShowHelp : BookSourceEditIntent
+    data object SaveAndSetVariable : BookSourceEditIntent
+    data object RequestBack : BookSourceEditIntent
+    data object DiscardChanges : BookSourceEditIntent
+}
+
+sealed interface BookSourceEditEffect {
+    data class Finish(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenDebug(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenLogin(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenSearch(val sourceJson: String) : BookSourceEditEffect
+    data class CopyText(val text: String) : BookSourceEditEffect
+    data class ShareText(val text: String) : BookSourceEditEffect
+    data object ReadClipboard : BookSourceEditEffect
+    data object ConfirmDiscard : BookSourceEditEffect
+    data object OpenLog : BookSourceEditEffect
+    data object OpenHelp : BookSourceEditEffect
+    data class OpenVariable(val sourceUrl: String) : BookSourceEditEffect
+    data class ShowMessage(val message: String) : BookSourceEditEffect
+}

+ 381 - 0
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditScreen.kt

@@ -0,0 +1,381 @@
+package io.legado.app.ui.book.source.edit
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookSourceEditScreen(
+    state: BookSourceEditUiState,
+    menuExpanded: Boolean,
+    onMenuExpandedChange: (Boolean) -> Unit,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    BackHandler { onIntent(BookSourceEditIntent.RequestBack) }
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val tabs = BookSourceEditTab.entries
+    val pagerState = rememberPagerState(initialPage = state.selectedTab.ordinal) { tabs.size }
+    val pagerScope = rememberCoroutineScope()
+    var editingFieldPath by remember { mutableStateOf<String?>(null) }
+    val editingField =
+        state.fieldGroups.values.flatten().firstOrNull { it.path == editingFieldPath }
+    androidx.compose.runtime.LaunchedEffect(state.selectedTab) {
+        if (pagerState.currentPage != state.selectedTab.ordinal) {
+            pagerState.animateScrollToPage(state.selectedTab.ordinal)
+        }
+    }
+    androidx.compose.runtime.LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }.collect { page ->
+            tabs.getOrNull(page)?.let { onIntent(BookSourceEditIntent.SelectTab(it)) }
+        }
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                GlassMediumFlexibleTopAppBar(
+                    title = stringResource(R.string.edit_book_source),
+                    scrollBehavior = scrollBehavior,
+                    navigationIcon = {
+                        TopBarNavigationButton(onClick = {
+                            onIntent(
+                                BookSourceEditIntent.RequestBack
+                            )
+                        })
+                    },
+                    actions = {
+                        TopBarActionButton(
+                            onClick = { onIntent(BookSourceEditIntent.SaveAndDebug) },
+                            imageVector = Icons.Default.BugReport,
+                            contentDescription = stringResource(R.string.debug_source),
+                        )
+                        TopBarActionButton(
+                            onClick = { onMenuExpandedChange(true) },
+                            imageVector = Icons.Default.MoreVert,
+                            contentDescription = stringResource(R.string.more_menu),
+                        )
+                        BookSourceEditMenu(menuExpanded, onMenuExpandedChange, state, onIntent)
+                    },
+                )
+                AppTabRow(
+                    tabTitles = BookSourceEditTab.entries.map { stringResource(it.titleRes) },
+                    selectedTabIndex = state.selectedTab.ordinal,
+                    onTabSelected = { page ->
+                        onIntent(BookSourceEditIntent.SelectTab(tabs[page]))
+                        pagerScope.launch { pagerState.animateScrollToPage(page) }
+                    },
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(BookSourceEditIntent.Save) },
+                tooltipText = stringResource(R.string.action_save),
+                icon = Icons.Default.Save,
+            )
+        },
+    ) { padding ->
+        HorizontalPager(
+            state = pagerState,
+            modifier = Modifier.fillMaxSize(),
+        ) { page ->
+            val tab = tabs[page]
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = androidx.compose.foundation.layout.PaddingValues(
+                    start = 16.dp,
+                    end = 16.dp,
+                    top = padding.calculateTopPadding() + 12.dp,
+                    bottom = padding.calculateBottomPadding() + 96.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                if (tab == BookSourceEditTab.Base) {
+                    item(key = "options", contentType = "options") {
+                        BookSourceOptions(state, onIntent)
+                    }
+                }
+                items(
+                    items = state.fieldGroups[tab].orEmpty(),
+                    key = { it.path },
+                    contentType = { "field" },
+                ) { field ->
+                    BookSourceFieldCard(
+                        field = field,
+                        modifier = Modifier.fillMaxWidth(),
+                        onClick = { editingFieldPath = field.path },
+                    )
+                }
+            }
+        }
+    }
+    BookSourceFieldEditSheet(
+        field = editingField,
+        onDismissRequest = { editingFieldPath = null },
+        onValueChange = { path, value ->
+            onIntent(BookSourceEditIntent.UpdateField(path, value))
+        },
+    )
+}
+
+@Composable
+private fun BookSourceFieldCard(
+    field: BookSourceEditFieldUi,
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit,
+) {
+    val title = field.labelRes?.let { stringResource(it) } ?: field.label.orEmpty()
+    GlassCard(modifier = modifier, onClick = onClick) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AppText(
+                text = title,
+                style = LegadoTheme.typography.titleSmall,
+            )
+            AppText(
+                text = field.value.ifBlank { "—" },
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 4,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+    }
+}
+
+@Composable
+private fun BookSourceFieldEditSheet(
+    field: BookSourceEditFieldUi?,
+    onDismissRequest: () -> Unit,
+    onValueChange: (String, String) -> Unit,
+) {
+    val title = field?.let {
+        it.labelRes?.let { labelRes -> stringResource(labelRes) } ?: it.label.orEmpty()
+    }
+    AppModalBottomSheet(
+        show = field != null,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        animateContentSize = false,
+    ) {
+        if (field != null) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .verticalScroll(rememberScrollState()),
+            ) {
+                AppTextField(
+                    value = field.value,
+                    onValueChange = { onValueChange(field.path, it) },
+                    label = title,
+                    modifier = Modifier.fillMaxWidth(),
+                    minLines = 4,
+                    maxLines = 24,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookSourceOptions(
+    state: BookSourceEditUiState,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    val sourceTypes = stringArrayResource(R.array.book_type)
+    var typeMenuExpanded by remember { mutableStateOf(false) }
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            Box(modifier = Modifier.weight(1f)) {
+                BookSourceOptionCard(
+                    title = stringResource(R.string.book_type),
+                    subtitle = sourceTypes.getOrNull(state.bookSourceType),
+                    onClick = { typeMenuExpanded = true },
+                )
+                RoundDropdownMenu(
+                    expanded = typeMenuExpanded,
+                    onDismissRequest = { typeMenuExpanded = false },
+                ) {
+                    sourceTypes.forEachIndexed { index, title ->
+                        RoundDropdownMenuItem(
+                            text = title,
+                            isSelected = state.bookSourceType == index,
+                            onClick = {
+                                typeMenuExpanded = false
+                                onIntent(BookSourceEditIntent.SetSourceType(index))
+                            },
+                        )
+                    }
+                }
+            }
+            BookSourceOptionCard(
+                title = stringResource(R.string.is_enable),
+                checked = state.enabled,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetEnabled(!state.enabled)) },
+            )
+            BookSourceOptionCard(
+                title = stringResource(R.string.discovery),
+                checked = state.enabledExplore,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetExploreEnabled(!state.enabledExplore)) },
+            )
+        }
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            BookSourceOptionCard(
+                title = stringResource(R.string.auto_save_cookie),
+                checked = state.enabledCookieJar,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetCookieJarEnabled(!state.enabledCookieJar)) },
+            )
+            BookSourceOptionCard(
+                title = stringResource(R.string.is_event_listener),
+                checked = state.eventListener,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetEventListener(!state.eventListener)) },
+            )
+            BookSourceOptionCard(
+                title = stringResource(R.string.custom_button),
+                checked = state.customButton,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetCustomButton(!state.customButton)) },
+            )
+        }
+    }
+}
+
+@Composable
+private fun BookSourceOptionCard(
+    title: String,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    subtitle: String? = null,
+    checked: Boolean? = null,
+) {
+    GlassCard(modifier = modifier, onClick = onClick) {
+        Row(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = subtitle?.let { "$title $it" } ?: title,
+                maxLines = 1,
+                modifier = Modifier
+                    .weight(1f)
+                    .basicMarquee(),
+                style = LegadoTheme.typography.labelSmall,
+            )
+            AnimatedVisibility(
+                visible = checked == true
+            ) {
+                AppIcon(
+                    imageVector = AppIcons.Check,
+                    contentDescription = null,
+                    modifier = Modifier.size(16.dp),
+                    tint = LegadoTheme.colorScheme.primary,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookSourceEditMenu(
+    expanded: Boolean,
+    onExpandedChange: (Boolean) -> Unit,
+    state: BookSourceEditUiState,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    fun click(intent: BookSourceEditIntent) {
+        onExpandedChange(false); onIntent(intent)
+    }
+    RoundDropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) {
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.login),
+            onClick = { click(BookSourceEditIntent.SaveAndLogin) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.search),
+            onClick = { click(BookSourceEditIntent.SaveAndSearch) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.cookie),
+            onClick = { click(BookSourceEditIntent.ClearCookie) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.auto_complete),
+            isSelected = state.autoComplete,
+            onClick = { click(BookSourceEditIntent.ToggleAutoComplete) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.copy_source),
+            onClick = { click(BookSourceEditIntent.Copy) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.paste_source),
+            onClick = { click(BookSourceEditIntent.Paste) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.set_source_variable),
+            onClick = { click(BookSourceEditIntent.SaveAndSetVariable) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.str_share),
+            onClick = { click(BookSourceEditIntent.Share) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.log),
+            onClick = { click(BookSourceEditIntent.ShowLog) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.help),
+            onClick = { click(BookSourceEditIntent.ShowHelp) })
+    }
+}

+ 320 - 100
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt

@@ -1,9 +1,11 @@
 package io.legado.app.ui.book.source.edit
 
 import android.app.Application
-import android.content.Intent
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.exception.NoStackTraceException
@@ -20,136 +22,354 @@ import io.legado.app.model.SharedJsScope
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getClipText
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.jsonPath
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.toastOnUi
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
+class BookSourceEditViewModel(application: Application) : AndroidViewModel(application) {
+    private val _uiState = MutableStateFlow(BookSourceEditUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<BookSourceEditEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
-class BookSourceEditViewModel(application: Application) : BaseViewModel(application) {
-    var autoComplete = false
-    var bookSource: BookSource? = null
+    private var originalSource: BookSource? = null
+    private var draftJson = JsonObject()
+    private var baselineJson = ""
 
-    fun initData(intent: Intent, onFinally: () -> Unit) {
-        execute {
-            val sourceUrl = intent.getStringExtra("sourceUrl")
-            var source: BookSource? = null
-            if (sourceUrl != null) {
-                source = appDb.bookSourceDao.getBookSource(sourceUrl)
+    fun onIntent(intent: BookSourceEditIntent) {
+        when (intent) {
+            is BookSourceEditIntent.Load -> load(intent.sourceUrl)
+            is BookSourceEditIntent.SelectTab -> selectTab(intent.tab)
+            is BookSourceEditIntent.UpdateField -> updateField(intent.path, intent.value)
+            is BookSourceEditIntent.SetEnabled -> updateFlags { copy(enabled = intent.value) }
+            is BookSourceEditIntent.SetExploreEnabled -> updateFlags { copy(enabledExplore = intent.value) }
+            is BookSourceEditIntent.SetCookieJarEnabled -> updateFlags { copy(enabledCookieJar = intent.value) }
+            is BookSourceEditIntent.SetEventListener -> updateFlags { copy(eventListener = intent.value) }
+            is BookSourceEditIntent.SetCustomButton -> updateFlags { copy(customButton = intent.value) }
+            is BookSourceEditIntent.SetSourceType -> updateFlags { copy(bookSourceType = intent.value) }
+            is BookSourceEditIntent.ImportText -> importText(intent.text)
+            BookSourceEditIntent.ToggleAutoComplete -> _uiState.update { it.copy(autoComplete = !it.autoComplete) }
+            BookSourceEditIntent.Save -> save(BookSourceEditEffect::Finish)
+            BookSourceEditIntent.SaveAndDebug -> save { BookSourceEditEffect.OpenDebug(it) }
+            BookSourceEditIntent.SaveAndLogin -> save { BookSourceEditEffect.OpenLogin(it) }
+            BookSourceEditIntent.SaveAndSearch -> save {
+                BookSourceEditEffect.OpenSearch(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
             }
-            source?.let {
-                bookSource = it
-            }
-        }.onFinally {
-            onFinally()
+
+            BookSourceEditIntent.Copy -> _effects.tryEmit(
+                BookSourceEditEffect.CopyText(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
+            )
+
+            BookSourceEditIntent.Share -> _effects.tryEmit(
+                BookSourceEditEffect.ShareText(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
+            )
+
+            BookSourceEditIntent.Paste -> _effects.tryEmit(BookSourceEditEffect.ReadClipboard)
+            BookSourceEditIntent.ClearCookie -> clearCookie()
+            BookSourceEditIntent.ShowLog -> _effects.tryEmit(BookSourceEditEffect.OpenLog)
+            BookSourceEditIntent.ShowHelp -> _effects.tryEmit(BookSourceEditEffect.OpenHelp)
+            BookSourceEditIntent.SaveAndSetVariable -> save { BookSourceEditEffect.OpenVariable(it) }
+            BookSourceEditIntent.RequestBack -> if (_uiState.value.dirty) {
+                _effects.tryEmit(BookSourceEditEffect.ConfirmDiscard)
+            } else _effects.tryEmit(BookSourceEditEffect.Finish(""))
+
+            BookSourceEditIntent.DiscardChanges -> _effects.tryEmit(BookSourceEditEffect.Finish(""))
+        }
+    }
+
+    private fun load(sourceUrl: String?) = viewModelScope.launch(Dispatchers.IO) {
+        val source = sourceUrl?.let { appDb.bookSourceDao.getBookSource(it) } ?: BookSource()
+        withContext(Dispatchers.Main) {
+            applySource(source, asOriginal = true)
+            if (sourceUrl == null) originalSource = null
+        }
+    }
+
+    private fun applySource(source: BookSource, asOriginal: Boolean = false) {
+        if (asOriginal) originalSource = source
+        draftJson = JsonParser.parseString(GSON.toJson(source)).asJsonObject
+        if (asOriginal) baselineJson = GSON.toJson(source)
+        val tab = _uiState.value.selectedTab
+        _uiState.value = BookSourceEditUiState(
+            loading = false,
+            selectedTab = tab,
+            fieldGroups = fieldGroups(),
+            enabled = source.enabled,
+            enabledExplore = source.enabledExplore,
+            enabledCookieJar = source.enabledCookieJar == true,
+            eventListener = source.eventListener,
+            customButton = source.customButton,
+            bookSourceType = source.bookSourceType,
+            autoComplete = _uiState.value.autoComplete,
+        )
+    }
+
+    private fun selectTab(tab: BookSourceEditTab) {
+        _uiState.update { it.copy(selectedTab = tab) }
+    }
+
+    private fun updateField(path: String, value: String) {
+        draftJson.setPath(path, value)
+        _uiState.update { state ->
+            state.copy(
+                fieldGroups = state.fieldGroups.mapValues { (_, fields) ->
+                    fields.map { if (it.path == path) it.copy(value = value) else it }
+                        .toImmutableList()
+                }.toImmutableMap(),
+                dirty = isDirty(state),
+            )
         }
     }
 
-    fun save(source: BookSource, success: ((BookSource) -> Unit)? = null) {
-        execute {
+    private fun updateFlags(transform: BookSourceEditUiState.() -> BookSourceEditUiState) {
+        _uiState.update { state -> transform(state).let { it.copy(dirty = isDirty(it)) } }
+    }
+
+    private fun isDirty(state: BookSourceEditUiState): Boolean =
+        GSON.toJson(currentSource(state)) != baselineJson
+
+    private fun currentSource(state: BookSourceEditUiState = _uiState.value): BookSource {
+        return GSON.fromJson(draftJson, BookSource::class.java).apply {
+            enabled = state.enabled
+            enabledExplore = state.enabledExplore
+            enabledCookieJar = state.enabledCookieJar
+            eventListener = state.eventListener
+            customButton = state.customButton
+            bookSourceType = state.bookSourceType
+            if (state.autoComplete) completeRules(this)
+        }
+    }
+
+    private fun completeRules(source: BookSource) {
+        source.getSearchRule().apply {
+            name = RuleComplete.autoComplete(name, bookList)
+            author = RuleComplete.autoComplete(author, bookList)
+            kind = RuleComplete.autoComplete(kind, bookList)
+            wordCount = RuleComplete.autoComplete(wordCount, bookList)
+            lastChapter = RuleComplete.autoComplete(lastChapter, bookList)
+            intro = RuleComplete.autoComplete(intro, bookList)
+            coverUrl = RuleComplete.autoComplete(coverUrl, bookList, 3)
+            bookUrl = RuleComplete.autoComplete(bookUrl, bookList, 2)
+        }
+        source.getExploreRule().apply {
+            name = RuleComplete.autoComplete(name, bookList)
+            author = RuleComplete.autoComplete(author, bookList)
+            kind = RuleComplete.autoComplete(kind, bookList)
+            wordCount = RuleComplete.autoComplete(wordCount, bookList)
+            lastChapter = RuleComplete.autoComplete(lastChapter, bookList)
+            intro = RuleComplete.autoComplete(intro, bookList)
+            coverUrl = RuleComplete.autoComplete(coverUrl, bookList, 3)
+            bookUrl = RuleComplete.autoComplete(bookUrl, bookList, 2)
+        }
+    }
+
+    private fun save(effect: (String) -> BookSourceEditEffect) =
+        viewModelScope.launch(Dispatchers.IO) {
+            _uiState.update { it.copy(saving = true) }
+            runCatching {
+                val source = currentSource()
             if (source.bookSourceUrl.isBlank() || source.bookSourceName.isBlank()) {
-                throw NoStackTraceException(context.getString(R.string.non_null_name_url))
+                throw NoStackTraceException(getApplication<Application>().getString(R.string.non_null_name_url))
             }
-            val oldSource = bookSource ?: BookSource()
-            if (!source.equal(oldSource)) {
+                val old = originalSource ?: BookSource()
+                if (!source.equal(old)) {
                 source.lastUpdateTime = System.currentTimeMillis()
-                if (oldSource.exploreUrl != source.exploreUrl) {
-                    oldSource.clearExploreKindsCache()
-                }
-                if (oldSource.jsLib != source.jsLib) {
-                    SharedJsScope.remove(oldSource.jsLib)
-                }
-            }
-            bookSource?.let {
-                if (it.bookSourceUrl != source.bookSourceUrl) {
-                    SourceHelp.deleteBookSource(it.bookSourceUrl)
-                } else {
-                    appDb.bookSourceDao.delete(it)
-                    SourceConfig.removeSource(it.bookSourceUrl)
+                    if (old.exploreUrl != source.exploreUrl) old.clearExploreKindsCache()
+                    if (old.jsLib != source.jsLib) SharedJsScope.remove(old.jsLib)
                 }
+                originalSource?.let {
+                    if (it.bookSourceUrl != source.bookSourceUrl) SourceHelp.deleteBookSource(it.bookSourceUrl)
+                    else {
+                        appDb.bookSourceDao.delete(it); SourceConfig.removeSource(it.bookSourceUrl)
+                    }
             }
             appDb.bookSourceDao.insert(source)
-            bookSource = source
             concurrentRecordMap.remove(source.bookSourceUrl)
-            source
-        }.onSuccess {
-            success?.invoke(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-            it.printOnDebug()
+                originalSource = source
+                baselineJson = GSON.toJson(source)
+                source.bookSourceUrl
+            }.onSuccess { url ->
+                _uiState.update { it.copy(saving = false, dirty = false) }
+                _effects.emit(effect(url))
+            }.onFailure { error ->
+                _uiState.update { it.copy(saving = false) }
+                _effects.emit(BookSourceEditEffect.ShowMessage(error.localizedMessage ?: "Error"))
         }
     }
 
-    fun pasteSource(onSuccess: (source: BookSource) -> Unit) {
-        execute(context = Dispatchers.Main) {
-            val text = context.getClipText()
-            if (text.isNullOrBlank()) {
-                throw NoStackTraceException("剪贴板为空")
-            } else {
-                importSource(text, onSuccess)
-            }
-        }.onError {
-            context.toastOnUi(it.localizedMessage ?: "Error")
-            it.printOnDebug()
+    private fun importText(text: String) = viewModelScope.launch(Dispatchers.IO) {
+        runCatching { parseSource(text) }.onSuccess { source ->
+            withContext(Dispatchers.Main) { applySource(source) }
+            _uiState.update { it.copy(dirty = true) }
+        }.onFailure {
+            _effects.emit(
+                BookSourceEditEffect.ShowMessage(
+                    it.localizedMessage ?: "Error"
+                )
+            )
         }
     }
 
-    fun importSource(text: String, finally: (source: BookSource) -> Unit) {
-        execute {
-            importSource(text)
-        }.onSuccess {
-            finally.invoke(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage ?: "Error")
-            it.printOnDebug()
-        }
+    private suspend fun parseSource(text: String): BookSource = when {
+        text.isAbsUrl() -> parseSource(okHttpClient.newCallStrResponse { url(text) }.body.orEmpty())
+        text.isJsonArray() -> if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
+            val items: List<Map<String, Any>> = jsonPath.parse(text).read("$")
+            ImportOldData.fromOldBookSource(jsonPath.parse(items[0]))
+        } else GSON.fromJsonArray<BookSource>(text).getOrThrow().first()
+
+        text.isJsonObject() -> if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
+            ImportOldData.fromOldBookSource(jsonPath.parse(text))
+        } else GSON.fromJsonObject<BookSource>(text).getOrThrow()
+
+        else -> throw NoStackTraceException("格式不对")
     }
 
-    suspend fun importSource(text: String): BookSource {
-        return when {
-            text.isAbsUrl() -> {
-                val text1 = okHttpClient.newCallStrResponse { url(text) }.body
-                importSource(text1!!)
-            }
+    private fun clearCookie() = viewModelScope.launch(Dispatchers.IO) {
+        CookieStore.removeCookie(currentSource().bookSourceUrl)
+        _effects.emit(BookSourceEditEffect.ShowMessage(getApplication<Application>().getString(R.string.success)))
+    }
 
-            text.isJsonArray() -> {
-                if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
-                    val items: List<Map<String, Any>> = jsonPath.parse(text).read("$")
-                    val jsonItem = jsonPath.parse(items[0])
-                    ImportOldData.fromOldBookSource(jsonItem)
-                } else {
-                    GSON.fromJsonArray<BookSource>(text).getOrThrow()[0]
-                }
-            }
+    private fun fieldsFor(tab: BookSourceEditTab) = FIELD_SPECS.getValue(tab).map { spec ->
+        BookSourceEditFieldUi(spec.path, spec.labelRes, spec.label, draftJson.stringAt(spec.path))
+    }.toImmutableList()
 
-            text.isJsonObject() -> {
-                if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
-                    val jsonItem = jsonPath.parse(text)
-                    ImportOldData.fromOldBookSource(jsonItem)
-                } else {
-                    GSON.fromJsonObject<BookSource>(text).getOrThrow()
-                }
-            }
+    private fun fieldGroups() =
+        BookSourceEditTab.entries.associateWith(::fieldsFor).toImmutableMap()
 
-            else -> throw NoStackTraceException("格式不对")
-        }
-    }
+    private data class FieldSpec(
+        val path: String,
+        val labelRes: Int? = null,
+        val label: String? = null
+    )
 
-    fun clearCookie(url: String) {
-        execute {
-            CookieStore.removeCookie(url)
-        }
+    companion object {
+        private fun f(path: String, label: Int) = FieldSpec(path, labelRes = label)
+        private val FIELD_SPECS = mapOf(
+            BookSourceEditTab.Base to listOf(
+                f("bookSourceUrl", R.string.source_url),
+                f("bookSourceName", R.string.source_name),
+                f("bookSourceGroup", R.string.source_group),
+                f("bookSourceComment", R.string.comment),
+                f("loginUrl", R.string.login_url),
+                f("loginUi", R.string.login_ui),
+                f("loginCheckJs", R.string.login_check_js),
+                f("coverDecodeJs", R.string.cover_decode_js),
+                f("bookUrlPattern", R.string.book_url_pattern),
+                f("header", R.string.source_http_header),
+                f("variableComment", R.string.variable_comment),
+                f("concurrentRate", R.string.concurrent_rate),
+                FieldSpec("jsLib", label = "jsLib"),
+            ),
+            BookSourceEditTab.Search to listOf(
+                f("searchUrl", R.string.r_search_url),
+                f("ruleSearch.checkKeyWord", R.string.check_key_word),
+                f("ruleSearch.bookList", R.string.r_book_list),
+                f("ruleSearch.name", R.string.r_book_name),
+                f("ruleSearch.author", R.string.r_author),
+                f("ruleSearch.kind", R.string.rule_book_kind),
+                f("ruleSearch.wordCount", R.string.rule_word_count),
+                f("ruleSearch.lastChapter", R.string.rule_last_chapter),
+                f("ruleSearch.intro", R.string.rule_book_intro),
+                f("ruleSearch.coverUrl", R.string.rule_cover_url),
+                f("ruleSearch.bookUrl", R.string.r_book_url),
+            ),
+            BookSourceEditTab.Explore to listOf(
+                f("exploreUrl", R.string.r_find_url),
+                f("ruleExplore.bookList", R.string.r_book_list),
+                f("ruleExplore.name", R.string.r_book_name),
+                f("ruleExplore.author", R.string.r_author),
+                f("ruleExplore.kind", R.string.rule_book_kind),
+                f("ruleExplore.wordCount", R.string.rule_word_count),
+                f("ruleExplore.lastChapter", R.string.rule_last_chapter),
+                f("ruleExplore.intro", R.string.rule_book_intro),
+                f("ruleExplore.coverUrl", R.string.rule_cover_url),
+                f("ruleExplore.bookUrl", R.string.r_book_url),
+                f("homepageModules", R.string.homepage_modules),
+            ),
+            BookSourceEditTab.Info to listOf(
+                f("ruleBookInfo.init", R.string.rule_book_info_init),
+                f("ruleBookInfo.name", R.string.r_book_name),
+                f("ruleBookInfo.author", R.string.r_author),
+                f("ruleBookInfo.kind", R.string.rule_book_kind),
+                f("ruleBookInfo.wordCount", R.string.rule_word_count),
+                f("ruleBookInfo.lastChapter", R.string.rule_last_chapter),
+                f("ruleBookInfo.intro", R.string.rule_book_intro),
+                f("ruleBookInfo.coverUrl", R.string.rule_cover_url),
+                f("ruleBookInfo.tocUrl", R.string.rule_toc_url),
+                f("ruleBookInfo.canReName", R.string.rule_can_re_name),
+                f("ruleBookInfo.downloadUrls", R.string.download_url_rule),
+                f("ruleBookInfo.relatedBooks", R.string.related_books),
+            ),
+            BookSourceEditTab.Toc to listOf(
+                f("ruleToc.preUpdateJs", R.string.pre_update_js),
+                f("ruleToc.chapterList", R.string.rule_chapter_list),
+                f("ruleToc.chapterName", R.string.rule_chapter_name),
+                f("ruleToc.chapterUrl", R.string.rule_chapter_url),
+                f("ruleToc.formatJs", R.string.format_js_rule),
+                f("ruleToc.isVolume", R.string.rule_is_volume),
+                f("ruleToc.updateTime", R.string.rule_update_time),
+                f("ruleToc.isVip", R.string.rule_is_vip),
+                f("ruleToc.isPay", R.string.rule_is_pay),
+                f("ruleToc.nextTocUrl", R.string.rule_next_toc_url),
+            ),
+            BookSourceEditTab.Content to listOf(
+                f("ruleContent.content", R.string.rule_book_content),
+                f("ruleContent.subContent", R.string.rule_sub_content),
+                f("ruleContent.title", R.string.rule_chapter_name),
+                f("ruleContent.nextContentUrl", R.string.rule_next_content),
+                f("ruleContent.webJs", R.string.rule_web_js),
+                f("ruleContent.sourceRegex", R.string.rule_source_regex),
+                f("ruleContent.replaceRegex", R.string.rule_replace_regex),
+                f("ruleContent.imageStyle", R.string.rule_image_style),
+                f("ruleContent.imageDecode", R.string.rule_image_decode),
+                f("ruleContent.payAction", R.string.rule_pay_action),
+                f("ruleContent.callBackJs", R.string.rule_call_back),
+            ),
+        )
     }
+}
 
-    fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? {
-        if (autoComplete) {
-            return RuleComplete.autoComplete(rule, preRule, type)
-        }
-        return rule
+private fun JsonObject.stringAt(path: String): String {
+    val parts = path.split('.')
+    var current: JsonObject = this
+    parts.dropLast(1).forEach {
+        val child = current.get(it)
+        if (child == null || !child.isJsonObject) return ""
+        current = child.asJsonObject
     }
+    return current.get(parts.last())?.takeUnless { it.isJsonNull }?.asString.orEmpty()
+}
 
-}
+private fun JsonObject.setPath(path: String, value: String) {
+    val parts = path.split('.')
+    var current = this
+    parts.dropLast(1).forEach { part ->
+        val child = current.get(part)
+        current = if (child != null && child.isJsonObject) child.asJsonObject
+        else JsonObject().also { current.add(part, it) }
+    }
+    if (value.isBlank()) current.remove(parts.last()) else current.addProperty(parts.last(), value)
+}

+ 0 - 127
app/src/main/res/layout/activity_book_source_edit.xml

@@ -1,127 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:title="@string/edit_book_source" />
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none"
-        android:paddingHorizontal="12dp">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:gravity="center_vertical"
-            android:orientation="horizontal"
-            tools:ignore="VisualLintBounds">
-
-            <TextView
-                android:id="@+id/tv_book_type"
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/sp_type"
-                android:text="@string/book_type"
-                tools:ignore="RtlHardcoded" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/sp_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:contentDescription="@string/book_type"
-                android:entries="@array/book_type"
-                android:prompt="@string/book_type" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/is_enable" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_explore"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/discovery" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_cookie"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/auto_save_cookie" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_review"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/review"
-                android:visibility="gone" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none"
-        android:paddingHorizontal="12dp">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:gravity="center_vertical"
-            android:orientation="horizontal"
-            tools:ignore="VisualLintBounds">
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_event_listener"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/is_event_listener" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_custom_button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/custom_button" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <com.google.android.material.tabs.TabLayout
-        android:id="@+id/tab_layout"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingStart="12dp"
-        tools:ignore="SpeakableTextPresentCheck" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:paddingHorizontal="16dp"
-        android:clipToPadding="false"
-        tools:listitem="@layout/item_source_edit" />
-
-</LinearLayout>

+ 1 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -709,7 +709,7 @@
     <string name="refresh_cover">刷新封面</string>
     <string name="change_cover_source">封面换源</string>
     <string name="select_local_image">选择本地图片</string>
-    <string name="book_type">类型:</string>
+    <string name="book_type">类型 :</string>
     <string name="to_backstage">后台</string>
     <string name="importing">正在导入</string>
     <string name="exporting">正在导出</string>