diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d8a02238..aa45dc91b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -176,7 +176,8 @@ + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize|stateHidden" /> () + private val _isSearching = MutableStateFlow(false) + val isSearching = _isSearching.asStateFlow() var name: String = "" var author: String = "" val searchBooks: MutableList = Collections.synchronizedList(arrayListOf()) @@ -94,6 +98,11 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application } } + fun initData(name: String, author: String) { + this.name = name + this.author = author.replace(AppPattern.authorRegex, "") + } + private fun initSearchPool() { searchPool = Executors .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() @@ -121,12 +130,14 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application } }.onStart { searchStateData.postValue(true) + _isSearching.value = true }.mapParallelSafe(threadCount) { withTimeout(60000L) { search(it) } }.onCompletion { searchStateData.postValue(false) + _isSearching.value = false }.catch { AppLog.put("封面换源搜索出错\n${it.localizedMessage}", it) }.collect() @@ -156,10 +167,11 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application } } - private fun stopSearch() { + fun stopSearch() { task?.cancel() searchPool?.close() searchStateData.postValue(false) + _isSearching.value = false } override fun onCleared() { @@ -167,4 +179,4 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application searchPool?.close() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt new file mode 100644 index 000000000..21bf22a95 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt @@ -0,0 +1,65 @@ +package io.legado.app.ui.book.changesource + +import android.app.Application +import io.legado.app.data.entities.SearchBook +import kotlinx.coroutines.flow.StateFlow + +class ChangeBookSourceComposeViewModel(application: Application) : + ChangeBookSourceViewModel(application) { + + val searchGroup: String + get() = ChangeSourceConfig.searchGroup + + val checkAuthor: Boolean + get() = ChangeSourceConfig.checkAuthor + + val loadInfo: Boolean + get() = ChangeSourceConfig.loadInfo + + val loadToc: Boolean + get() = ChangeSourceConfig.loadToc + + val loadWordCount: Boolean + get() = ChangeSourceConfig.loadWordCount + + fun onSearchGroupSelected(group: String) { + if (ChangeSourceConfig.searchGroup == group) return + ChangeSourceConfig.searchGroup = group + if (refresh()) startSearch() + } + + fun onCheckAuthorChange(enabled: Boolean) { + if (ChangeSourceConfig.checkAuthor == enabled) return + ChangeSourceConfig.checkAuthor = enabled + refresh() + } + + fun onLoadInfoChange(enabled: Boolean) { + if (ChangeSourceConfig.loadInfo == enabled) return + ChangeSourceConfig.loadInfo = enabled + } + + fun onLoadTocChange(enabled: Boolean) { + if (ChangeSourceConfig.loadToc == enabled) return + ChangeSourceConfig.loadToc = enabled + } + + fun onLoadWordCountChange(enabled: Boolean) { + if (ChangeSourceConfig.loadWordCount == enabled) return + ChangeSourceConfig.loadWordCount = enabled + if (enabled) { + onLoadWordCountChecked(true) + } else { + refresh() + } + } + + fun bookScoreFlow(searchBook: SearchBook): StateFlow { + return ObservableSourceConfig.bookScoreFlow(searchBook) + } + + fun onBookScoreClick(searchBook: SearchBook) { + val currentScore = ObservableSourceConfig.getBookScore(searchBook) + setBookScore(searchBook, if (currentScore > 0) 0 else 1) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt index ed2f54e1c..2f8a37956 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt @@ -62,6 +62,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a private val threadCount = AppConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null val searchStateData = MutableLiveData() + private val _isSearching = MutableStateFlow(false) + val isSearching = _isSearching.asStateFlow() var searchFinishCallback: ((isEmpty: Boolean) -> Unit)? = null var name: String = "" var author: String = "" @@ -163,6 +165,19 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } } + @CallSuper + open fun initData( + name: String, + author: String, + book: Book?, + fromReadBookActivity: Boolean + ) { + this.name = name + this.author = author.replace(AppPattern.authorRegex, "") + this.fromReadBookActivity = fromReadBookActivity + oldBook = book + } + private fun initSearchPool() { searchPool = Executors .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() @@ -234,6 +249,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } }.onStart { searchStateData.postValue(true) + _isSearching.value = true }.mapParallel(threadCount) { try { withTimeout(60000L) { @@ -250,6 +266,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a }.onCompletion { ensureActive() searchStateData.postValue(false) + _isSearching.value = false searchFinishCallback?.invoke(searchBooks.isEmpty()) }.catch { AppLog.put("换源搜索出错\n${it.localizedMessage}", it) @@ -383,6 +400,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } }.onStart { searchStateData.postValue(true) + _isSearching.value = true }.mapParallelSafe(threadCount) { val source = appDb.bookSourceDao.getBookSource(it.origin)!! withTimeout(60000L) { @@ -390,6 +408,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } }.onCompletion { searchStateData.postValue(false) + _isSearching.value = false }.catch { AppLog.put("换源刷新列表出错\n${it.localizedMessage}", it) }.collect() @@ -446,6 +465,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a task?.cancel() searchPool?.close() searchStateData.postValue(false) + _isSearching.value = false } fun getToc( @@ -554,10 +574,8 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } fun setBookScore(searchBook: SearchBook, score: Int) { - execute { - SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score) - searchCallback?.upAdapter() - } + SourceConfig.setBookScore(searchBook.origin, searchBook.name, searchBook.author, score) + searchCallback?.upAdapter() } fun getBookScore(searchBook: SearchBook): Int { @@ -577,4 +595,4 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt new file mode 100644 index 000000000..7058dbdcd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt @@ -0,0 +1,32 @@ +package io.legado.app.ui.book.changesource + +import io.legado.app.constant.PreferKey +import io.legado.app.ui.config.prefDelegate + +object ChangeSourceConfig { + + var searchGroup by prefDelegate( + key = "searchGroup", + defaultValue = "" + ) + + var checkAuthor by prefDelegate( + key = PreferKey.changeSourceCheckAuthor, + defaultValue = false + ) + + var loadInfo by prefDelegate( + key = PreferKey.changeSourceLoadInfo, + defaultValue = false + ) + + var loadToc by prefDelegate( + key = PreferKey.changeSourceLoadToc, + defaultValue = false + ) + + var loadWordCount by prefDelegate( + key = PreferKey.changeSourceLoadWordCount, + defaultValue = false + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ObservableSourceConfig.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ObservableSourceConfig.kt new file mode 100644 index 000000000..88f8ca706 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ObservableSourceConfig.kt @@ -0,0 +1,71 @@ +package io.legado.app.ui.book.changesource + +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.config.SourceConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import splitties.init.appCtx +import java.util.concurrent.ConcurrentHashMap + +object ObservableSourceConfig : SharedPreferences.OnSharedPreferenceChangeListener { + + private val sp = appCtx.getSharedPreferences("SourceConfig", MODE_PRIVATE) + private val bookScoreFlows = ConcurrentHashMap>() + private val sourceScoreFlows = ConcurrentHashMap>() + + init { + sp.registerOnSharedPreferenceChangeListener(this) + } + + fun bookScoreFlow(searchBook: SearchBook): StateFlow { + return bookScoreFlow(searchBook.origin, searchBook.name, searchBook.author) + } + + fun bookScoreFlow(origin: String, name: String, author: String): StateFlow { + val key = bookScoreKey(origin, name, author) + return bookScoreFlows.getOrPut(key) { + MutableStateFlow(sp.getInt(key, 0)) + }.asStateFlow() + } + + fun sourceScoreFlow(origin: String): StateFlow { + return sourceScoreFlows.getOrPut(origin) { + MutableStateFlow(sp.getInt(origin, 0)) + }.asStateFlow() + } + + fun getBookScore(searchBook: SearchBook): Int { + return bookScoreFlow(searchBook).value + } + + fun setBookScore(searchBook: SearchBook, score: Int) { + setBookScore(searchBook.origin, searchBook.name, searchBook.author, score) + } + + fun setBookScore(origin: String, name: String, author: String, score: Int) { + SourceConfig.setBookScore(origin, name, author, score) + syncBookScore(bookScoreKey(origin, name, author)) + syncSourceScore(origin) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + key ?: return + syncBookScore(key) + syncSourceScore(key) + } + + private fun syncBookScore(key: String) { + bookScoreFlows[key]?.value = sp.getInt(key, 0) + } + + private fun syncSourceScore(origin: String) { + sourceScoreFlows[origin]?.value = sp.getInt(origin, 0) + } + + private fun bookScoreKey(origin: String, name: String, author: String): String { + return "${origin}_${name}_${author}" + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt index 8410f6d5a..674b88e8f 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -1,131 +1,43 @@ package io.legado.app.ui.book.info -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Context import android.content.Intent -import android.content.res.ColorStateList -import android.content.res.Configuration -import android.graphics.Color -import android.graphics.RenderEffect -import android.graphics.Shader -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.TransitionDrawable -import android.net.Uri -import android.os.Build import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.CheckBox -import android.widget.LinearLayout -import androidx.activity.addCallback -import androidx.core.app.ActivityCompat -import androidx.core.app.ActivityOptionsCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.scale -import androidx.core.view.doOnPreDraw -import androidx.lifecycle.lifecycleScope -import androidx.transition.TransitionManager -import com.google.android.material.color.DynamicColors -import com.google.android.material.color.DynamicColorsOptions -import com.google.android.material.color.MaterialColors -import com.google.android.material.transition.platform.MaterialContainerTransform -import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.BookType -import io.legado.app.data.appDb -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter -import io.legado.app.data.entities.BookSource -import io.legado.app.databinding.ActivityBookInfoBinding -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.help.book.addType +import io.legado.app.base.BaseComposeActivity +import io.legado.app.help.config.AppConfig 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.isLocalTxt -import io.legado.app.help.book.isWebFile -import io.legado.app.help.book.removeType -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.LocalConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.dialogs.selector -import io.legado.app.model.BookCover import io.legado.app.model.SourceCallBack -import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.audio.AudioPlayActivity -import io.legado.app.ui.book.changecover.ChangeCoverDialog -import io.legado.app.ui.book.changesource.ChangeBookSourceDialog -import io.legado.app.ui.book.group.GroupSelectDialog import io.legado.app.ui.book.info.edit.BookInfoEditActivity import io.legado.app.ui.book.manga.ReadMangaActivity import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.file.HandleFileContract import io.legado.app.ui.login.SourceLoginActivity -import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.ui.widget.dialog.VariableDialog -import io.legado.app.ui.widget.dialog.WaitDialog -import io.legado.app.utils.ConvertUtils -import io.legado.app.utils.FileDoc -import io.legado.app.utils.GSON import io.legado.app.utils.StartActivityContract -import io.legado.app.utils.ToolbarUtils.setAllIconsColor -import io.legado.app.utils.applyNavigationBarMargin -import io.legado.app.utils.dpToPx -import io.legado.app.utils.gone -import io.legado.app.utils.longToastOnUi import io.legado.app.utils.openFileUri import io.legado.app.utils.sendToClip import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.viewmodel.ext.android.viewModel -class BookInfoActivity : - VMBaseActivity(), - GroupSelectDialog.CallBack, - ChangeBookSourceDialog.CallBack, - ChangeCoverDialog.CallBack, - VariableDialog.Callback { +class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { - companion object { - private const val READ_BOOK_REQUEST_CODE = 1001 - } + private val viewModel: BookInfoViewModel by viewModel() private val tocActivityResult = registerForActivityResult(TocActivityResult()) { - it?.let { - viewModel.getBook(false)?.let { book -> - lifecycleScope.launch { - withContext(IO) { - book.durChapterIndex = it.first - book.durChapterPos = it.second - chapterChanged = it.third - appDb.bookDao.update(book) - } - startReadActivity(book, binding.btnRead) - } - } - } ?: let { - if (!viewModel.inBookshelf) { - viewModel.delBook() - } - } + viewModel.onTocResult(it) } private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) { it.uri?.let { treeUri -> @@ -136,1036 +48,145 @@ class BookInfoActivity : StartActivityContract(BookInfoEditActivity::class.java) ) { if (it.resultCode == RESULT_OK) { - viewModel.upEditBook() + viewModel.onInfoEdited() } } private val editSourceResult = registerForActivityResult( StartActivityContract(BookSourceEditActivity::class.java) ) { - if (it.resultCode == RESULT_CANCELED) { - return@registerForActivityResult - } - book?.let { book -> - viewModel.bookSource = appDb.bookSourceDao.getBookSource(book.origin) - viewModel.refreshBook(book) + if (it.resultCode != RESULT_CANCELED) { + viewModel.onSourceEdited() } } - private var surfaceFinalColor: Int = 0 - private var surfaceContainerFinalColor: Int = 0 - private var secondaryFinalColor: Int = 0 - private var onSurfaceFinalColor: Int = 0 - private var secondaryContainerFinalColor: Int = 0 - private var primaryFinalColor: Int = 0 - private var tertiaryFinalColor: Int = 0 - private var currentJob: Job? = null - private var wrappedContext: Context? = null - private var chapterChanged = false - private val waitDialog by lazy { WaitDialog(this) } - private var editMenuItem: MenuItem? = null - private val book get() = viewModel.getBook(false) + private val readBookResult = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + viewModel.onReaderResult(it.resultCode) + } - override val binding by viewBinding(ActivityBookInfoBinding::inflate) - override val viewModel: BookInfoViewModel by viewModel() - - @SuppressLint("PrivateResource") override fun onCreate(savedInstanceState: Bundle?) { - setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback()) - setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback()) - val transform = MaterialContainerTransform().apply { - addTarget(binding.cdCov) - scrimColor = Color.TRANSPARENT - } - window.sharedElementEnterTransition = transform - window.sharedElementReturnTransition = transform super.onCreate(savedInstanceState) - setupBackCallback() - surfaceFinalColor = - MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, -1) - secondaryFinalColor = - MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondary, -1) - onSurfaceFinalColor = - MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, -1) - surfaceContainerFinalColor = MaterialColors.getColor( - this, - com.google.android.material.R.attr.colorSurfaceContainer, - -1 - ) - secondaryContainerFinalColor = MaterialColors.getColor( - this, - com.google.android.material.R.attr.colorSecondaryContainer, - -1 - ) - primaryFinalColor = MaterialColors.getColor( - this, - androidx.appcompat.R.attr.colorPrimary, - -1 - ) - tertiaryFinalColor = MaterialColors.getColor( - this, - com.google.android.material.R.attr.colorTertiary, - -1 - ) - binding.cdCov.transitionName = intent.getStringExtra("transitionName") - binding.cdCov.doOnPreDraw { - startPostponedEnterTransition() - } - setSupportActionBar(binding.topBar) - - binding.scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - if (scrollY > oldScrollY) binding.btnRead.shrink() - else if (scrollY < oldScrollY) binding.btnRead.extend() - } - - binding.btnRead.applyNavigationBarMargin(true) - binding.btnShelf.text = getString(R.string.remove_from_bookshelf) - binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) - - binding.tvDetail.revealOnFocusHint = false - - viewModel.bookData.observe(this) { showBook(it) } - viewModel.chapterListData.observe(this) { upLoading(false, it) } - viewModel.waitDialogData.observe(this) { upWaitDialogStatus(it) } viewModel.initData(intent) - initViewEvent() } - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_info, menu) - editMenuItem = menu.findItem(R.id.menu_edit) - return super.onCompatCreateOptionsMenu(menu) - } + @Composable + override fun Content() { + val state = viewModel.uiState.collectAsStateWithLifecycle().value - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - menu.findItem(R.id.menu_can_update)?.isChecked = - viewModel.bookData.value?.canUpdate ?: true - menu.findItem(R.id.menu_split_long_chapter)?.isChecked = - viewModel.bookData.value?.getSplitLongChapter() ?: true - menu.findItem(R.id.menu_login)?.isVisible = - !viewModel.bookSource?.loginUrl.isNullOrBlank() - menu.findItem(R.id.menu_set_source_variable)?.isVisible = - viewModel.bookSource != null - menu.findItem(R.id.menu_set_book_variable)?.isVisible = - viewModel.bookSource != null - menu.findItem(R.id.menu_can_update)?.isVisible = - viewModel.bookSource != null - menu.findItem(R.id.menu_split_long_chapter)?.isVisible = - viewModel.bookData.value?.isLocalTxt ?: false - menu.findItem(R.id.menu_upload)?.isVisible = - viewModel.bookData.value?.isLocal ?: false - menu.findItem(R.id.menu_delete_alert)?.isChecked = - LocalConfig.bookInfoDeleteAlert - return super.onMenuOpened(featureId, menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_edit -> { - viewModel.getBook()?.let { - infoEditResult.launch { - putExtra("bookUrl", it.bookUrl) + LaunchedEffect(Unit) { + viewModel.effects.collectLatest { effect -> + when (effect) { + is BookInfoEffect.Finish -> { + effect.resultCode?.let { setResult(it) } + if (effect.afterTransition) finishAfterTransition() else finish() } - } - } - R.id.menu_share_it -> { - viewModel.getBook()?.let { - SourceCallBack.callBackBtn( - this, - SourceCallBack.CLICK_SHARE_BOOK, - viewModel.bookSource, - it, - null - ) { - val bookJson = GSON.toJson(it) - val shareStr = "${it.bookUrl}#$bookJson" - val intent = Intent(Intent.ACTION_SEND) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra(Intent.EXTRA_TEXT, shareStr) - intent.type = "text/plain" - startActivity(Intent.createChooser(intent, it.name)) - } - } - } - - R.id.menu_refresh -> { - refreshBook() - } - - R.id.menu_sync_remote -> { - viewModel.syncFromRemote() - } - - R.id.menu_login -> viewModel.bookSource?.let { - startActivity { - putExtra("type", "bookSource") - putExtra("key", it.bookSourceUrl) - } - } - - R.id.menu_top -> viewModel.topBook() - R.id.menu_set_source_variable -> setSourceVariable() - R.id.menu_set_book_variable -> setBookVariable() - R.id.menu_copy_book_url -> viewModel.getBook()?.let { - SourceCallBack.callBackBtn( - this, - SourceCallBack.CLICK_COPY_BOOK_URL, - viewModel.bookSource, - it, - null - ) { - sendToClip(it.bookUrl) - } - } - - R.id.menu_copy_toc_url -> viewModel.getBook()?.let { - SourceCallBack.callBackBtn( - this, - SourceCallBack.CLICK_COPY_TOC_URL, - viewModel.bookSource, - it, - null - ) { - sendToClip(it.tocUrl) - } - } - - R.id.menu_can_update -> { - viewModel.getBook()?.let { - it.canUpdate = !it.canUpdate - if (viewModel.inBookshelf) { - if (!it.canUpdate) { - it.removeType(BookType.updateError) + is BookInfoEffect.OpenBookInfoEdit -> { + infoEditResult.launch { + putExtra("bookUrl", effect.bookUrl) } - viewModel.saveBook(it) } - } - } - R.id.menu_clear_cache -> viewModel.getBook()?.let { - SourceCallBack.callBackBtn( - this, - SourceCallBack.CLICK_CLEAR_CACHE, - viewModel.bookSource, - it, - null - ) { - viewModel.clearCache(it) - } - } - - R.id.menu_log -> showDialogFragment() - R.id.menu_split_long_chapter -> { - upLoading(true) - viewModel.getBook()?.let { - it.setSplitLongChapter(!item.isChecked) - viewModel.loadBookInfo(it, false) - } - item.isChecked = !item.isChecked - if (!item.isChecked) longToastOnUi(R.string.need_more_time_load_content) - } - - R.id.menu_delete_alert -> LocalConfig.bookInfoDeleteAlert = !item.isChecked - R.id.menu_upload -> { - viewModel.getBook()?.let { book -> - viewModel.uploadBook(book) { - toastOnUi("上传成功") - } - } - } - } - return super.onCompatOptionsItemSelected(item) - } - - override fun observeLiveBus() { - viewModel.actionLive.observe(this) { - when (it) { - "selectBooksDir" -> localBookTreeSelect.launch { - title = getString(R.string.select_book_folder) - } - } - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == READ_BOOK_REQUEST_CODE) { - when (resultCode) { - RESULT_OK -> { - viewModel.inBookshelf = true - upTvBookshelf() - } - - RESULT_DELETED -> { - setResult(RESULT_OK) - finish() - } - } - } - } - - private fun setupBackCallback() { - onBackPressedDispatcher.addCallback(this) { - if (!viewModel.inBookshelf && AppConfig.showAddToShelfAlert) { - viewModel.getBook()?.let { book -> - alert(title = getString(R.string.add_to_bookshelf)) { - setMessage(getString(R.string.check_add_bookshelf, book.name)) - okButton { - book.removeType(BookType.notShelf) - book.save() - viewModel.inBookshelf = true - super.finishAfterTransition() + is BookInfoEffect.OpenReader -> startReadActivity( + book = effect.book, + inBookshelf = effect.inBookshelf, + chapterChanged = effect.chapterChanged, + ) + is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl) + is BookInfoEffect.OpenBookSourceEdit -> { + editSourceResult.launch { + putExtra("sourceUrl", effect.sourceUrl) } - noButton { super.finishAfterTransition() } } - } - } else { - finishAfterTransition() - } - } - } - private fun refreshBook() { - upLoading(true) - viewModel.getBook()?.let { - viewModel.refreshBook(it) - } - } + is BookInfoEffect.OpenSourceLogin -> { + startActivity { + putExtra("type", "bookSource") + putExtra("key", effect.sourceUrl) + } + } - private fun showBook(book: Book) = binding.run { - showCover(book) - addColorScheme(binding.ivCover.drawable) - tvName.text = book.name - tvRemark.text = book.remark - if (book.remark.isNullOrEmpty()) - cdRemark.gone() - else - cdRemark.visible() - tvAuthor.text = getString(R.string.author_show, book.getRealAuthor()) - tvOrigin.text = getString(R.string.origin_show, book.originName) - tvLasted.text = getString(R.string.lasted_show, book.latestChapterTitle) - tvChapter.text = getString(R.string.read_chapter_total, book.totalChapterNum) - if (book.durChapterIndex + 1 == book.totalChapterNum) - tvChapterIndex.text = "已读完" - else - tvChapterIndex.text = getString(R.string.read_chapter_index, book.durChapterIndex + 1) - tvDetail.text = book.getDisplayIntro() - tvToc.visible(!book.isWebFile) - upTvBookshelf() - upKinds(book) - upGroup(book.group) - } + BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch { + title = getString(R.string.select_book_folder) + } - private fun upKinds(book: Book) = binding.run { - lifecycleScope.launch { - var kinds = book.getKindList() - if (book.isLocal) { - withContext(IO) { - val size = FileDoc.fromFile(book.bookUrl).size - if (size > 0) { - kinds = kinds.toMutableList() - kinds.add(ConvertUtils.formatFileSize(size)) + is BookInfoEffect.OpenFile -> openFileUri(effect.uri, effect.mimeType) + is BookInfoEffect.RunSourceCallback -> runSourceCallback(effect) + is BookInfoEffect.ShowVariableDialog -> { + showDialogFragment( + VariableDialog( + effect.title, + effect.key, + effect.variable, + effect.comment, + ) + ) } } } - if (kinds.isEmpty()) { - lbKind.gone() - } else { - lbKind.visible() - lbKind.setLabels(kinds) {} - } } - } - private fun showCover(book: Book) { - binding.ivCover.load( - book.getDisplayCover(), - book.name, - book.author, - false, - book.origin, - onLoadFinish = { - binding.ivCover.post { - val drawable = binding.ivCover.drawable - if (drawable != null) { - addColorScheme(drawable) - } - } - } + BookInfoScreen( + state = state, + onIntent = viewModel::onIntent, + onBack = { finishAfterTransition() }, ) - if (!AppConfig.isEInkMode) { - //高版本使用RenderEffect - BookCover.load(this, book.getDisplayCover(), false, book.origin) - .into(binding.bgBook) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - BookCover.load(this, book.getDisplayCover(), false, book.origin) - .into(binding.bgBook) - val blurEffect = RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP) - binding.bgBook.setRenderEffect(blurEffect) - }else{ - BookCover.loadBlur(this, book.getDisplayCover(), false, book.origin) - .into(binding.bgBook) - } - } - addColorScheme(binding.ivCover.drawable) - - } - - private fun addColorScheme(drawable: Drawable?) { - currentJob?.cancel() - currentJob = CoroutineScope(Dispatchers.Default).launch { - val bitmap = when (drawable) { - is BitmapDrawable -> drawable.bitmap - is TransitionDrawable -> (drawable.getDrawable(1) as? BitmapDrawable)?.bitmap - else -> null - } ?: return@launch - - val colorAccuracy = true - val targetWidth = if (colorAccuracy) (bitmap.width / 4).coerceAtMost(256) else 16 - val targetHeight = if (colorAccuracy) (bitmap.height / 4).coerceAtMost(256) else 16 - val scaledBitmap = bitmap.scale(targetWidth, targetHeight, false) - - val options = DynamicColorsOptions.Builder() - .setContentBasedSource(scaledBitmap) - .build() - - wrappedContext = DynamicColors.wrapContextIfAvailable( - this@BookInfoActivity, - options - ).apply { - resources.configuration.uiMode = - this@BookInfoActivity.resources.configuration.uiMode - } - - withContext(Dispatchers.Main) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - applyColorScheme() - } - } - } - } - - private suspend fun applyColorScheme() { - val ctx = wrappedContext ?: this - - val colorPrimary = - MaterialColors.getColor(ctx, androidx.appcompat.R.attr.colorPrimary, -1) - val colorSecondary = - MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondary, -1) - val colorTertiary = - MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorTertiary, -1) - val colorOnSurface = - MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, -1) - val colorSurface = - MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, -1) - val colorSurfaceContainer = - MaterialColors.getColor( - ctx, - com.google.android.material.R.attr.colorSurfaceContainerHighest, - -1 - ) - val colorSecondaryContainer = - MaterialColors.getColor( - ctx, - com.google.android.material.R.attr.colorSecondaryContainer, - -1 - ) - val colorOnTertiary = - MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnTertiary, -1) - - val surfaceTransition = ValueAnimator.ofArgb(surfaceFinalColor, colorSurface).apply { - duration = 400L - addUpdateListener { animation -> - val nightModeFlags = - resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val isDarkMode = nightModeFlags == Configuration.UI_MODE_NIGHT_YES - var color = animation.animatedValue as Int - if (AppConfig.pureBlack && isDarkMode) { - color = Color.BLACK - } - binding.cdInfo?.setCardBackgroundColor(color) - if (!AppConfig.isTransparent) - { - binding.llInfo.setBackgroundColor(color) - (binding.llCover.background as? GradientDrawable)?.colors = - intArrayOf(Color.TRANSPARENT, color) - } - } - } - - val surfaceContainerTransition = - ValueAnimator.ofArgb(surfaceContainerFinalColor, colorSurfaceContainer).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - binding.lbKind.applyColorScheme(color, colorOnSurface) - binding.collTopBar.setContentScrimColor(color) - binding.cdRemark.strokeColor = color - } - } - - val buttonTransition = ValueAnimator.ofArgb(secondaryFinalColor, colorSecondary).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - val tint = ColorStateList.valueOf(color) - listOf( - binding.btnShelf, - binding.tvTocView, - binding.tvChangeGroup, - binding.btnChangeSource - ).forEach { btn -> - btn.setTextColor(color) - btn.iconTint = tint - } - binding.div.setTextColor(color) - binding.tvChapterIndex.setTextColor(color) - binding.tvRemark.setTextColor(color) - } - } - - val backTransition = - ValueAnimator.ofArgb(secondaryContainerFinalColor, colorSecondaryContainer).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - if (!AppConfig.isTransparent) - binding.bgBookMask.setBackgroundColor(color) - } - } - - val textTransition = ValueAnimator.ofArgb(onSurfaceFinalColor, colorOnSurface).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - binding.tvName.setTextColor(color) - binding.tvAuthor.setTextColor(color) - binding.tvOrigin.setTextColor(color) - binding.tvDetail.setTextColor(color) - binding.tvToc.setTextColor(color) - binding.tvLasted.setTextColor(color) - binding.ivName.imageTintList = ColorStateList.valueOf(color) - binding.ivWeb.imageTintList = ColorStateList.valueOf(color) - } - } - - val primaryTransition = ValueAnimator.ofArgb(primaryFinalColor, colorPrimary).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - binding.tvChapter.setTextColor(color) - } - } - - val tertiaryTransition = ValueAnimator.ofArgb(tertiaryFinalColor, colorTertiary).apply { - duration = 400L - addUpdateListener { animation -> - val color = animation.animatedValue as Int - binding.btnRead.backgroundTintList = ColorStateList.valueOf(color) - } - } - - withContext(Dispatchers.Main) { - listOf( - surfaceTransition, - surfaceContainerTransition, - buttonTransition, - backTransition, - textTransition, - primaryTransition, - tertiaryTransition - ).forEach { it.start() } - } - - surfaceFinalColor = colorSurface - secondaryFinalColor = colorSecondary - onSurfaceFinalColor = colorOnSurface - surfaceContainerFinalColor = colorSurfaceContainer - primaryFinalColor = colorPrimary - tertiaryFinalColor = colorTertiary - secondaryContainerFinalColor = colorSecondaryContainer - - binding.topBar.setAllIconsColor(colorOnSurface) - binding.btnRead.setTextColor(colorOnTertiary) - binding.btnRead.iconTint = ColorStateList.valueOf(colorOnTertiary) - } - - private fun upLoading(isLoading: Boolean, chapterList: List? = null) { - when { - isLoading -> { - binding.tvToc.text = getString(R.string.toc_s, getString(R.string.loading)) - binding.tvTocView.text = getString(R.string.loading) - binding.tvTocView.isEnabled = false - } - - chapterList.isNullOrEmpty() -> { - binding.tvToc.text = getString( - R.string.toc_s, - getString(R.string.error_load_toc) - ) - binding.tvTocView.text = getString(R.string.error_load_toc) - binding.tvTocView.isEnabled = false - } - - else -> { - book?.let { - binding.tvToc.text = getString(R.string.toc_s, it.durChapterTitle) - binding.tvLasted.text = getString(R.string.lasted_show, it.latestChapterTitle) - binding.tvTocView.text = getString(R.string.view_toc) - binding.tvTocView.isEnabled = true - } - } - } - } - - private fun upTvBookshelf() { - binding.btnShelf.apply { - if (viewModel.inBookshelf) { - text = getString(R.string.remove_from_bookshelf) - icon = ContextCompat.getDrawable(context, R.drawable.ic_star_fill) - } else { - text = getString(R.string.add_to_bookshelf) - icon = ContextCompat.getDrawable(context, R.drawable.ic_star) - } - } - editMenuItem?.isVisible = viewModel.inBookshelf - } - - private fun upGroup(groupId: Long) { - viewModel.loadGroup(groupId) { - if (it.isNullOrEmpty()) { - binding.tvChangeGroup.text = if (book?.isLocal == true) { - getString(R.string.local_no_group) - } else { - getString(R.string.no_group) - } - binding.tvChangeGroup.setIconResource(R.drawable.ic_groups) - } else { - binding.tvChangeGroup.text = getString(R.string.group_s, it) - binding.tvChangeGroup.setIconResource(R.drawable.ic_groups_fill) - } - } - } - - private fun initViewEvent() = binding.run { - ivCover.setOnClickListener { - viewModel.getBook()?.let { - showDialogFragment( - ChangeCoverDialog(it.name, it.author) - ) - } - } - ivCover.setOnLongClickListener { - viewModel.getBook()?.getDisplayCover()?.let { path -> - showDialogFragment(PhotoDialog(path)) - } - true - } - btnRead.setOnClickListener { - viewModel.getBook()?.let { book -> - if (book.isWebFile) { - showWebFileDownloadAlert { - readBook(it) - } - } else { - readBook(book) - } - } - } - btnShelf.setOnClickListener { - viewModel.getBook()?.let { book -> - if (viewModel.inBookshelf) { - deleteBook() - } else { - if (book.isWebFile) { - showWebFileDownloadAlert() - } else { - viewModel.addToBookshelf { - upTvBookshelf() - } - } - } - } - } - tvOrigin.setOnClickListener { - viewModel.getBook()?.let { book -> - if (book.isLocal) return@let - if (!appDb.bookSourceDao.has(book.origin)) { - toastOnUi(R.string.error_no_source) - return@let - } - editSourceResult.launch { - putExtra("sourceUrl", book.origin) - } - } - } - btnChangeSource.setOnClickListener { - viewModel.getBook()?.let { book -> - showDialogFragment(ChangeBookSourceDialog(book.name, book.author)) - } - } - tvTocView.setOnClickListener { - if (viewModel.chapterListData.value.isNullOrEmpty()) { - toastOnUi(R.string.chapter_list_empty) - return@setOnClickListener - } - viewModel.getBook()?.let { book -> - if (!viewModel.inBookshelf) { - viewModel.saveBook(book) { - viewModel.saveChapterList { - openChapterList() - } - } - } else { - openChapterList() - } - } - } - tvChangeGroup.setOnClickListener { - viewModel.getBook()?.let { - showDialogFragment( - GroupSelectDialog(it.group) - ) - } - } - tvAuthor.setOnClickListener { - viewModel.getBook(false)?.let { book -> - SourceCallBack.callBackBtn( - this@BookInfoActivity, - SourceCallBack.CLICK_AUTHOR, - viewModel.bookSource, - book, - null - ) { - startActivity { - putExtra("key", book.author) - } - } - } - } - tvAuthor.setOnLongClickListener { - viewModel.getBook(false)?.let { book -> - SourceCallBack.callBackBtn( - this@BookInfoActivity, - SourceCallBack.LONG_CLICK_AUTHOR, - viewModel.bookSource, - book, - null - ) { - startActivity { - putExtra("key", book.author) - } - } - } - true - } - tvName.setOnClickListener { - viewModel.getBook(false)?.let { book -> - SourceCallBack.callBackBtn( - this@BookInfoActivity, - SourceCallBack.CLICK_BOOK_NAME, - viewModel.bookSource, - book, - null - ) { - startActivity { - putExtra("key", book.name) - } - } - } - } - tvName.setOnLongClickListener { - TransitionManager.beginDelayedTransition(scrollView) - tvName.maxLines = if (tvName.maxLines == 3) 10 else 3 - true - } - cdRemark.setOnClickListener { - alert(R.string.edit_remark) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editLayout.hint = "编辑备注" - editView.setText(book?.remark) - } - customView { alertBinding.root } - okButton { - viewModel.saveRemark(alertBinding.editView.text.toString()) - } - cancelButton() - } - } - refreshLayout.setOnRefreshListener { - refreshLayout.isRefreshing = false - refreshBook() - } - } - - private fun setSourceVariable() { - lifecycleScope.launch { - val source = viewModel.bookSource - if (source == null) { - toastOnUi("书源不存在") - return@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 setBookVariable() { - lifecycleScope.launch { - val source = viewModel.bookSource - if (source == null) { - toastOnUi("书源不存在") - return@launch - } - val book = viewModel.getBook() ?: return@launch - val variable = withContext(IO) { book.getCustomVariable() } - val comment = source.getDisplayVariableComment( - """书籍变量可在js中通过book.getVariable("custom")获取""" - ) - showDialogFragment( - VariableDialog( - getString(R.string.set_book_variable), - book.bookUrl, - variable, - comment - ) - ) - } } override fun setVariable(key: String, variable: String?) { - when (key) { - viewModel.bookSource?.getKey() -> viewModel.bookSource?.setVariable(variable) - viewModel.bookData.value?.bookUrl -> viewModel.bookData.value?.let { - it.putCustomVariable(variable) - if (viewModel.inBookshelf) { - viewModel.saveBook(it) + viewModel.setVariable(key, variable) + } + + private fun runSourceCallback(effect: BookInfoEffect.RunSourceCallback) { + SourceCallBack.callBackBtn( + this, + effect.event, + effect.source, + effect.book, + null, + ) { + when (val action = effect.action) { + is BookInfoCallbackAction.Search -> { + startActivity { + putExtra("key", action.keyword) + } + } + + is BookInfoCallbackAction.ShareText -> { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, action.text) + type = "text/plain" + } + startActivity(Intent.createChooser(intent, action.chooserTitle)) + } + + is BookInfoCallbackAction.CopyText -> { + sendToClip(action.text) + } + + BookInfoCallbackAction.ClearCache -> { + viewModel.clearCache() } } } } - @SuppressLint("InflateParams") - private fun deleteBook() { - viewModel.getBook()?.let { book -> - if (LocalConfig.bookInfoDeleteAlert) { - alert( - titleResource = R.string.draw, - messageResource = R.string.sure_del - ) { - var checkBox: CheckBox? = null - if (book.isLocal) { - checkBox = CheckBox(this@BookInfoActivity).apply { - setText(R.string.delete_book_file) - isChecked = LocalConfig.deleteBookOriginal - } - val view = LinearLayout(this@BookInfoActivity).apply { - setPadding(16.dpToPx(), 0, 16.dpToPx(), 0) - addView(checkBox) - } - customView { view } - } - yesButton { - if (checkBox != null) { - LocalConfig.deleteBookOriginal = checkBox.isChecked - } - SourceCallBack.callBackBook( - SourceCallBack.DEL_BOOK_SHELF, - viewModel.bookSource, - book - ) //确认后删除书架 - viewModel.delBook(LocalConfig.deleteBookOriginal) { - setResult(RESULT_OK) - finish() - } - } - noButton() - } - } else { - SourceCallBack.callBackBook( - SourceCallBack.DEL_BOOK_SHELF, - viewModel.bookSource, - book - ) //点按钮直接删除书架 - viewModel.delBook(LocalConfig.deleteBookOriginal) { - setResult(RESULT_OK) - finish() - } - } - } - } - - private fun openChapterList() { - viewModel.getBook()?.let { - tocActivityResult.launch(it.bookUrl) - } - } - - private fun showWebFileDownloadAlert( - onClick: ((Book) -> Unit)? = null, + private fun startReadActivity( + book: io.legado.app.data.entities.Book, + inBookshelf: Boolean, + chapterChanged: Boolean, ) { - val webFiles = viewModel.webFiles - if (webFiles.isEmpty()) { - toastOnUi("Unexpected webFileData") - return - } - selector( - R.string.download_and_import_file, - webFiles - ) { _, webFile, _ -> - if (webFile.isSupported) { - /* import */ - viewModel.importOrDownloadWebFile(webFile) { - onClick?.invoke(it) - } - } else if (webFile.isSupportDecompress) { - /* 解压筛选后再选择导入项 */ - viewModel.importOrDownloadWebFile(webFile) { uri -> - viewModel.getArchiveFilesName(uri) { fileNames -> - if (fileNames.size == 1) { - viewModel.importArchiveBook(uri, fileNames[0]) { - onClick?.invoke(it) - } - } else { - showDecompressFileImportAlert(uri, fileNames, onClick) - } - } - } - } else { - alert( - title = getString(R.string.draw), - message = getString(R.string.file_not_supported, webFile.name) - ) { - neutralButton(R.string.open_fun) { - /* download only */ - viewModel.importOrDownloadWebFile(webFile) { - openFileUri(it, "*/*") - } - } - noButton() - } - } - } - } - - private fun showDecompressFileImportAlert( - archiveFileUri: Uri, - fileNames: List, - success: ((Book) -> Unit)? = null, - ) { - if (fileNames.isEmpty()) { - toastOnUi(R.string.unsupport_archivefile_entry) - return - } - selector( - R.string.import_select_book, - fileNames - ) { _, name, _ -> - viewModel.importArchiveBook(archiveFileUri, name) { - success?.invoke(it) - } - } - } - - private fun readBook(book: Book) { - if (!viewModel.inBookshelf) { - book.addType(BookType.notShelf) - viewModel.saveBook(book) { - viewModel.saveChapterList { - startReadActivity(book, binding.btnRead) - } - } - } else { - viewModel.saveBook(book) { - startReadActivity(book, binding.btnRead) - } - } - } - - private fun startReadActivity(book: Book, sharedView: View) { - val transitionName = "book_${book.bookUrl}_${System.currentTimeMillis()}" - sharedView.transitionName = transitionName - val cls = when { book.isAudio -> AudioPlayActivity::class.java !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java else -> ReadBookActivity::class.java } - - val intent = Intent(this, cls).apply { - putExtra("bookUrl", book.bookUrl) - putExtra("inBookshelf", viewModel.inBookshelf) - putExtra("chapterChanged", chapterChanged) - putExtra("transitionName", transitionName) - } - - val options = ActivityOptionsCompat.makeSceneTransitionAnimation( - this, - sharedView, - transitionName + readBookResult.launch( + Intent(this, cls).apply { + putExtra("bookUrl", book.bookUrl) + putExtra("inBookshelf", inBookshelf) + putExtra("chapterChanged", chapterChanged) + } ) - ActivityCompat.startActivityForResult( - this, - intent, - READ_BOOK_REQUEST_CODE, - options.toBundle() - ) - } - - override val oldBook: Book? - get() = viewModel.bookData.value - - override fun changeTo(source: BookSource, book: Book, toc: List) { - viewModel.changeTo(source, book, toc) - } - - override fun coverChangeTo(coverUrl: String) { - viewModel.bookData.value?.let { book -> - book.customCoverUrl = coverUrl - showCover(book) - if (viewModel.inBookshelf) { - viewModel.saveBook(book) - } - } - } - - override fun upGroup(requestCode: Int, groupId: Long) { - upGroup(groupId) - viewModel.getBook()?.let { book -> - book.group = groupId - if (viewModel.inBookshelf) { - viewModel.saveBook(book) - } else if (groupId > 0) { - viewModel.addToBookshelf { - upTvBookshelf() - } - } - } - } - - private fun upWaitDialogStatus(isShow: Boolean) { - val showText = "Loading....." - if (isShow) { - waitDialog.run { - setText(showText) - show() - } - } else { - waitDialog.dismiss() - } - } - - override fun addToBookshelf(book: Book, toc: List) { - viewModel.addToBookshelf(book, toc) { - toastOnUi("已添加到书架") - } } } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt new file mode 100644 index 000000000..eb839f05b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -0,0 +1,154 @@ +package io.legado.app.ui.book.info + +import android.net.Uri +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource + +data class BookInfoUiState( + val book: Book? = null, + val chapterList: List = emptyList(), + val webFiles: List = emptyList(), + val kindLabels: List = emptyList(), + val groupNames: String? = null, + val inBookshelf: Boolean = false, + val bookSource: BookSource? = null, + val isTocLoading: Boolean = true, + val isBusy: Boolean = false, + val showAppLogSheet: Boolean = false, + val sheet: BookInfoSheet = BookInfoSheet.None, + val dialog: BookInfoDialog? = null, +) + +sealed interface BookInfoSheet { + data object None : BookInfoSheet + data object CoverPicker : BookInfoSheet + data object GroupPicker : BookInfoSheet + data object SourcePicker : BookInfoSheet + data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet + data class ArchiveEntries( + val archiveUri: Uri, + val entries: List, + val openAfterImport: Boolean, + ) : BookInfoSheet +} + +sealed interface BookInfoDialog { + data object AddToShelfOnBack : BookInfoDialog + data class DeleteBook(val isLocal: Boolean) : BookInfoDialog + data class EditRemark(val remark: String?) : BookInfoDialog + data class PhotoPreview(val path: String) : BookInfoDialog + data class UnsupportedWebFile( + val webFile: BookInfoWebFile, + val openAfterImport: Boolean, + ) : BookInfoDialog +} + +data class BookInfoWebFile( + val url: String, + val name: String, +) { + override fun toString(): String = name +} + +sealed interface BookInfoIntent { + data object BackPressed : BookInfoIntent + data object DismissSheet : BookInfoIntent + data object DismissDialog : BookInfoIntent + data object DismissAppLogSheet : BookInfoIntent + data class MenuAction(val action: BookInfoMenuAction) : BookInfoIntent + data class AuthorClick(val longClick: Boolean) : BookInfoIntent + data class BookNameClick(val longClick: Boolean) : BookInfoIntent + data object OriginClick : BookInfoIntent + data object ReadClick : BookInfoIntent + data object ShelfClick : BookInfoIntent + data object TocClick : BookInfoIntent + data object CoverClick : BookInfoIntent + data object CoverLongClick : BookInfoIntent + data object GroupClick : BookInfoIntent + data object ChangeSourceClick : BookInfoIntent + data object RemarkClick : BookInfoIntent + data object ConfirmBackAddToShelf : BookInfoIntent + data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent + data class UpdateRemark(val remark: String) : BookInfoIntent + data class SelectGroup(val groupId: Long) : BookInfoIntent + data class SelectCover(val coverUrl: String) : BookInfoIntent + data class ReplaceWithSource( + val source: BookSource, + val book: Book, + val toc: List, + ) : BookInfoIntent + data class AddSourceAsNewBook( + val book: Book, + val toc: List, + ) : BookInfoIntent + data class SelectWebFile( + val webFile: BookInfoWebFile, + val openAfterImport: Boolean, + ) : BookInfoIntent + data class OpenUnsupportedWebFile( + val webFile: BookInfoWebFile, + ) : BookInfoIntent + data class SelectArchiveEntry( + val archiveUri: Uri, + val entryName: String, + val openAfterImport: Boolean, + ) : BookInfoIntent +} + +sealed interface BookInfoEffect { + data class Finish( + val resultCode: Int? = null, + val afterTransition: Boolean = false, + ) : BookInfoEffect + + data class OpenBookInfoEdit(val bookUrl: String) : BookInfoEffect + data class OpenToc(val bookUrl: String) : BookInfoEffect + data class OpenReader( + val book: Book, + val inBookshelf: Boolean, + val chapterChanged: Boolean, + ) : BookInfoEffect + data class OpenBookSourceEdit(val sourceUrl: String) : BookInfoEffect + data class OpenSourceLogin(val sourceUrl: String) : BookInfoEffect + data object OpenSelectBooksDir : BookInfoEffect + data class OpenFile(val uri: Uri, val mimeType: String) : BookInfoEffect + data class RunSourceCallback( + val event: String, + val source: BookSource?, + val book: Book, + val action: BookInfoCallbackAction, + ) : BookInfoEffect + data class ShowVariableDialog( + val title: String, + val key: String, + val variable: String?, + val comment: String, + ) : BookInfoEffect +} + +sealed interface BookInfoCallbackAction { + data class Search(val keyword: String) : BookInfoCallbackAction + data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction + data class CopyText(val text: String) : BookInfoCallbackAction + data object ClearCache : BookInfoCallbackAction +} + +enum class BookInfoMenuAction { + Edit, + Share, + Upload, + SyncRemote, + Refresh, + Login, + Top, + SetSourceVariable, + SetBookVariable, + CopyBookUrl, + CopyTocUrl, + ToggleCanUpdate, + ToggleSplitLongChapter, + ToggleDeleteAlert, + ClearCache, + ShowLog, +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt new file mode 100644 index 000000000..76fd3e59d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -0,0 +1,1013 @@ +package io.legado.app.ui.book.info + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.FolderZip +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookGroup +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.book.isLocal +import io.legado.app.help.config.AppConfig +import io.legado.app.ui.about.AppLogSheet +import io.legado.app.ui.book.changecover.ChangeCoverViewModel +import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel +import io.legado.app.ui.book.group.GroupEditSheet +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ProvideThemeOverride +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.ThemeOverrideState +import io.legado.app.ui.theme.rememberImageSeedColor +import io.legado.app.ui.theme.rememberThemeOverride +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.TopBarActionButton +import io.legado.app.ui.widget.components.button.TopBarButtonVariant +import io.legado.app.ui.widget.components.button.TopBarNavigationButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.cover.BookCover +import io.legado.app.ui.widget.components.icon.AppIcon +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.text.AnimatedTextLine +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.MiuixGlassScrollBehavior +import io.legado.app.utils.StartActivityContract +import io.legado.app.utils.startActivity +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.koin.androidx.compose.koinViewModel +import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookInfoScreen( + state: BookInfoUiState, + onIntent: (BookInfoIntent) -> Unit, + onBack: () -> Unit, +) { + val bookColorTheme = rememberBookInfoColorTheme(state.book) + + BookInfoColorTheme(theme = bookColorTheme) { + BookInfoScreenContent( + state = state, + onIntent = onIntent, + onBack = onBack, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BookInfoScreenContent( + state: BookInfoUiState, + onIntent: (BookInfoIntent) -> Unit, + onBack: () -> Unit, +) { + val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) + val miuixScrollBehavior = if (isMiuix) { + GlassTopAppBarDefaults.defaultScrollBehavior() as? MiuixGlassScrollBehavior + } else { + null + } + val miuixCollapsedFraction = miuixScrollBehavior?.collapsedFraction ?: 0f + val listState = rememberLazyListState() + val colorThresholdPx = with(LocalDensity.current) { 6.dp.roundToPx() } + val showAppBarColor by remember(listState, isMiuix, colorThresholdPx, miuixCollapsedFraction) { + derivedStateOf { + if (isMiuix) { + miuixCollapsedFraction > 0.01f + } else { + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > colorThresholdPx + } + } + } + val appBarColor by animateColorAsState( + targetValue = if (showAppBarColor) { + LegadoTheme.colorScheme.surfaceContainer + } else { + LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0f) + }, + label = "book-info-top-bar-color", + ) + val pullState = rememberPullToRefreshState() + var showMenu by rememberSaveable { mutableStateOf(false) } + + BackHandler { onIntent(BookInfoIntent.BackPressed) } + + AppScaffold( + modifier = Modifier + .fillMaxSize() + .then( + if (miuixScrollBehavior != null) { + Modifier.nestedScroll(miuixScrollBehavior.nestedScrollConnection) + } else { + Modifier + } + ), + topBar = { + if (isMiuix) { + MiuixTopAppBar( + title = "", + color = appBarColor, + navigationIcon = { + TopBarNavigationButton( + onClick = { onIntent(BookInfoIntent.BackPressed) } + ) + }, + actions = { + BookInfoTopBarActions( + state = state, + showMenu = showMenu, + onShowMenuChange = { showMenu = it }, + onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) }, + ) + }, + scrollBehavior = miuixScrollBehavior?.miuixBehavior + ) + } else { + TopAppBar( + title = { + Text( + text = "", + maxLines = 1, + ) + }, + navigationIcon = { + TopBarNavigationButton( + onClick = { onIntent(BookInfoIntent.BackPressed) } + ) + }, + actions = { + BookInfoTopBarActions( + state = state, + showMenu = showMenu, + onShowMenuChange = { showMenu = it }, + onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) }, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = appBarColor, + scrolledContainerColor = appBarColor, + navigationIconContentColor = LegadoTheme.colorScheme.onSurface, + titleContentColor = LegadoTheme.colorScheme.onSurface, + actionIconContentColor = LegadoTheme.colorScheme.onSurface, + ), + ) + } + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { onIntent(BookInfoIntent.ReadClick) }, + containerColor = LegadoTheme.colorScheme.primaryContainer, + contentColor = LegadoTheme.colorScheme.onPrimaryContainer, + icon = { Icon(Icons.Default.Book, null) }, + text = { Text(stringResource(R.string.reading)) }, + ) + }, + ) { paddingValues -> + val book = state.book + if (book == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = LegadoTheme.colorScheme.primary, + trackColor = LegadoTheme.colorScheme.surfaceContainerHighest, + ) + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + BookInfoBackdrop(book) + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + state = pullState, + isRefreshing = state.isTocLoading, + onRefresh = { onIntent(BookInfoIntent.MenuAction(BookInfoMenuAction.Refresh)) }, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pullState, + isRefreshing = state.isTocLoading, + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + color = LegadoTheme.colorScheme.primary, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding()) + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 8.dp, + bottom = paddingValues.calculateBottomPadding() + 88.dp, + ), + ) { + item { + BookInfoHeader( + book = book, + kindLabels = state.kindLabels, + groupNames = state.groupNames, + onCoverClick = { onIntent(BookInfoIntent.CoverClick) }, + onCoverLongClick = { onIntent(BookInfoIntent.CoverLongClick) }, + onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) }, + onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) }, + onOriginClick = { onIntent(BookInfoIntent.OriginClick) }, + ) + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = LegadoTheme.colorScheme.surface + ) + .padding(bottom = 24.dp) + ) { + BookInfoActions( + inBookshelf = state.inBookshelf, + onShelfClick = { onIntent(BookInfoIntent.ShelfClick) }, + onTocClick = { onIntent(BookInfoIntent.TocClick) }, + onGroupClick = { onIntent(BookInfoIntent.GroupClick) }, + onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) }, + ) + BookInfoSummary( + book = book, + chapterList = state.chapterList, + onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) }, + ) + } + } + } + } + } + } + } + + val currentSheet = state.sheet + var renderedSheet by remember { mutableStateOf(BookInfoSheet.None) } + + LaunchedEffect(currentSheet) { + if (currentSheet == BookInfoSheet.None) { + delay(300) + renderedSheet = BookInfoSheet.None + } else { + renderedSheet = currentSheet + } + } + + when (val sheet = renderedSheet) { + BookInfoSheet.None -> Unit + BookInfoSheet.CoverPicker -> ChangeCoverSheet( + show = currentSheet == BookInfoSheet.CoverPicker, + name = state.book?.name.orEmpty(), + author = state.book?.author.orEmpty(), + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onSelect = { onIntent(BookInfoIntent.SelectCover(it)) }, + ) + BookInfoSheet.GroupPicker -> GroupSelectSheet( + show = currentSheet == BookInfoSheet.GroupPicker, + currentGroupId = state.book?.group ?: 0L, + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) }, + ) + BookInfoSheet.SourcePicker -> state.book?.let { book -> + ChangeSourceSheet( + show = currentSheet == BookInfoSheet.SourcePicker, + oldBook = book, + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onReplace = { source, newBook, toc -> + onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc)) + }, + onAddAsNew = { newBook, toc -> + onIntent(BookInfoIntent.AddSourceAsNewBook(newBook, toc)) + }, + ) + } + is BookInfoSheet.WebFiles -> WebFileSheet( + show = currentSheet is BookInfoSheet.WebFiles, + files = state.webFiles, + title = stringResource(R.string.download_and_import_file), + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onSelect = { onIntent(BookInfoIntent.SelectWebFile(it, sheet.openAfterImport)) }, + ) + is BookInfoSheet.ArchiveEntries -> WebFileSheet( + show = currentSheet is BookInfoSheet.ArchiveEntries, + files = sheet.entries.map { BookInfoWebFile(it, it) }, + title = stringResource(R.string.import_select_book), + onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, + onSelect = { + onIntent( + BookInfoIntent.SelectArchiveEntry( + archiveUri = sheet.archiveUri, + entryName = it.name, + openAfterImport = sheet.openAfterImport, + ) + ) + }, + ) + } + + BookInfoDialogs(state = state, onIntent = onIntent, onBack = onBack) +} + +@Composable +private fun BookInfoColorTheme( + theme: ThemeOverrideState?, + content: @Composable () -> Unit, +) { + ProvideThemeOverride(theme = theme, content = content) +} + +@Composable +private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { + val imageLoader = koinInject() + val coverPath = book?.getDisplayCover() + val sourceOrigin = book?.origin + val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi + + val seedColor = rememberImageSeedColor( + imageLoader = imageLoader, + data = coverPath, + requestKey = listOf(coverPath, sourceOrigin, loadOnlyWifi), + ) { + setParameter("sourceOrigin", sourceOrigin) + setParameter("loadOnlyWifi", loadOnlyWifi) + } + + return rememberThemeOverride(seedColor) +} + +@Composable +private fun BookInfoTopBarActions( + state: BookInfoUiState, + showMenu: Boolean, + onShowMenuChange: (Boolean) -> Unit, + onMenuAction: (BookInfoMenuAction) -> Unit, +) { + if (state.inBookshelf) { + TopBarActionButton( + onClick = { onMenuAction(BookInfoMenuAction.Edit) }, + imageVector = Icons.Default.Edit, + ) + } + TopBarActionButton( + onClick = { onMenuAction(BookInfoMenuAction.Share) }, + imageVector = Icons.Default.Share, + ) + TopBarActionButton( + onClick = { onShowMenuChange(true) }, + imageVector = Icons.Default.MoreVert, + ) + BookInfoOverflowMenu( + expanded = showMenu, + onDismissRequest = { onShowMenuChange(false) }, + state = state, + onMenuAction = { + onShowMenuChange(false) + onMenuAction(it) + } + ) +} + +@Composable +private fun BookInfoBackdrop(book: Book) { + val cover = book.getDisplayCover() + val seedOverlay = lerp( + LegadoTheme.colorScheme.secondaryContainer, + LegadoTheme.seedColor, + 0.42f + ) + Box(modifier = Modifier.fillMaxSize()) { + if (!cover.isNullOrBlank()) { + AsyncImage( + model = cover, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .blur(32.dp), + contentScale = ContentScale.Crop, + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(seedOverlay.copy(alpha = 0.34f)) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + 0.18f to seedOverlay.copy(alpha = 0.10f), + 0.34f to seedOverlay.copy(alpha = 0.18f), + 0.52f to LegadoTheme.colorScheme.surface.copy(alpha = 0.82f), + 0.72f to LegadoTheme.colorScheme.surface, + 1f to LegadoTheme.colorScheme.surface, + ) + ) + ) + ) + } +} + +@Composable +private fun BookInfoOverflowMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + state: BookInfoUiState, + onMenuAction: (BookInfoMenuAction) -> Unit, +) { + val book = state.book + RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + if (state.inBookshelf) { + RoundDropdownMenuItem( + text = stringResource(R.string.edit), + onClick = { onMenuAction(BookInfoMenuAction.Edit) } + ) + } + RoundDropdownMenuItem( + text = stringResource(R.string.refresh), + onClick = { onMenuAction(BookInfoMenuAction.Refresh) } + ) + if (book?.isLocal == true) { + RoundDropdownMenuItem( + text = stringResource(R.string.re_sync_webdav), + onClick = { onMenuAction(BookInfoMenuAction.SyncRemote) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.upload_to_remote), + onClick = { onMenuAction(BookInfoMenuAction.Upload) } + ) + } + if (!state.bookSource?.loginUrl.isNullOrBlank()) { + RoundDropdownMenuItem( + text = stringResource(R.string.login), + onClick = { onMenuAction(BookInfoMenuAction.Login) } + ) + } + if (state.bookSource != null) { + RoundDropdownMenuItem( + text = stringResource(R.string.set_source_variable), + onClick = { onMenuAction(BookInfoMenuAction.SetSourceVariable) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.set_book_variable), + onClick = { onMenuAction(BookInfoMenuAction.SetBookVariable) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.copy_book_url), + onClick = { onMenuAction(BookInfoMenuAction.CopyBookUrl) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.copy_toc_url), + onClick = { onMenuAction(BookInfoMenuAction.CopyTocUrl) } + ) + } + RoundDropdownMenuItem( + text = stringResource(R.string.to_top), + onClick = { onMenuAction(BookInfoMenuAction.Top) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.allow_update), + onClick = { onMenuAction(BookInfoMenuAction.ToggleCanUpdate) }, + trailingIcon = { if (book?.canUpdate == true) Icon(Icons.Default.Star, null) } + ) + if (book?.isLocal == true && book.type and io.legado.app.constant.BookType.text > 0) { + RoundDropdownMenuItem( + text = stringResource(R.string.split_long_chapter), + onClick = { onMenuAction(BookInfoMenuAction.ToggleSplitLongChapter) }, + trailingIcon = { if (book.getSplitLongChapter()) Icon(Icons.Default.Star, null) } + ) + } + RoundDropdownMenuItem( + text = stringResource(R.string.delete_alert), + onClick = { onMenuAction(BookInfoMenuAction.ToggleDeleteAlert) }, + trailingIcon = { if (io.legado.app.help.config.LocalConfig.bookInfoDeleteAlert) Icon(Icons.Default.Star, null) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.clear_cache), + onClick = { onMenuAction(BookInfoMenuAction.ClearCache) } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.log), + onClick = { onMenuAction(BookInfoMenuAction.ShowLog) } + ) + } +} + +@Composable +private fun BookInfoHeader( + book: Book, + kindLabels: List, + groupNames: String?, + onCoverClick: () -> Unit, + onCoverLongClick: () -> Unit, + onAuthorClick: (Boolean) -> Unit, + onBookNameClick: (Boolean) -> Unit, + onOriginClick: () -> Unit, +) { + val labelBackground = lerp( + LegadoTheme.colorScheme.surface, + LegadoTheme.seedColor, + 0.1f + ).copy(alpha = 0.82f) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + lerp(LegadoTheme.colorScheme.surface, LegadoTheme.seedColor, 0.08f) + .copy(alpha = 0.5f), + LegadoTheme.colorScheme.surface, + ) + ) + ) + .padding(top = 16.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(112.dp) + .combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick) + ) { + BookCover( + name = book.name, + author = book.author, + path = book.getDisplayCover(), + sourceOrigin = book.origin, + modifier = Modifier.width(112.dp) + ) + } + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + .padding(top = 8.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, + modifier = Modifier.combinedClickable( + onClick = { onBookNameClick(false) }, + onLongClick = { onBookNameClick(true) } + ) + ) + AppText( + text = stringResource(R.string.author_show, book.getRealAuthor()), + style = LegadoTheme.typography.bodyLarge, + color = LegadoTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.combinedClickable( + onClick = { onAuthorClick(false) }, + onLongClick = { onAuthorClick(true) } + ) + ) + AppText( + text = stringResource(R.string.origin_show, book.originName), + style = LegadoTheme.typography.labelMedium, + color = LegadoTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onOriginClick) + ) + } + } + if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = kindLabels, + key = { index, label -> "kind-$index-$label" } + ) { _, label -> + TextCard( + text = label, + textStyle = LegadoTheme.typography.labelLargeEmphasized, + backgroundColor = labelBackground, + contentColor = LegadoTheme.colorScheme.onSurface, + ) + } + groupNames?.takeIf { it.isNotBlank() }?.let { + item(key = "group-$it") { + TextCard( + text = stringResource(R.string.group_s, it), + textStyle = LegadoTheme.typography.labelLargeEmphasized, + backgroundColor = labelBackground, + contentColor = LegadoTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } +} + +@Composable +private fun BookInfoActions( + inBookshelf: Boolean, + onShelfClick: () -> Unit, + onTocClick: () -> Unit, + onGroupClick: () -> Unit, + onSourceClick: () -> Unit, +) { + var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) } + var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(inBookshelf, awaitingShelfAddition) { + if (awaitingShelfAddition && inBookshelf) { + awaitingShelfAddition = false + showShelfRemoveHint = true + delay(1000) + showShelfRemoveHint = false + } else if (!inBookshelf) { + showShelfRemoveHint = false + } + } + + val shelfLabel = when { + !inBookshelf -> stringResource(R.string.add_to_bookshelf) + showShelfRemoveHint -> stringResource(R.string.click_to_remove) + else -> stringResource(R.string.remove_from_bookshelf) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(LegadoTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + BookInfoActionCard( + modifier = Modifier.weight(1f), + icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd, + label = shelfLabel, + onClick = { + if (!inBookshelf) { + awaitingShelfAddition = true + } else { + awaitingShelfAddition = false + showShelfRemoveHint = false + } + onShelfClick() + }, + ) + BookInfoActionCard( + modifier = Modifier.weight(1f), + icon = Icons.AutoMirrored.Outlined.FormatListBulleted, + label = stringResource(R.string.view_toc), + onClick = onTocClick + ) + BookInfoActionCard( + modifier = Modifier.weight(1f), + icon = Icons.Default.Bookmark, + label = stringResource(R.string.change_group), + onClick = onGroupClick + ) + BookInfoActionCard( + modifier = Modifier.weight(1f), + icon = Icons.Default.Code, + label = stringResource(R.string.book_source), + onClick = onSourceClick + ) + } +} + +@Composable +private fun BookInfoActionCard( + modifier: Modifier = Modifier, + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit +) { + GlassCard( + modifier = modifier, + onClick = onClick, + containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f), + contentColor = LegadoTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppIcon(icon, null) + AnimatedTextLine( + text = label, + style = LegadoTheme.typography.bodySmall, + maxLines = 2, + ) + } + } +} + +@Composable +private fun BookInfoSummary( + book: Book, + chapterList: List, + onRemarkClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LegadoTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AppText( + text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)), + style = LegadoTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + AppText( + text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""), + style = LegadoTheme.typography.bodyMedium, + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = stringResource(R.string.read_chapter_total, book.totalChapterNum), + style = LegadoTheme.typography.bodyMedium, + color = LegadoTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + AppText(text = "|", color = LegadoTheme.colorScheme.secondary) + AppText( + text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1), + style = LegadoTheme.typography.labelMedium, + color = LegadoTheme.colorScheme.secondary, + ) + } + if (chapterList.isEmpty()) { + AppText( + text = stringResource(R.string.error_load_toc), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.height(4.dp)) + book.remark?.takeIf { it.isNotBlank() }?.let { remark -> + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = onRemarkClick, + colors = CardDefaults.elevatedCardColors( + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + contentColor = LegadoTheme.colorScheme.onSurface, + ) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppText(text = stringResource(R.string.book_remark), style = LegadoTheme.typography.titleSmall) + AppText(text = remark, style = LegadoTheme.typography.labelMediumEmphasized) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + AppText( + text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) }, + style = LegadoTheme.typography.bodyMedium, + ) + } +} +@Composable +private fun BookInfoDialogs( + state: BookInfoUiState, + onIntent: (BookInfoIntent) -> Unit, + onBack: () -> Unit, +) { + val dialog = state.dialog + var deleteOriginal by remember(dialog) { mutableStateOf(io.legado.app.help.config.LocalConfig.deleteBookOriginal) } + var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) } + + if (dialog is BookInfoDialog.AddToShelfOnBack) { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.add_to_bookshelf), + text = stringResource(R.string.check_add_bookshelf, state.book?.name.orEmpty()), + confirmText = stringResource(android.R.string.ok), + onConfirm = { onIntent(BookInfoIntent.ConfirmBackAddToShelf) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = onBack, + ) + } + + if (dialog is BookInfoDialog.DeleteBook) { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_del), + confirmText = stringResource(android.R.string.ok), + onConfirm = { + io.legado.app.help.config.LocalConfig.deleteBookOriginal = deleteOriginal + onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal)) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + content = { + if (dialog.isLocal) { + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Checkbox( + checked = deleteOriginal, + onCheckedChange = { deleteOriginal = it }, + colors = androidx.compose.material3.CheckboxDefaults.colors( + checkedColor = LegadoTheme.colorScheme.primary, + checkmarkColor = LegadoTheme.colorScheme.onPrimary, + uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant, + ) + ) + Text(text = stringResource(R.string.delete_book_file)) + } + } + } + ) + } + + if (dialog is BookInfoDialog.EditRemark) { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.edit_remark), + confirmText = stringResource(android.R.string.ok), + onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + content = { + AppTextField( + value = remarkText, + onValueChange = { remarkText = it }, + label = "备注", + modifier = Modifier.fillMaxWidth(), + ) + } + ) + } + + if (dialog is BookInfoDialog.UnsupportedWebFile) { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.draw), + text = stringResource(R.string.file_not_supported, dialog.webFile.name), + confirmText = stringResource(R.string.open_fun), + onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(dialog.webFile)) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { onIntent(BookInfoIntent.DismissDialog) }, + ) + } + + if (dialog is BookInfoDialog.PhotoPreview) { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, + title = stringResource(R.string.img_cover), + confirmText = stringResource(android.R.string.ok), + onConfirm = { onIntent(BookInfoIntent.DismissDialog) }, + content = { + AsyncImage( + model = dialog.path, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + contentScale = ContentScale.Fit, + ) + } + ) + } + + if (state.isBusy) { + AppAlertDialog( + show = true, + onDismissRequest = {}, + content = { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = LegadoTheme.colorScheme.primary, + trackColor = LegadoTheme.colorScheme.surfaceContainerHighest, + ) + } + } + ) + } + + AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) }) +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt new file mode 100644 index 000000000..274d03e07 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt @@ -0,0 +1,510 @@ +package io.legado.app.ui.book.info + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.ExperimentalFoundationApi +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PauseCircleOutline +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.FolderZip +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.appDb +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookGroup +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.book.changecover.ChangeCoverViewModel +import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel +import io.legado.app.ui.book.group.GroupEditSheet +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow +import io.legado.app.ui.widget.components.button.MediumIconButton +import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.button.TopBarButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.checkBox.AppCheckbox +import io.legado.app.ui.widget.components.cover.BookCover +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.text.AppText +import io.legado.app.utils.StartActivityContract +import io.legado.app.utils.startActivity +import io.legado.app.utils.toastOnUi +import org.koin.androidx.compose.koinViewModel + +@Composable +fun WebFileSheet( + show: Boolean, + files: List, + title: String, + onDismissRequest: () -> Unit, + onSelect: (BookInfoWebFile) -> Unit, +) { + AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest, title = title) { + if (files.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.empty)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(files, key = { it.name }) { file -> + GlassCard(onClick = { onSelect(file) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(if (file.name.endsWith("zip") || file.name.endsWith("rar") || file.name.endsWith("7z")) Icons.Outlined.FolderZip else Icons.Outlined.Image, null) + Text(text = file.name, modifier = Modifier.weight(1f), style = LegadoTheme.typography.bodyMedium) + } + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +fun GroupSelectSheet( + show: Boolean, + currentGroupId: Long, + onDismissRequest: () -> Unit, + onConfirm: (Long) -> Unit, +) { + val groups by appDb.bookGroupDao.flowSelect().collectAsStateWithLifecycle(initialValue = emptyList()) + var selectedGroupId by remember(currentGroupId) { mutableLongStateOf(currentGroupId) } + var editingGroup by remember { mutableStateOf(null) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.group_select), + endAction = { IconButton(onClick = { editingGroup = BookGroup() }) { Icon(Icons.Default.Add, null) } } + ) { + LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(groups, key = { it.groupId }) { group -> + val isSelected = selectedGroupId and group.groupId > 0 + SelectionItemCard( + title = group.groupName, + isSelected = isSelected, + onToggleSelection = { + selectedGroupId = if (isSelected) { + selectedGroupId - group.groupId + } else { + selectedGroupId + group.groupId + } + }, + leadingContent = { + AppCheckbox( + checked = isSelected, + onCheckedChange = { + selectedGroupId = if (it) { + selectedGroupId + group.groupId + } else { + selectedGroupId - group.groupId + } + } + ) + }, + trailingAction = { + SmallIconButton( + onClick = { editingGroup = group }, + imageVector = Icons.Default.Edit + ) + }, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + ConfirmDismissButtonsRow( + onDismiss = onDismissRequest, + onConfirm = { onConfirm(selectedGroupId) }, + dismissText = stringResource(R.string.cancel), + confirmText = stringResource(R.string.ok), + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + GroupEditSheet(show = editingGroup != null, group = editingGroup, onDismissRequest = { editingGroup = null }) +} +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChangeCoverSheet( + show: Boolean, + name: String, + author: String, + onDismissRequest: () -> Unit, + onSelect: (String) -> Unit, + viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"), +) { + val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + + LaunchedEffect(name, author) { + viewModel.initData(name, author) + } + DisposableEffect(show) { + onDispose { + viewModel.stopSearch() + } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.change_cover_source), + endAction = { + IconButton(onClick = { viewModel.startOrStopSearch() }) { + Icon(if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh, null) + } + } + ) { + if (isSearching) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(12.dp)) + } + LazyVerticalGrid(columns = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(items, key = { it.bookUrl + it.originName }) { item -> + GlassCard(onClick = { onSelect(item.coverUrl.orEmpty()) }) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + BookCover(name = item.name, author = item.author, path = item.coverUrl, sourceOrigin = item.origin, modifier = Modifier.fillMaxWidth()) + AppText(text = item.originName, style = LegadoTheme.typography.bodySmall, maxLines = 2) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +fun ChangeSourceSheet( + show: Boolean, + oldBook: Book, + onDismissRequest: () -> Unit, + onReplace: (BookSource, Book, List) -> Unit, + onAddAsNew: (Book, List) -> Unit, + viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), +) { + val context = LocalContext.current + val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle() + val groups by appDb.bookSourceDao.flowEnabledGroups().collectAsStateWithLifecycle(initialValue = emptyList()) + var searchQuery by rememberSaveable { mutableStateOf("") } + val selectedGroup = viewModel.searchGroup + val checkAuthor = viewModel.checkAuthor + val loadInfo = viewModel.loadInfo + val loadToc = viewModel.loadToc + val loadWordCount = viewModel.loadWordCount + var actionBook by remember { mutableStateOf(null) } + var mismatchBook by remember { mutableStateOf(null) } + var loadingAction by remember { mutableStateOf(false) } + var showOptionsMenu by rememberSaveable { mutableStateOf(false) } + var showFilterMenu by rememberSaveable { mutableStateOf(false) } + + val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { + val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult + viewModel.startSearch(origin) + } + + LaunchedEffect(oldBook.bookUrl) { + viewModel.initData(oldBook.name, oldBook.author, oldBook, false) + } + DisposableEffect(oldBook.bookUrl) { + onDispose { + viewModel.stopSearch() + } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.book_source), + startAction = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box { + MediumIconButton( + onClick = { showOptionsMenu = true }, + imageVector = Icons.Default.MoreVert + ) + RoundDropdownMenu( + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } + ) { dismiss -> + RoundDropdownMenuItem( + text = "校验作者", + isSelected = checkAuthor, + onClick = { + viewModel.onCheckAuthorChange(!checkAuthor) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载详情", + isSelected = loadInfo, + onClick = { + viewModel.onLoadInfoChange(!loadInfo) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载目录", + isSelected = loadToc, + onClick = { + viewModel.onLoadTocChange(!loadToc) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "字数对比", + isSelected = loadWordCount, + onClick = { + viewModel.onLoadWordCountChange(!loadWordCount) + dismiss() + } + ) + } + } + MediumIconButton( + onClick = { context.startActivity() }, + imageVector = Icons.Outlined.Settings + ) + } + }, + endAction = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MediumIconButton( + onClick = { viewModel.startOrStopSearch() }, + imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, + ) + Box { + MediumIconButton( + onClick = { showFilterMenu = true }, + imageVector = Icons.Default.FilterList + ) + RoundDropdownMenu( + expanded = showFilterMenu, + onDismissRequest = { showFilterMenu = false } + ) { dismiss -> + RoundDropdownMenuItem( + text = stringResource(R.string.all_source), + isSelected = selectedGroup.isBlank(), + onClick = { + viewModel.onSearchGroupSelected("") + dismiss() + } + ) + groups.forEach { group -> + RoundDropdownMenuItem( + text = group, + isSelected = selectedGroup == group, + onClick = { + viewModel.onSearchGroupSelected(group) + dismiss() + } + ) + } + } + } + } + } + ) { + AppTextField( + value = searchQuery, + backgroundColor = LegadoTheme.colorScheme.surface, + onValueChange = { + searchQuery = it + viewModel.screen(it) + }, + label = stringResource(R.string.screen), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + if (isSearching) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(8.dp)) + AppText( + text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}", + style = LegadoTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + } + LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(items, key = { it.bookUrl + it.origin }) { item -> + val bookScore by remember(item.origin, item.name, item.author) { + viewModel.bookScoreFlow(item) + }.collectAsStateWithLifecycle() + SelectionItemCard( + title = item.originName, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.2f), + leadingContent = { + MediumIconButton( + onClick = { + viewModel.onBookScoreClick(item) + }, + imageVector = Icons.Default.PushPin, + tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + contentDescription = null + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppText( + text = item.author, + style = LegadoTheme.typography.labelLargeEmphasized + ) + AppText( + text = item.getDisplayLastChapterTitle(), + style = LegadoTheme.typography.labelMediumEmphasized + ) + item.chapterWordCountText?.takeIf { loadWordCount }?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmallEmphasized, + color = LegadoTheme.colorScheme.primary + ) + } + } + }, + isSelected = item.bookUrl == oldBook.bookUrl, + onToggleSelection = { + if (item.bookUrl != oldBook.bookUrl) { + if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = item + } + }, + dropdownContent = { onDismiss: () -> Unit -> + RoundDropdownMenuItem( + text = stringResource(R.string.to_top), + onClick = { + viewModel.topSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = "置底", + onClick = { + viewModel.bottomSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.edit), + onClick = { + onDismiss() + editSourceResult.launch { putExtra("sourceUrl", item.origin) } + } + ) + RoundDropdownMenuItem( + text = "禁用", + onClick = { + viewModel.disableSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.delete), + color = LegadoTheme.colorScheme.error, + onClick = { + viewModel.del(item) + if (oldBook.bookUrl == item.bookUrl) { + viewModel.autoChangeSource(oldBook.type) { book, toc, source -> + onReplace(source, book, toc) + } + } + onDismiss() + } + ) + } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace -> + loadingAction = true + val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook() + viewModel.getToc(book, { toc, source -> + loadingAction = false + if (replace) { + onReplace(source, book, toc) + onDismissRequest() + } else { + onAddAsNew(book, toc) + context.toastOnUi(context.getString(R.string.book_added_to_shelf)) + } + actionBook = null + }, { + loadingAction = false + context.toastOnUi(if (replace) "换源失败" else "添加书籍失败") + }) + } + + if (mismatchBook != null) { + AppAlertDialog(show = true, onDismissRequest = { mismatchBook = null }, title = stringResource(R.string.book_type_different), text = stringResource(R.string.soure_change_source), confirmText = stringResource(android.R.string.ok), onConfirm = { actionBook = mismatchBook; mismatchBook = null }, dismissText = stringResource(android.R.string.cancel), onDismiss = { mismatchBook = null }) + } + actionBook?.let { searchBook -> + AppAlertDialog(show = true, onDismissRequest = { actionBook = null }, title = stringResource(R.string.change_source_option_title), dismissText = stringResource(R.string.add_as_new_book), onDismiss = { performAction(searchBook, false) }, confirmText = stringResource(R.string.replace_current_book), onConfirm = { performAction(searchBook, true) }) + } + if (loadingAction) { + AppAlertDialog(show = true, onDismissRequest = {}, content = { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + }) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index f5776d12e..50b17063b 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -1,9 +1,9 @@ package io.legado.app.ui.book.info +import android.app.Activity.RESULT_OK import android.app.Application import android.content.Intent import android.net.Uri -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import io.legado.app.R import io.legado.app.base.BaseViewModel @@ -19,6 +19,7 @@ import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.exception.NoBooksDirException import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp +import io.legado.app.help.book.addType import io.legado.app.help.book.getExportFileName import io.legado.app.help.book.isLocal import io.legado.app.help.book.isNotShelf @@ -26,6 +27,8 @@ import io.legado.app.help.book.isSameNameAuthor import io.legado.app.help.book.isWebFile import io.legado.app.help.book.removeType import io.legado.app.help.book.updateTo +import io.legado.app.help.config.AppConfig +import io.legado.app.help.config.LocalConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.lib.webdav.ObjectNotFoundException import io.legado.app.model.AudioPlay @@ -37,381 +40,375 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.utils.ArchiveUtils +import io.legado.app.utils.ConvertUtils +import io.legado.app.utils.FileDoc +import io.legado.app.utils.GSON import io.legado.app.utils.UrlUtil import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO +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 class BookInfoViewModel( application: Application, private val remoteBookRepository: RemoteBookRepository ) : BaseViewModel(application) { - val bookData = MutableLiveData() - val chapterListData = MutableLiveData>() - val webFiles = mutableListOf() + + private val _uiState = MutableStateFlow(BookInfoUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 8) + val effects = _effects.asSharedFlow() + + private var currentBook: Book? = null + private var currentChapterList: List = emptyList() + private var currentWebFiles: List = emptyList() + private var currentKindLabels: List = emptyList() + private var currentGroupNames: String? = null + private var chapterChanged = false + var inBookshelf = false + private set var bookSource: BookSource? = null + private set + private var changeSourceCoroutine: Coroutine<*>? = null - val waitDialogData = MutableLiveData() - val actionLive = MutableLiveData() fun initData(intent: Intent) { + if (currentBook != null) return execute { val bookUrl = intent.getStringExtra("bookUrl") ?: "" appDb.bookDao.getBook(bookUrl)?.let { inBookshelf = !it.isNotShelf - upBook(it) - return@execute + return@execute it } appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { - upBook(it) - return@execute + inBookshelf = false + return@execute it } throw NoStackTraceException("未找到书籍") - } - } - - fun upBook(intent: Intent) { - execute { - val name = intent.getStringExtra("name") ?: "" - val author = intent.getStringExtra("author") ?: "" - val bookUrl = intent.getStringExtra("bookUrl") ?: "" - val book = when { - bookUrl.isNotBlank() -> appDb.bookDao.getBook(bookUrl) - else -> appDb.bookDao.getBook(name, author) - } - book?.let { upBook(it) } - } - } - - private fun upBook(book: Book) { - execute { - bookData.postValue(book) - upCoverByRule(book) - bookSource = if (book.isLocal) null else - appDb.bookSourceDao.getBookSource(book.origin) - if (book.tocUrl.isEmpty() && !book.isLocal) { - loadBookInfo(book, runPreUpdateJs = inBookshelf) - } else { - val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl) - if (chapterList.isNotEmpty()) { - chapterListData.postValue(chapterList) - } else { - loadChapter(book) - } - } - } - } - - private fun upCoverByRule(book: Book) { - execute { - if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) { - val coverUrl = BookCover.searchCover(book) - if (coverUrl.isNullOrBlank()) { - return@execute - } - book.customCoverUrl = coverUrl - bookData.postValue(book) - if (inBookshelf) { - saveBook(book) - } - } - } - } - - fun refreshBook(book: Book) { - executeLazy(executeContext = IO) { - if (book.isLocal) { - book.tocUrl = "" - remoteBookRepository.refreshLocalBook(book) - } else { - val bs = bookSource ?: return@executeLazy - if (book.originName != bs.bookSourceName) { - book.originName = bs.bookSourceName - } - } + }.onSuccess { + upBook(it) }.onError { - when (it) { - is ObjectNotFoundException -> { - book.origin = BookType.localTag - } - - else -> { - AppLog.put("下载远程书籍<${book.name}>失败", it) - } - } - }.onFinally { - loadBookInfo(book, false) - }.start() - } - - fun syncFromRemote() { - val book = bookData.value ?: return - if (!book.isLocal) return - - execute { - waitDialogData.postValue(true) - val newBook = remoteBookRepository.syncBookFromRemote(book) - - appDb.bookDao.delete(book) - appDb.bookDao.insert(newBook) - - loadChapter(newBook) - - bookData.postValue(newBook) - context.toastOnUi("同步完成") - }.onFinally { - waitDialogData.postValue(false) - }.onError { - context.toastOnUi(it.localizedMessage) + context.toastOnUi(it.localizedMessage ?: "未找到书籍") + emitEffect(BookInfoEffect.Finish(afterTransition = true)) } } - fun uploadBook(book: Book, success: () -> Unit) { - execute { - waitDialogData.postValue(true) - remoteBookRepository.uploadBook(book) - saveBook(book) - success.invoke() - }.onFinally { - waitDialogData.postValue(false) - }.onError { - context.toastOnUi(it.localizedMessage) - } - } - - fun loadBookInfo( - book: Book, - canReName: Boolean = true, - runPreUpdateJs: Boolean = true, - scope: CoroutineScope = viewModelScope - ) { - if (book.isLocal) { - LocalBook.upBookInfo(book) - bookData.postValue(book) - loadChapter(book) - } else { - val bookSource = bookSource ?: let { - chapterListData.postValue(emptyList()) - context.toastOnUi(R.string.error_no_source) - return + fun onIntent(intent: BookInfoIntent) { + when (intent) { + BookInfoIntent.BackPressed -> onBackPressed() + BookInfoIntent.DismissSheet -> dismissSheet() + BookInfoIntent.DismissDialog -> dismissDialog() + is BookInfoIntent.MenuAction -> handleMenuAction(intent.action) + is BookInfoIntent.AuthorClick -> onAuthorClick(intent.longClick) + is BookInfoIntent.BookNameClick -> onBookNameClick(intent.longClick) + BookInfoIntent.OriginClick -> onOriginClick() + BookInfoIntent.DismissAppLogSheet -> { + _uiState.update { it.copy(showAppLogSheet = false) } } - WebBook.getBookInfo(scope, bookSource, book, canReName = canReName) - .onSuccess(IO) { - val dbBook = appDb.bookDao.getBook(book.name, book.author) - if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == book.origin) { - /** - * book 来自搜索时(inBookshelf == false),搜索的书名不存在于书架,但是加载详情后,书名更新,存在同名书籍 - * 此时 book 的数据会与数据库中的不同,需要更新 #3652 #4619 - * book 加载详情后虽然书名作者相同,但是又可能不是数据库中(书源不同)的那本书 #3149 - */ - dbBook.updateTo(it) - inBookshelf = true + + BookInfoIntent.ReadClick -> onReadClick() + BookInfoIntent.ShelfClick -> onShelfClick() + BookInfoIntent.TocClick -> onTocClick() + BookInfoIntent.CoverClick -> setSheet(BookInfoSheet.CoverPicker) + BookInfoIntent.CoverLongClick -> currentBook?.getDisplayCover()?.takeIf { it.isNotBlank() } + ?.let { showDialog(BookInfoDialog.PhotoPreview(it)) } + + BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker) + BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker) + BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark)) + BookInfoIntent.ConfirmBackAddToShelf -> { + dismissDialog() + addToBookshelf { + emitEffect(BookInfoEffect.Finish(afterTransition = true)) + } + } + + is BookInfoIntent.ConfirmDelete -> { + dismissDialog() + deleteBook(intent.deleteOriginal) + } + + is BookInfoIntent.UpdateRemark -> { + dismissDialog() + saveRemark(intent.remark) + } + + is BookInfoIntent.SelectGroup -> { + dismissSheet() + updateGroup(intent.groupId) + } + + is BookInfoIntent.SelectCover -> { + dismissSheet() + updateCover(intent.coverUrl) + } + + is BookInfoIntent.ReplaceWithSource -> { + dismissSheet() + changeTo(intent.source, intent.book, intent.toc) + } + + is BookInfoIntent.AddSourceAsNewBook -> { + addToBookshelf(intent.book, intent.toc) { + context.toastOnUi("已添加到书架") + } + } + + is BookInfoIntent.SelectWebFile -> handleWebFileSelection( + intent.webFile, + intent.openAfterImport + ) + + is BookInfoIntent.OpenUnsupportedWebFile -> { + dismissDialog() + importOrDownloadWebFile(intent.webFile) { uri -> + emitEffect(BookInfoEffect.OpenFile(uri, "*/*")) + } + } + + is BookInfoIntent.SelectArchiveEntry -> { + dismissSheet() + importArchiveBook(intent.archiveUri, intent.entryName) { book -> + if (intent.openAfterImport) { + openReader(book) } - bookData.postValue(it) - if (inBookshelf) { - it.save() - } - if (it.isWebFile) { - loadWebFile(it) - } else { - loadChapter(it, runPreUpdateJs) - } - }.onError { - AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) - context.toastOnUi(R.string.error_get_book_info) - } - } - } - - private fun loadChapter( - book: Book, - runPreUpdateJs: Boolean = true, - scope: CoroutineScope = viewModelScope - ) { - if (book.isLocal) { - execute(scope) { - LocalBook.getChapterList(book).let { - appDb.bookDao.update(book) - appDb.bookChapterDao.delByBook(book.bookUrl) - appDb.bookChapterDao.insert(*it.toTypedArray()) - ReadBook.onChapterListUpdated(book) - bookData.postValue(book) - chapterListData.postValue(it) - } - }.onError { - context.toastOnUi("LoadTocError:${it.localizedMessage}") - } - } else { - val bookSource = bookSource ?: let { - chapterListData.postValue(emptyList()) - context.toastOnUi(R.string.error_no_source) - return - } - val oldBook = book.copy() - WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs) - .onSuccess(IO) { - if (inBookshelf) { - appDb.bookDao.replace(oldBook, book) - /** - * runPreUpdateJs 有可能会修改 book 的 bookUrl - */ - if (oldBook.bookUrl != book.bookUrl) { - BookHelp.updateCacheFolder(oldBook, book) - } - appDb.bookChapterDao.delByBook(oldBook.bookUrl) - appDb.bookChapterDao.insert(*it.toTypedArray()) - ReadBook.onChapterListUpdated(book) - } - bookData.postValue(book) - chapterListData.postValue(it) - }.onError { - chapterListData.postValue(emptyList()) - AppLog.put("获取目录失败\n${it.localizedMessage}", it) - context.toastOnUi(R.string.error_get_chapter_list) - } - } - } - - - fun loadGroup(groupId: Long, success: ((groupNames: String?) -> Unit)) { - execute { - appDb.bookGroupDao.getGroupNames(groupId).joinToString(",") - }.onSuccess { - success.invoke(it) - } - } - - private fun loadWebFile(book: Book) { - execute { - webFiles.clear() - val fileNameNoExtension = if (book.author.isBlank()) book.name - else "${book.name} 作者:${book.author}" - book.downloadUrls!!.map { - val analyzeUrl = AnalyzeUrl( - it, source = bookSource, - coroutineContext = coroutineContext - ) - val mFileName = UrlUtil.getFileName(analyzeUrl) - ?: "${fileNameNoExtension}.${analyzeUrl.type}" - WebFile(it, mFileName) - } - }.onError { - context.toastOnUi("LoadWebFileError\n${it.localizedMessage}") - }.onSuccess { - webFiles.addAll(it) - } - } - - /* 导入或者下载在线文件 */ - fun importOrDownloadWebFile(webFile: WebFile, success: ((T) -> Unit)?) { - bookSource ?: return - execute { - waitDialogData.postValue(true) - if (webFile.isSupported) { - val book = LocalBook.importFileOnLine( - webFile.url, - bookData.value!!.getExportFileName(webFile.suffix), - bookSource - ) - changeToLocalBook(book) - } else { - LocalBook.saveBookFile( - webFile.url, - bookData.value!!.getExportFileName(webFile.suffix), - bookSource - ) - } - }.onSuccess { - @Suppress("unchecked_cast") - success?.invoke(it as T) - }.onError { - when (it) { - is NoBooksDirException -> actionLive.postValue("selectBooksDir") - else -> { - AppLog.put("ImportWebFileError\n${it.localizedMessage}", it) - context.toastOnUi("ImportWebFileError\n${it.localizedMessage}") - webFiles.remove(webFile) } } - }.onFinally { - waitDialogData.postValue(false) } } - fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List) -> Unit) { - execute { - ArchiveUtils.getArchiveFilesName(archiveFileUri) { - AppPattern.bookFileRegex.matches(it) + fun openEdit() { + currentBook?.let { + emitEffect(BookInfoEffect.OpenBookInfoEdit(it.bookUrl)) + } + } + + fun showAppLog() { + _uiState.update { it.copy(showAppLogSheet = true) } + } + + fun refreshCurrentBook() { + currentBook?.let { + refreshBook(it) + } + } + + fun onSourceEdited() { + currentBook?.let { book -> + bookSource = appDb.bookSourceDao.getBookSource(book.origin) + syncUiState() + refreshBook(book) + } + } + + fun onInfoEdited() { + currentBook?.bookUrl?.let { bookUrl -> + appDb.bookDao.getBook(bookUrl)?.let { upBook(it) } + } + } + + fun onTocResult(result: Triple?) { + if (result == null) { + if (!inBookshelf) { + delBook() } - }.onError { - AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it) - context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}") - }.onSuccess { - onSuccess.invoke(it) + return } - } - - fun importArchiveBook( - archiveFileUri: Uri, - archiveEntryName: String, - success: ((Book) -> Unit)? = null - ) { + chapterChanged = result.third + val book = currentBook ?: return execute { - val suffix = archiveEntryName.substringAfterLast(".") - LocalBook.importArchiveFile( - archiveFileUri, - bookData.value!!.getExportFileName(suffix) - ) { - it.contains(archiveEntryName) - }.first() + book.durChapterIndex = result.first + book.durChapterPos = result.second + appDb.bookDao.update(book) + book }.onSuccess { - val book = changeToLocalBook(it) - success?.invoke(book) - }.onError { - AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it) - context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}") + currentBook = it + syncUiState(isTocLoading = false) + openReader(it) } } - fun changeTo(source: BookSource, book: Book, toc: List) { - changeSourceCoroutine?.cancel() - changeSourceCoroutine = execute { - bookSource = source - bookData.value?.migrateTo(book, toc) + fun onReaderResult(resultCode: Int) { + when (resultCode) { + RESULT_OK -> { + inBookshelf = true + syncUiState() + } + + io.legado.app.ui.book.read.ReadBookActivity.RESULT_DELETED -> { + emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK)) + } + } + } + + fun toggleCanUpdate() { + currentBook?.let { book -> + book.canUpdate = !book.canUpdate if (inBookshelf) { - book.removeType(BookType.updateError) - bookData.value?.delete() - appDb.bookDao.insert(book) - appDb.bookChapterDao.insert(*toc.toTypedArray()) + if (!book.canUpdate) { + book.removeType(BookType.updateError) + } + saveBook(book) + } + syncUiState() + } + } + + fun toggleSplitLongChapter() { + currentBook?.takeIf { it.isLocal && it.type and BookType.text > 0 }?.let { book -> + book.setSplitLongChapter(!book.getSplitLongChapter()) + syncUiState(isTocLoading = true) + loadBookInfo(book, canReName = false) + if (!book.getSplitLongChapter()) { + context.toastOnUi(context.getString(R.string.need_more_time_load_content)) + } + } + } + + fun toggleDeleteAlert() { + LocalConfig.bookInfoDeleteAlert = !LocalConfig.bookInfoDeleteAlert + syncUiState() + } + + fun requestSourceVariableDialog() { + execute { + val source = bookSource ?: throw NoStackTraceException("书源不存在") + val comment = source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取") + val variable = source.getVariable() + BookInfoEffect.ShowVariableDialog( + title = context.getString(R.string.set_source_variable), + key = source.getKey(), + variable = variable, + comment = comment, + ) + }.onSuccess { + emitEffect(it) + }.onError { + context.toastOnUi(it.localizedMessage ?: "书源不存在") + } + } + + fun requestBookVariableDialog() { + execute { + val source = bookSource ?: throw NoStackTraceException("书源不存在") + val book = currentBook ?: throw NoStackTraceException("book is null") + val variable = book.getCustomVariable() + val comment = source.getDisplayVariableComment( + "书籍变量可在js中通过book.getVariable(\"custom\")获取" + ) + BookInfoEffect.ShowVariableDialog( + title = context.getString(R.string.set_book_variable), + key = book.bookUrl, + variable = variable, + comment = comment, + ) + }.onSuccess { + emitEffect(it) + }.onError { + context.toastOnUi(it.localizedMessage ?: "书源不存在") + } + } + + fun setVariable(key: String, variable: String?) { + when (key) { + bookSource?.getKey() -> bookSource?.setVariable(variable) + currentBook?.bookUrl -> currentBook?.let { + it.putCustomVariable(variable) + if (inBookshelf) { + saveBook(it) + } } - bookData.postValue(book) - chapterListData.postValue(toc) - }.onFinally { - postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) } } fun topBook() { - execute { - bookData.value?.let { book -> + currentBook?.let { book -> + execute { val minOrder = appDb.bookDao.minOrder book.order = minOrder - 1 book.durChapterTime = System.currentTimeMillis() appDb.bookDao.update(book) + book + }.onSuccess { + currentBook = it + syncUiState() + } + } + } + fun syncFromRemote() { + val book = currentBook ?: return + if (!book.isLocal) return + + execute { + setBusy(true) + val newBook = remoteBookRepository.syncBookFromRemote(book) + appDb.bookDao.delete(book) + appDb.bookDao.insert(newBook) + newBook + }.onSuccess { newBook -> + currentBook = newBook + inBookshelf = true + syncUiState(isTocLoading = true) + loadChapter(newBook) + context.toastOnUi("同步完成") + }.onFinally { + setBusy(false) + }.onError { + context.toastOnUi(it.localizedMessage) + } + } + + fun uploadBook(success: () -> Unit) { + val book = currentBook ?: return + execute { + setBusy(true) + remoteBookRepository.uploadBook(book) + saveBook(book) + }.onSuccess { + success.invoke() + }.onFinally { + setBusy(false) + }.onError { + context.toastOnUi(it.localizedMessage) + } + } + + fun clearCache() { + currentBook?.let { book -> + execute { + BookHelp.clearCache(book) + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.clearTextChapter() + } + if (ReadManga.book?.bookUrl == book.bookUrl) { + ReadManga.clearMangaChapter() + } + }.onSuccess { + context.toastOnUi(R.string.clear_cache_success) + }.onError { + context.toastOnUi("清理缓存出错\n${it.localizedMessage}") } } } fun saveRemark(remark: String, success: (() -> Unit)? = null) { - execute { - bookData.value?.let { - it.remark = remark - it.save() + currentBook?.let { book -> + execute { + book.remark = remark + book.save() + book + }.onSuccess { + currentBook = it + syncUiState() + success?.invoke() } - }.onSuccess { - success?.invoke() } } @@ -432,98 +429,69 @@ class BookInfoViewModel( } else if (AudioPlay.book?.isSameNameAuthor(book) == true) { AudioPlay.book = book } + book + }.onSuccess { + if (currentBook?.bookUrl == it.bookUrl) { + currentBook = it + syncUiState() + } + success?.invoke() + } + } + + fun saveChapterList(success: (() -> Unit)? = null) { + execute { + appDb.bookChapterDao.insert(*currentChapterList.toTypedArray()) }.onSuccess { success?.invoke() } } - fun saveChapterList(success: (() -> Unit)?) { + fun addToBookshelf(success: (() -> Unit)? = null) { + val book = currentBook ?: return execute { - chapterListData.value?.let { - appDb.bookChapterDao.insert(*it.toTypedArray()) + book.removeType(BookType.notShelf) + if (book.order == 0) { + book.order = appDb.bookDao.minOrder - 1 } + appDb.bookDao.getBook(book.name, book.author)?.let { + book.durChapterIndex = it.durChapterIndex + book.durChapterPos = it.durChapterPos + book.durChapterTitle = it.durChapterTitle + } + if (ReadBook.book?.isSameNameAuthor(book) == true) { + ReadBook.book = book + } else if (AudioPlay.book?.isSameNameAuthor(book) == true) { + AudioPlay.book = book + } + book.save() + SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book) + appDb.bookChapterDao.insert(*currentChapterList.toTypedArray()) + book }.onSuccess { - success?.invoke() - } - } - - fun addToBookshelf(success: (() -> Unit)?) { //点击书架按钮或在加分组时触发 - execute { - bookData.value?.let { book -> - book.removeType(BookType.notShelf) - if (book.order == 0) { - book.order = appDb.bookDao.minOrder - 1 - } - appDb.bookDao.getBook(book.name, book.author)?.let { - book.durChapterIndex = it.durChapterIndex - book.durChapterPos = it.durChapterPos - book.durChapterTitle = it.durChapterTitle - } - if (ReadBook.book?.isSameNameAuthor(book) == true) { - ReadBook.book = book - } else if (AudioPlay.book?.isSameNameAuthor(book) == true) { - AudioPlay.book = book - } - book.save() - SourceCallBack.callBackBook(SourceCallBack.ADD_BOOK_SHELF, bookSource, book) - } - chapterListData.value?.let { - appDb.bookChapterDao.insert(*it.toTypedArray()) - } + currentBook = it inBookshelf = true - }.onSuccess { + syncUiState() success?.invoke() } } - fun getBook(toastNull: Boolean = true): Book? { - val book = bookData.value - if (toastNull && book == null) { - context.toastOnUi("book is null") - } - return book - } - - fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) { - execute { - bookData.value?.let { - inBookshelf = false - if (it.isLocal) { - LocalBook.deleteBook(it, deleteOriginal) - } - it.delete() - } - }.onSuccess { - success?.invoke() - } - } - - fun clearCache(book: Book) { - execute { - BookHelp.clearCache(book) - if (ReadBook.book?.bookUrl == book.bookUrl) { - ReadBook.clearTextChapter() - } - if (ReadManga.book?.bookUrl == book.bookUrl) { - ReadManga.clearMangaChapter() - } - }.onSuccess { - context.toastOnUi(R.string.clear_cache_success) - }.onError { - context.toastOnUi("清理缓存出错\n${it.localizedMessage}") - } - } - fun addToBookshelf(book: Book, toc: List, success: (() -> Unit)? = null) { execute { book.removeType(BookType.notShelf) if (book.order == 0) { book.order = appDb.bookDao.minOrder - 1 } - appDb.bookDao.insert(book) appDb.bookChapterDao.insert(*toc.toTypedArray()) + book }.onSuccess { + if (currentBook?.bookUrl == it.bookUrl) { + currentBook = it + currentChapterList = toc + inBookshelf = true + syncUiState(isTocLoading = false) + } success?.invoke() }.onError { AppLog.put("添加书籍到书架失败", it) @@ -531,41 +499,632 @@ class BookInfoViewModel( } } - fun upEditBook() { - bookData.value?.let { - appDb.bookDao.getBook(it.bookUrl)?.let { book -> - bookData.postValue(book) + fun delBook(deleteOriginal: Boolean = false, success: (() -> Unit)? = null) { + val book = currentBook ?: return + execute { + inBookshelf = false + if (book.isLocal) { + LocalBook.deleteBook(book, deleteOriginal) + } + book.delete() + }.onSuccess { + success?.invoke() + } + } + + fun refreshBook(book: Book) { + syncUiState(isTocLoading = true) + execute { + if (book.isLocal) { + book.tocUrl = "" + remoteBookRepository.refreshLocalBook(book) + } else { + val bs = bookSource ?: return@execute + if (book.originName != bs.bookSourceName) { + book.originName = bs.bookSourceName + } + } + book + }.onError { + when (it) { + is ObjectNotFoundException -> { + book.origin = BookType.localTag + } + + else -> { + AppLog.put("下载远程书籍<${book.name}>失败", it) + } + } + }.onFinally { + loadBookInfo(book, canReName = false) + } + } + + fun loadBookInfo( + book: Book, + canReName: Boolean = true, + runPreUpdateJs: Boolean = true, + scope: CoroutineScope = viewModelScope, + ) { + syncUiState(isTocLoading = true) + if (book.isLocal) { + LocalBook.upBookInfo(book) + currentBook = book + syncUiState(isTocLoading = true) + loadChapter(book) + } else { + val source = bookSource ?: run { + currentChapterList = emptyList() + syncUiState(isTocLoading = false) + context.toastOnUi(R.string.error_no_source) + return + } + WebBook.getBookInfo(scope, source, book, canReName = canReName) + .onSuccess(IO) { loadedBook -> + val dbBook = appDb.bookDao.getBook(loadedBook.name, loadedBook.author) + if (!inBookshelf && dbBook != null && !dbBook.isNotShelf && dbBook.origin == loadedBook.origin) { + dbBook.updateTo(loadedBook) + inBookshelf = true + } + currentBook = loadedBook + if (inBookshelf) { + loadedBook.save() + } + syncUiState(isTocLoading = true) + refreshMeta(loadedBook) + if (loadedBook.isWebFile) { + loadWebFile(loadedBook) + currentChapterList = emptyList() + syncUiState(isTocLoading = false) + } else { + loadChapter(loadedBook, runPreUpdateJs) + } + }.onError { + AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) + context.toastOnUi(R.string.error_get_book_info) + syncUiState(isTocLoading = false) + } + } + } + fun changeTo(source: BookSource, book: Book, toc: List) { + changeSourceCoroutine?.cancel() + changeSourceCoroutine = execute { + bookSource = source + currentBook?.migrateTo(book, toc) + if (inBookshelf) { + book.removeType(BookType.updateError) + currentBook?.delete() + appDb.bookDao.insert(book) + appDb.bookChapterDao.insert(*toc.toTypedArray()) + } + book + }.onSuccess { + currentBook = it + currentChapterList = toc + currentGroupNames = null + currentKindLabels = emptyList() + syncUiState(isTocLoading = false) + refreshMeta(it) + }.onFinally { + postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) + } + } + + private fun upBook(book: Book) { + currentBook = book + currentChapterList = emptyList() + currentWebFiles = emptyList() + currentKindLabels = emptyList() + currentGroupNames = null + syncUiState(isTocLoading = true) + refreshMeta(book) + upCoverByRule(book) + bookSource = if (book.isLocal) null else appDb.bookSourceDao.getBookSource(book.origin) + syncUiState(isTocLoading = true) + if (book.tocUrl.isEmpty() && !book.isLocal) { + loadBookInfo(book, runPreUpdateJs = inBookshelf) + } else { + execute { + appDb.bookChapterDao.getChapterList(book.bookUrl) + }.onSuccess { chapters -> + if (chapters.isNotEmpty()) { + currentChapterList = chapters + syncUiState(isTocLoading = false) + } else { + loadChapter(book) + } + }.onError { + loadChapter(book) } } } + private fun upCoverByRule(book: Book) { + execute { + if (book.coverUrl.isNullOrBlank() && book.customCoverUrl.isNullOrBlank()) { + val coverUrl = BookCover.searchCover(book) + if (!coverUrl.isNullOrBlank()) { + book.customCoverUrl = coverUrl + if (inBookshelf) { + saveBook(book) + } + } + } + book + }.onSuccess { + if (currentBook?.bookUrl == it.bookUrl) { + currentBook = it + syncUiState() + } + } + } + + private fun refreshMeta(book: Book) { + execute { + val kinds = book.getKindList().toMutableList() + if (book.isLocal) { + val size = FileDoc.fromFile(book.bookUrl).size + if (size > 0) { + kinds.add(ConvertUtils.formatFileSize(size)) + } + } + val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",") + kinds.toList() to groupNames.ifBlank { null } + }.onSuccess { + currentKindLabels = it.first + currentGroupNames = it.second + syncUiState() + } + } + + private fun loadChapter( + book: Book, + runPreUpdateJs: Boolean = true, + scope: CoroutineScope = viewModelScope, + ) { + syncUiState(isTocLoading = true) + if (book.isLocal) { + execute(scope) { + LocalBook.getChapterList(book).also { + appDb.bookDao.update(book) + appDb.bookChapterDao.delByBook(book.bookUrl) + appDb.bookChapterDao.insert(*it.toTypedArray()) + ReadBook.onChapterListUpdated(book) + } + }.onSuccess { + currentBook = book + currentChapterList = it + syncUiState(isTocLoading = false) + }.onError { + currentChapterList = emptyList() + syncUiState(isTocLoading = false) + context.toastOnUi("LoadTocError:${it.localizedMessage}") + } + } else { + val source = bookSource ?: run { + currentChapterList = emptyList() + syncUiState(isTocLoading = false) + context.toastOnUi(R.string.error_no_source) + return + } + val oldBook = book.copy() + WebBook.getChapterList(scope, source, book, runPreUpdateJs) + .onSuccess(IO) { chapters -> + if (inBookshelf) { + appDb.bookDao.replace(oldBook, book) + if (oldBook.bookUrl != book.bookUrl) { + BookHelp.updateCacheFolder(oldBook, book) + } + appDb.bookChapterDao.delByBook(oldBook.bookUrl) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) + ReadBook.onChapterListUpdated(book) + } + currentBook = book + currentChapterList = chapters + syncUiState(isTocLoading = false) + }.onError { + currentChapterList = emptyList() + syncUiState(isTocLoading = false) + AppLog.put("获取目录失败\n${it.localizedMessage}", it) + context.toastOnUi(R.string.error_get_chapter_list) + } + } + } + + private fun loadWebFile(book: Book) { + execute { + val fileNameNoExtension = if (book.author.isBlank()) book.name else "${book.name} 作者:${book.author}" + book.downloadUrls.orEmpty().map { url -> + val analyzeUrl = AnalyzeUrl( + url, + source = bookSource, + coroutineContext = coroutineContext, + ) + val fileName = UrlUtil.getFileName(analyzeUrl) + ?: "${fileNameNoExtension}.${analyzeUrl.type}" + BookInfoWebFile(url = url, name = fileName) + } + }.onSuccess { + currentWebFiles = it + syncUiState(isTocLoading = false) + }.onError { + currentWebFiles = emptyList() + context.toastOnUi("LoadWebFileError\n${it.localizedMessage}") + syncUiState(isTocLoading = false) + } + } + + private fun onBackPressed() { + if (!inBookshelf && AppConfig.showAddToShelfAlert && currentBook != null) { + showDialog(BookInfoDialog.AddToShelfOnBack) + } else { + emitEffect(BookInfoEffect.Finish(afterTransition = true)) + } + } + + private fun onReadClick() { + val book = currentBook ?: return + if (book.isWebFile) { + setSheet(BookInfoSheet.WebFiles(openAfterImport = true)) + } else { + readBook(book) + } + } + + private fun onShelfClick() { + val book = currentBook ?: return + if (inBookshelf) { + showDialog(BookInfoDialog.DeleteBook(book.isLocal)) + } else if (book.isWebFile) { + setSheet(BookInfoSheet.WebFiles(openAfterImport = false)) + } else { + addToBookshelf() + } + } + + private fun onTocClick() { + val book = currentBook ?: return + if (currentChapterList.isEmpty()) { + context.toastOnUi(R.string.chapter_list_empty) + return + } + if (!inBookshelf) { + saveBook(book) { + saveChapterList { + emitEffect(BookInfoEffect.OpenToc(book.bookUrl)) + } + } + } else { + emitEffect(BookInfoEffect.OpenToc(book.bookUrl)) + } + } + + private fun updateGroup(groupId: Long) { + currentBook?.let { book -> + book.group = groupId + currentGroupNames = null + refreshMeta(book) + if (inBookshelf) { + saveBook(book) + } else if (groupId > 0) { + addToBookshelf() + } else { + syncUiState() + } + } + } + + private fun updateCover(coverUrl: String) { + currentBook?.let { book -> + book.customCoverUrl = coverUrl + currentBook = book + syncUiState() + if (inBookshelf) { + saveBook(book) + } + } + } + private fun deleteBook(deleteOriginal: Boolean) { + currentBook?.let { book -> + SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, bookSource, book) + delBook(deleteOriginal) { + emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK)) + } + } + } + + private fun handleWebFileSelection(webFile: BookInfoWebFile, openAfterImport: Boolean) { + when { + webFile.isSupported -> { + dismissSheet() + importOrDownloadWebFile(webFile) { book -> + if (openAfterImport) { + openReader(book) + } + } + } + + webFile.isSupportDecompress -> { + importOrDownloadWebFile(webFile) { uri -> + getArchiveFilesName(uri) { fileNames -> + if (fileNames.size == 1) { + importArchiveBook(uri, fileNames.first()) { book -> + if (openAfterImport) { + openReader(book) + } + } + } else { + setSheet( + BookInfoSheet.ArchiveEntries( + archiveUri = uri, + entries = fileNames, + openAfterImport = openAfterImport, + ) + ) + } + } + } + } + + else -> { + showDialog(BookInfoDialog.UnsupportedWebFile(webFile, openAfterImport)) + } + } + } + + private fun readBook(book: Book) { + if (!inBookshelf) { + book.addType(BookType.notShelf) + saveBook(book) { + saveChapterList { + openReader(book) + } + } + } else { + saveBook(book) { + openReader(book) + } + } + } + + private fun openReader(book: Book) { + emitEffect(BookInfoEffect.OpenReader(book.uiCopy(), inBookshelf, chapterChanged)) + } + + private fun handleMenuAction(action: BookInfoMenuAction) { + val book = currentBook ?: return + when (action) { + BookInfoMenuAction.Edit -> openEdit() + BookInfoMenuAction.Share -> { + val bookJson = GSON.toJson(book) + emitEffect( + BookInfoEffect.RunSourceCallback( + event = SourceCallBack.CLICK_SHARE_BOOK, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.ShareText( + chooserTitle = book.name, + text = "${book.bookUrl}#$bookJson", + ) + ) + ) + } + + BookInfoMenuAction.Upload -> uploadBook { + context.toastOnUi("上传成功") + } + BookInfoMenuAction.SyncRemote -> syncFromRemote() + BookInfoMenuAction.Refresh -> refreshCurrentBook() + BookInfoMenuAction.Login -> bookSource?.let { + emitEffect(BookInfoEffect.OpenSourceLogin(it.bookSourceUrl)) + } + + BookInfoMenuAction.Top -> topBook() + BookInfoMenuAction.SetSourceVariable -> requestSourceVariableDialog() + BookInfoMenuAction.SetBookVariable -> requestBookVariableDialog() + BookInfoMenuAction.CopyBookUrl -> emitEffect( + BookInfoEffect.RunSourceCallback( + event = SourceCallBack.CLICK_COPY_BOOK_URL, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.CopyText(book.bookUrl), + ) + ) + + BookInfoMenuAction.CopyTocUrl -> emitEffect( + BookInfoEffect.RunSourceCallback( + event = SourceCallBack.CLICK_COPY_TOC_URL, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.CopyText(book.tocUrl), + ) + ) + + BookInfoMenuAction.ToggleCanUpdate -> toggleCanUpdate() + BookInfoMenuAction.ToggleSplitLongChapter -> toggleSplitLongChapter() + BookInfoMenuAction.ToggleDeleteAlert -> toggleDeleteAlert() + BookInfoMenuAction.ClearCache -> emitEffect( + BookInfoEffect.RunSourceCallback( + event = SourceCallBack.CLICK_CLEAR_CACHE, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.ClearCache, + ) + ) + + BookInfoMenuAction.ShowLog -> showAppLog() + } + } + + private fun onAuthorClick(longClick: Boolean) { + val book = currentBook ?: return + emitEffect( + BookInfoEffect.RunSourceCallback( + event = if (longClick) SourceCallBack.LONG_CLICK_AUTHOR else SourceCallBack.CLICK_AUTHOR, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.Search(book.author), + ) + ) + } + + private fun onBookNameClick(longClick: Boolean) { + val book = currentBook ?: return + emitEffect( + BookInfoEffect.RunSourceCallback( + event = if (longClick) SourceCallBack.LONG_CLICK_BOOK_NAME else SourceCallBack.CLICK_BOOK_NAME, + source = bookSource, + book = book.uiCopy(), + action = BookInfoCallbackAction.Search(book.name), + ) + ) + } + + private fun onOriginClick() { + val book = currentBook ?: return + if (book.isLocal) return + if (!appDb.bookSourceDao.has(book.origin)) { + context.toastOnUi(R.string.error_no_source) + return + } + emitEffect(BookInfoEffect.OpenBookSourceEdit(book.origin)) + } + + fun getArchiveFilesName(archiveFileUri: Uri, onSuccess: (List) -> Unit) { + execute { + ArchiveUtils.getArchiveFilesName(archiveFileUri) { + AppPattern.bookFileRegex.matches(it) + } + }.onError { + AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it) + context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}") + }.onSuccess { + onSuccess.invoke(it) + } + } + + fun importArchiveBook( + archiveFileUri: Uri, + archiveEntryName: String, + success: ((Book) -> Unit)? = null, + ) { + execute { + val suffix = archiveEntryName.substringAfterLast(".") + LocalBook.importArchiveFile( + archiveFileUri, + currentBook!!.getExportFileName(suffix) + ) { + it.contains(archiveEntryName) + }.first() + }.onSuccess { + val book = changeToLocalBook(it) + success?.invoke(book) + }.onError { + AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it) + context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}") + } + } + + fun importOrDownloadWebFile(webFile: BookInfoWebFile, success: ((T) -> Unit)? = null) { + bookSource ?: return + val book = currentBook ?: return + execute { + setBusy(true) + if (webFile.isSupported) { + val localBook = LocalBook.importFileOnLine( + webFile.url, + book.getExportFileName(webFile.suffix), + bookSource + ) + changeToLocalBook(localBook) + } else { + LocalBook.saveBookFile( + webFile.url, + book.getExportFileName(webFile.suffix), + bookSource + ) + } + }.onSuccess { + @Suppress("UNCHECKED_CAST") + success?.invoke(it as T) + }.onError { + when (it) { + is NoBooksDirException -> emitEffect(BookInfoEffect.OpenSelectBooksDir) + else -> { + AppLog.put("ImportWebFileError\n${it.localizedMessage}", it) + context.toastOnUi("ImportWebFileError\n${it.localizedMessage}") + } + } + }.onFinally { + setBusy(false) + } + } + private fun changeToLocalBook(localBook: Book): Book { - return LocalBook.mergeBook(localBook, bookData.value).let { - bookData.postValue(it) - loadChapter(it) + return LocalBook.mergeBook(localBook, currentBook).let { + currentBook = it + currentWebFiles = emptyList() inBookshelf = true + syncUiState(isTocLoading = true) + refreshMeta(it) + loadChapter(it) it } } - data class WebFile( - val url: String, - val name: String, - ) { - - override fun toString(): String { - return name - } - - // 后缀 - val suffix: String = UrlUtil.getSuffix(name) - - // txt epub umd pdf等文件 - val isSupported: Boolean = AppPattern.bookFileRegex.matches(name) - - // 压缩包形式的txt epub umd pdf文件 - val isSupportDecompress: Boolean = AppPattern.archiveFileRegex.matches(name) - + private fun dismissSheet() { + setSheet(BookInfoSheet.None) } + private fun setSheet(sheet: BookInfoSheet) { + _uiState.update { it.copy(sheet = sheet) } + } + + private fun dismissDialog() { + showDialog(null) + } + + private fun showDialog(dialog: BookInfoDialog?) { + _uiState.update { it.copy(dialog = dialog) } + } + + private fun setBusy(isBusy: Boolean) { + _uiState.update { it.copy(isBusy = isBusy) } + } + + private fun syncUiState(isTocLoading: Boolean = _uiState.value.isTocLoading) { + _uiState.update { + it.copy( + book = currentBook?.uiCopy(), + chapterList = currentChapterList, + webFiles = currentWebFiles, + kindLabels = currentKindLabels, + groupNames = currentGroupNames, + inBookshelf = inBookshelf, + bookSource = bookSource, + isTocLoading = isTocLoading, + ) + } + } + + private fun emitEffect(effect: BookInfoEffect) { + _effects.tryEmit(effect) + } + + private fun Book.uiCopy(): Book { + return copy().also { snapshot -> + snapshot.infoHtml = infoHtml + snapshot.tocHtml = tocHtml + snapshot.downloadUrls = downloadUrls + } + } } + +private val BookInfoWebFile.suffix: String + get() = UrlUtil.getSuffix(name) + +private val BookInfoWebFile.isSupported: Boolean + get() = AppPattern.bookFileRegex.matches(name) + +private val BookInfoWebFile.isSupportDecompress: Boolean + get() = AppPattern.archiveFileRegex.matches(name) diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt index e53b377dd..a93f84c90 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt @@ -5,9 +5,11 @@ 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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -89,8 +91,10 @@ fun BookInfoEditScreen( uiState.book?.let { BookInfoEditContent( modifier = Modifier - .padding(paddingValues) .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .imePadding() .verticalScroll(rememberScrollState()), uiState = uiState, viewModel = viewModel diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt index 045d7fd84..5d9b968fa 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/LauncherIconPickerSheet.kt @@ -57,18 +57,14 @@ fun LauncherIconPickerSheet( AppModalBottomSheet( show = show, - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, + title = stringResource(R.string.change_icon) ) { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 24.dp) ) { - AppText( - text = stringResource(R.string.change_icon), - style = LegadoTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) LazyVerticalGrid( columns = GridCells.Fixed(3), @@ -89,15 +85,15 @@ fun LauncherIconPickerSheet( .clip(MaterialTheme.shapes.large) .background( if (isSelected) - MaterialTheme.colorScheme.secondaryContainer + LegadoTheme.colorScheme.secondaryContainer else - MaterialTheme.colorScheme.surfaceContainer + LegadoTheme.colorScheme.surfaceContainer ) .then( if (isSelected) { Modifier.border( width = 2.dp, - color = MaterialTheme.colorScheme.primary, + color = LegadoTheme.colorScheme.primary, shape = MaterialTheme.shapes.large ) } else { diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index b34636be3..e2f846e22 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -24,6 +24,10 @@ object ThemeConfig { //m3 or miuix var composeEngine by prefDelegate(PreferKey.composeEngine, "material") + var useMiuixMonet by prefDelegate(PreferKey.useMiuixMonet, false) { + postEvent(EventBus.RECREATE, "") + } + var materialVersion by prefDelegate(PreferKey.materialVersion, "material3") var appTheme by prefDelegate(PreferKey.appTheme, "0") diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index 9b5e0d2fc..82c0b4843 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -75,7 +75,7 @@ import io.legado.app.help.config.OldThemeConfig import io.legado.app.lib.theme.ThemeStore import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.ThemeManager +import io.legado.app.ui.theme.ThemeEngine import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold @@ -112,6 +112,7 @@ fun ThemeConfigScreen( var selectedThemeMode by remember { mutableStateOf(ThemeConfig.themeMode) } var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) } + var useMiuixMonet by remember { mutableStateOf(ThemeConfig.useMiuixMonet) } var showRestartDialog by remember { mutableStateOf(false) } var showColorPicker by remember { mutableStateOf(false) } var showLauncherIconPicker by remember { mutableStateOf(false) } @@ -202,6 +203,37 @@ fun ThemeConfigScreen( OldThemeConfig.applyDayNight(context) } ) + + SwitchSettingItem( + title = stringResource(R.string.miuix_monet), + description = stringResource(R.string.miuix_monet_summary), + checked = useMiuixMonet, + onCheckedChange = { + useMiuixMonet = it + ThemeConfig.useMiuixMonet = it + if (it && selectedTheme != "0" && selectedTheme != "12") { + selectedTheme = "0" + ThemeConfig.appTheme = "0" + } + } + ) + + if (useMiuixMonet) { + SwitchSettingItem( + title = stringResource(R.string.dynamic_colors), + description = stringResource(R.string.dynamic_colors_summary), + checked = selectedTheme == "0", + onCheckedChange = { checked -> + val newTheme = if (checked) "0" else "12" + val oldTheme = selectedTheme + selectedTheme = newTheme + ThemeConfig.appTheme = newTheme + if (oldTheme != newTheme) { + showRestartDialog = true + } + } + ) + } } else { ThemeModeSelector( selectedMode = selectedThemeMode, @@ -249,10 +281,14 @@ fun ThemeConfigScreen( } SplicedColumnGroup { - SwitchSettingItem( - title = stringResource(R.string.pure_black), - checked = ThemeConfig.isPureBlack, - onCheckedChange = { ThemeConfig.isPureBlack = it } + DropdownListSettingItem( + title = stringResource(R.string.compose_engine), + selectedValue = ThemeConfig.composeEngine, + displayEntries = stringArrayResource(R.array.composeEngine), + entryValues = stringArrayResource(R.array.composeEngine_value), + onValueChange = { + ThemeConfig.composeEngine = it + } ) ClickableSettingItem( title = stringResource(R.string.change_icon), @@ -268,15 +304,6 @@ fun ThemeConfigScreen( context.toastOnUi(R.string.restart_to_apply) } ) - DropdownListSettingItem( - title = stringResource(R.string.compose_engine), - selectedValue = ThemeConfig.composeEngine, - displayEntries = stringArrayResource(R.array.composeEngine), - entryValues = stringArrayResource(R.array.composeEngine_value), - onValueChange = { - ThemeConfig.composeEngine = it - } - ) SliderSettingItem( title = stringResource(R.string.font_scale), description = stringResource( @@ -294,7 +321,8 @@ fun ThemeConfigScreen( ) } - if (selectedTheme == "12") { + val showCustomThemeOptions = selectedTheme == "12" && (!isMiuixEngine || useMiuixMonet) + if (showCustomThemeOptions) { SplicedColumnGroup(title = stringResource(R.string.custom_theme)) { ClickableSettingItem( title = stringResource(R.string.seed_color), @@ -426,11 +454,18 @@ fun ThemeConfigScreen( } SplicedColumnGroup(title = stringResource(R.string.compose_related)) { - SwitchSettingItem( - title = stringResource(R.string.use_flexible_top_bar), - checked = ThemeConfig.useFlexibleTopAppBar, - onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it } - ) + if (!isMiuixEngine) { + SwitchSettingItem( + title = stringResource(R.string.pure_black), + checked = ThemeConfig.isPureBlack, + onCheckedChange = { ThemeConfig.isPureBlack = it } + ) + SwitchSettingItem( + title = stringResource(R.string.use_flexible_top_bar), + checked = ThemeConfig.useFlexibleTopAppBar, + onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it } + ) + } SwitchSettingItem( title = stringResource(R.string.is_blur_enable), checked = ThemeConfig.enableBlur, @@ -446,7 +481,7 @@ fun ThemeConfigScreen( onCheckedChange = { ThemeConfig.enableProgressiveBlur = it } ) } - AnimatedVisibility(visible = !ThemeConfig.enableBlur) { + AnimatedVisibility(visible = !isMiuixEngine && !ThemeConfig.enableBlur) { Column( verticalArrangement = Arrangement.spacedBy(2.dp) ) { @@ -476,18 +511,20 @@ fun ThemeConfigScreen( ) } } - SliderSettingItem( - title = stringResource(R.string.container_opacity), - description = stringResource( - R.string.container_opacity_summary, - ThemeConfig.containerOpacity - ), - value = ThemeConfig.containerOpacity.toFloat(), - defaultValue = 100f, - valueRange = 0f..100f, - steps = 99, - onValueChange = { ThemeConfig.containerOpacity = it.toInt() } - ) + if (!isMiuixEngine) { + SliderSettingItem( + title = stringResource(R.string.container_opacity), + description = stringResource( + R.string.container_opacity_summary, + ThemeConfig.containerOpacity + ), + value = ThemeConfig.containerOpacity.toFloat(), + defaultValue = 100f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { ThemeConfig.containerOpacity = it.toInt() } + ) + } } SplicedColumnGroup(title = stringResource(R.string.day)) { @@ -738,7 +775,7 @@ fun ThemeColorButton( shape = RoundedCornerShape(16.dp), border = if (isSelected) BorderStroke( borderWidth, - MaterialTheme.colorScheme.primary + LegadoTheme.colorScheme.primary ) else null, colors = CardDefaults.cardColors(containerColor = colors.surfaceContainer) ) { @@ -801,7 +838,7 @@ fun ThemeColorButton( AppText( text = label, style = LegadoTheme.typography.labelSmall, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + color = if (isSelected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurface ) } } @@ -915,7 +952,7 @@ private fun getThemeColorPalette( materialVersion: String? = null ): ThemeColorPalette { val appThemeMode = ThemeResolver.resolveThemeMode(value) - val colorScheme = ThemeManager.getColorScheme( + val colorScheme = ThemeEngine.getColorScheme( context = context, mode = appThemeMode, darkTheme = isDark, @@ -942,7 +979,7 @@ private fun getThemeColors( materialVersion: String? = null ): ThemeColors { val appThemeMode = ThemeResolver.resolveThemeMode(value) - val colorScheme = ThemeManager.getColorScheme( + val colorScheme = ThemeEngine.getColorScheme( context = context, mode = appThemeMode, darkTheme = isDark, diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt index eb865dfca..c5310591a 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt @@ -38,6 +38,7 @@ import io.legado.app.help.config.OldThemeConfig import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.text.AppText @@ -59,41 +60,27 @@ fun ThemeListDialog( AppModalBottomSheet( show = show, - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, + title = stringResource(R.string.theme_list), + endAction = { + MediumIconButton( + onClick = { + val clipText = context.getClipText() + if (clipText != null && OldThemeConfig.addConfig(clipText)) { + listVersion++ + } else { + context.toastOnUi("Import failed") + } + }, + imageVector = Icons.Default.FileDownload + ) + } ) { Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 24.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppText( - text = stringResource(R.string.theme_list), - style = LegadoTheme.typography.titleMedium, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = { - val clipText = context.getClipText() - if (clipText != null && OldThemeConfig.addConfig(clipText)) { - listVersion++ - } else { - context.toastOnUi("Import failed") - } - } - ) { - Icon( - imageVector = Icons.Default.FileDownload, - contentDescription = stringResource(R.string.import_theme) - ) - } - } - LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) @@ -104,8 +91,7 @@ fun ThemeListDialog( OldThemeConfig.applyConfig(context, item) }, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth(), shape = RoundedCornerShape(20.dp), containerColor = if (item.primaryColor.toColorInt() == context.primaryColor) { MaterialTheme.colorScheme.secondaryContainer diff --git a/app/src/main/java/io/legado/app/ui/theme/AppContentColor.kt b/app/src/main/java/io/legado/app/ui/theme/AppContentColor.kt new file mode 100644 index 000000000..d4a7d28fb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/AppContentColor.kt @@ -0,0 +1,19 @@ +package io.legado.app.ui.theme + +import androidx.compose.material3.LocalContentColor as MaterialLocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import top.yukonga.miuix.kmp.theme.LocalContentColor as MiuixLocalContentColor + +@Composable +fun ProvideAppContentColor( + contentColor: Color, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + MaterialLocalContentColor provides contentColor, + MiuixLocalContentColor provides contentColor, + content = content + ) +} diff --git a/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt b/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt index 8ab051faf..1199280b6 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.theme +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme @@ -11,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import io.legado.app.ui.config.themeConfig.ThemeConfig import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -24,33 +27,56 @@ fun AppTheme( ) { val context = LocalContext.current val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme) + val themeModeValue = ThemeConfig.themeMode val isPureBlack = ThemeConfig.isPureBlack - val paletteStyleStr = ThemeConfig.paletteStyle + val paletteStyleValue = ThemeConfig.paletteStyle val materialVersion = ThemeConfig.materialVersion val composeEngine = ThemeConfig.composeEngine - val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(ThemeConfig.themeMode) - val paletteStyle = - remember(paletteStyleStr) { ThemeResolver.resolvePaletteStyle(paletteStyleStr) } - val seedColor = remember(ThemeConfig.cPrimary) { - if (ThemeConfig.cPrimary != 0) Color(ThemeConfig.cPrimary) else Color(0xFF3482FF) + val useMiuixMonet = ThemeConfig.useMiuixMonet + val customPrimary = ThemeConfig.cPrimary + val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue) + val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) { + ThemeResolver.resolveMiuixColorSchemeMode(themeModeValue, useMiuixMonet) } + val paletteStyle = + remember(paletteStyleValue) { ThemeResolver.resolvePaletteStyle(paletteStyleValue) } val colorScheme = - remember(context, appThemeMode, darkTheme, isPureBlack, paletteStyleStr, materialVersion) { - ThemeManager.getColorScheme( + remember( + context, + appThemeMode, + darkTheme, + isPureBlack, + paletteStyleValue, + materialVersion + ) { + ThemeEngine.getColorScheme( context = context, mode = appThemeMode, darkTheme = darkTheme, isAmoled = isPureBlack, - paletteStyle = paletteStyleStr, + paletteStyle = paletteStyleValue, materialVersion = materialVersion ) } + val customSeedColor = remember(customPrimary, colorScheme.primary) { + if (customPrimary != 0) Color(customPrimary) else colorScheme.primary + } + val themeSeedColor = remember(appThemeMode, customSeedColor, colorScheme.primary) { + if (appThemeMode == AppThemeMode.Custom) customSeedColor else colorScheme.primary + } + val miuixPaletteStyle = remember(paletteStyleValue) { + ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue) + } + val miuixColorSpec = remember(materialVersion, paletteStyleValue) { + ThemeResolver.resolveMiuixColorSpec(materialVersion, paletteStyleValue) + } + val themeColors = remember( colorScheme, darkTheme, - seedColor, + themeSeedColor, paletteStyle, colorSchemeMode, composeEngine @@ -58,7 +84,7 @@ fun AppTheme( LegadoThemeMode( colorScheme = colorScheme, isDark = darkTheme, - seedColor = seedColor, + seedColor = themeSeedColor, paletteStyle = paletteStyle, themeMode = colorSchemeMode, useDynamicColor = appThemeMode == AppThemeMode.Dynamic, @@ -70,8 +96,37 @@ fun AppTheme( LocalLegadoThemeColors provides themeColors ) { if (ThemeResolver.isMiuixEngine(themeColors.composeEngine)) { - val controller = remember(colorSchemeMode, darkTheme) { - ThemeController(colorSchemeMode = colorSchemeMode, isDark = darkTheme) + val keyColor = if (useMiuixMonet && + themeColors.useDynamicColor && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + colorResource(id = android.R.color.system_accent1_500) + } else { + themeSeedColor + } + + val controller = remember( + miuixColorSchemeMode, + useMiuixMonet, + keyColor, + miuixPaletteStyle, + miuixColorSpec, + darkTheme + ) { + if (useMiuixMonet) { + ThemeController( + colorSchemeMode = miuixColorSchemeMode, + keyColor = keyColor, + paletteStyle = miuixPaletteStyle, + colorSpec = miuixColorSpec, + isDark = darkTheme + ) + } else { + ThemeController( + colorSchemeMode = miuixColorSchemeMode, + isDark = darkTheme + ) + } } MiuixTheme(controller = controller) { @@ -79,34 +134,34 @@ fun AppTheme( val legadoTypography = remember(miuixStyles) { LegadoTypography( headlineLarge = miuixStyles.title1, - headlineLargeEmphasized = miuixStyles.title1.copy(fontWeight = FontWeight.Medium), + headlineLargeEmphasized = miuixStyles.title1.emphasized(), headlineMedium = miuixStyles.title2, - headlineMediumEmphasized = miuixStyles.title2.copy(fontWeight = FontWeight.Medium), + headlineMediumEmphasized = miuixStyles.title2.emphasized(), headlineSmall = miuixStyles.title3, - headlineSmallEmphasized = miuixStyles.title3.copy(fontWeight = FontWeight.Medium), + headlineSmallEmphasized = miuixStyles.title3.emphasized(), titleLarge = miuixStyles.headline1, - titleLargeEmphasized = miuixStyles.headline1.copy(fontWeight = FontWeight.Medium), + titleLargeEmphasized = miuixStyles.headline1.emphasized(), titleMedium = miuixStyles.headline2, - titleMediumEmphasized = miuixStyles.headline2.copy(fontWeight = FontWeight.Medium), + titleMediumEmphasized = miuixStyles.headline2.emphasized(), titleSmall = miuixStyles.subtitle, - titleSmallEmphasized = miuixStyles.subtitle.copy(fontWeight = FontWeight.Medium), + titleSmallEmphasized = miuixStyles.subtitle.emphasized(), bodyLarge = miuixStyles.paragraph, - bodyLargeEmphasized = miuixStyles.paragraph.copy(fontWeight = FontWeight.Medium), + bodyLargeEmphasized = miuixStyles.paragraph.emphasized(), bodyMedium = miuixStyles.body1, - bodyMediumEmphasized = miuixStyles.body1.copy(fontWeight = FontWeight.Medium), + bodyMediumEmphasized = miuixStyles.body1.emphasized(), bodySmall = miuixStyles.body2, - bodySmallEmphasized = miuixStyles.body2.copy(fontWeight = FontWeight.Medium), + bodySmallEmphasized = miuixStyles.body2.emphasized(), labelLarge = miuixStyles.button, - labelLargeEmphasized = miuixStyles.button.copy(fontWeight = FontWeight.Medium), + labelLargeEmphasized = miuixStyles.button.emphasized(), labelMedium = miuixStyles.footnote1, - labelMediumEmphasized = miuixStyles.footnote1.copy(fontWeight = FontWeight.Medium), + labelMediumEmphasized = miuixStyles.footnote1.emphasized(), labelSmall = miuixStyles.footnote2, - labelSmallEmphasized = miuixStyles.footnote2.copy(fontWeight = FontWeight.Medium) + labelSmallEmphasized = miuixStyles.footnote2.emphasized() ) } @@ -130,39 +185,28 @@ fun AppTheme( tertiaryContainer = miuixColorScheme.primaryContainer, onTertiaryContainer = miuixColorScheme.primaryVariant, - // ================= 4. 背景与表面 (Background & Surface) ================= background = miuixColorScheme.background, onBackground = miuixColorScheme.onBackground, surface = miuixColorScheme.surface, onSurface = miuixColorScheme.onSurface, surfaceVariant = miuixColorScheme.surfaceVariant, - // M3 的 onSurfaceVariant 通常是次级文字色。Miuix 的 onSurfaceSecondary 完美契合这个语义 onSurfaceVariant = miuixColorScheme.onSurfaceSecondary, - - // M3 中用于给 Surface 叠加一层极淡主题色的属性,通常直接取 primary surfaceTint = miuixColorScheme.primary, - - // Inverse 系列通常用于深色模式下的反色提示(如 Snackbar)。 - // 简单映射法:直接用现有的 onSurface 和 surface 交叉互换。 inverseSurface = miuixColorScheme.onSurface, inverseOnSurface = miuixColorScheme.surface, - // ================= 5. 错误状态 (Error) ================= error = miuixColorScheme.error, onError = miuixColorScheme.onError, errorContainer = miuixColorScheme.errorContainer, onErrorContainer = miuixColorScheme.onErrorContainer, - // ================= 6. 边框、分割线与遮罩 (Outline & Scrim) ================= outline = miuixColorScheme.outline, - // outlineVariant 在 M3 中常用于分割线。Miuix 刚好有 dividerLine outlineVariant = miuixColorScheme.dividerLine, - // scrim 是 M3 的遮罩层(如弹窗背后的阴影)。Miuix 刚好有 windowDimming scrim = miuixColorScheme.windowDimming, - surfaceBright = miuixColorScheme.surface, // Miuix 缺省,用 surface 兜底 - surfaceDim = miuixColorScheme.background, // Miuix 缺省,用 background 兜底 + surfaceBright = miuixColorScheme.surface, + surfaceDim = miuixColorScheme.background, surfaceContainer = miuixColorScheme.surfaceContainer, surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh, surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest, @@ -182,7 +226,7 @@ fun AppTheme( onTertiaryFixed = miuixColorScheme.onTertiaryContainer, onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer, - cardContainer = miuixColorScheme.tertiaryContainer, + cardContainer = miuixColorScheme.disabledPrimary, onCardContainer = miuixColorScheme.primary ) } @@ -195,102 +239,17 @@ fun AppTheme( } } } else { - val Typography = Typography() + val materialTypography = remember { Typography() } MaterialExpressiveTheme( colorScheme = colorScheme, - typography = Typography, + typography = materialTypography, motionScheme = MotionScheme.expressive(), shapes = Shapes() ) { - val legadoTypography = remember(Typography) { - LegadoTypography( - headlineLarge = Typography.headlineLarge, - headlineLargeEmphasized = Typography.headlineLargeEmphasized, - headlineMedium = Typography.headlineMedium, - headlineMediumEmphasized = Typography.headlineMediumEmphasized, - headlineSmall = Typography.headlineSmall, - headlineSmallEmphasized = Typography.headlineSmallEmphasized, - - - titleLarge = Typography.titleLarge, - titleLargeEmphasized = Typography.titleLargeEmphasized, - titleMedium = Typography.titleMedium, - titleMediumEmphasized = Typography.titleMediumEmphasized, - titleSmall = Typography.titleSmall, - titleSmallEmphasized = Typography.titleSmallEmphasized, - - - bodyLarge = Typography.bodyLarge, - bodyLargeEmphasized = Typography.bodyLargeEmphasized, - bodyMedium = Typography.bodyMedium, - bodyMediumEmphasized = Typography.bodyMediumEmphasized, - bodySmall = Typography.bodySmall, - bodySmallEmphasized = Typography.bodySmallEmphasized, - - labelLarge = Typography.labelLarge, - labelLargeEmphasized = Typography.labelLargeEmphasized, - labelMedium = Typography.labelMedium, - labelMediumEmphasized = Typography.labelMediumEmphasized, - labelSmall = Typography.labelSmall, - labelSmallEmphasized = Typography.labelSmallEmphasized - ) - } - - val semanticColors = remember(colorScheme) { - LegadoColorScheme( - primary = colorScheme.primary, - onPrimary = colorScheme.onPrimary, - primaryContainer = colorScheme.primaryContainer, - onPrimaryContainer = colorScheme.onPrimaryContainer, - inversePrimary = colorScheme.inversePrimary, - secondary = colorScheme.secondary, - onSecondary = colorScheme.onSecondary, - secondaryContainer = colorScheme.secondaryContainer, - onSecondaryContainer = colorScheme.onSecondaryContainer, - tertiary = colorScheme.tertiary, - onTertiary = colorScheme.onTertiary, - tertiaryContainer = colorScheme.tertiaryContainer, - onTertiaryContainer = colorScheme.onTertiaryContainer, - background = colorScheme.background, - onBackground = colorScheme.onBackground, - surface = colorScheme.surface, - onSurface = colorScheme.onSurface, - surfaceVariant = colorScheme.surfaceVariant, - onSurfaceVariant = colorScheme.onSurfaceVariant, - surfaceTint = colorScheme.surfaceTint, - inverseSurface = colorScheme.inverseSurface, - inverseOnSurface = colorScheme.inverseOnSurface, - error = colorScheme.error, - onError = colorScheme.onError, - errorContainer = colorScheme.errorContainer, - onErrorContainer = colorScheme.onErrorContainer, - outline = colorScheme.outline, - outlineVariant = colorScheme.outlineVariant, - scrim = colorScheme.scrim, - surfaceBright = colorScheme.surfaceBright, - surfaceDim = colorScheme.surfaceDim, - surfaceContainer = colorScheme.surfaceContainer, - surfaceContainerHigh = colorScheme.surfaceContainerHigh, - surfaceContainerHighest = colorScheme.surfaceContainerHighest, - surfaceContainerLow = colorScheme.surfaceContainerLow, - surfaceContainerLowest = colorScheme.surfaceContainerLowest, - primaryFixed = colorScheme.primaryFixed, - primaryFixedDim = colorScheme.primaryFixedDim, - onPrimaryFixed = colorScheme.onPrimaryFixed, - onPrimaryFixedVariant = colorScheme.onPrimaryFixedVariant, - secondaryFixed = colorScheme.secondaryFixed, - secondaryFixedDim = colorScheme.secondaryFixedDim, - onSecondaryFixed = colorScheme.onSecondaryFixed, - onSecondaryFixedVariant = colorScheme.onSecondaryFixedVariant, - tertiaryFixed = colorScheme.tertiaryFixed, - tertiaryFixedDim = colorScheme.tertiaryFixedDim, - onTertiaryFixed = colorScheme.onTertiaryFixed, - onTertiaryFixedVariant = colorScheme.onTertiaryFixedVariant, - - cardContainer = colorScheme.primaryContainer.copy(alpha = 0.5f), - onCardContainer = colorScheme.primary - ) + val legadoTypography = remember(materialTypography) { + materialTypography.toLegadoTypography() } + val semanticColors = remember(colorScheme) { colorScheme.toLegadoColorScheme() } CompositionLocalProvider( LocalLegadoTypography provides legadoTypography, @@ -302,3 +261,36 @@ fun AppTheme( } } } + +private fun Typography.toLegadoTypography(): LegadoTypography { + return LegadoTypography( + headlineLarge = headlineLarge, + headlineLargeEmphasized = headlineLargeEmphasized, + headlineMedium = headlineMedium, + headlineMediumEmphasized = headlineMediumEmphasized, + headlineSmall = headlineSmall, + headlineSmallEmphasized = headlineSmallEmphasized, + titleLarge = titleLarge, + titleLargeEmphasized = titleLargeEmphasized, + titleMedium = titleMedium, + titleMediumEmphasized = titleMediumEmphasized, + titleSmall = titleSmall, + titleSmallEmphasized = titleSmallEmphasized, + bodyLarge = bodyLarge, + bodyLargeEmphasized = bodyLargeEmphasized, + bodyMedium = bodyMedium, + bodyMediumEmphasized = bodyMediumEmphasized, + bodySmall = bodySmall, + bodySmallEmphasized = bodySmallEmphasized, + labelLarge = labelLarge, + labelLargeEmphasized = labelLargeEmphasized, + labelMedium = labelMedium, + labelMediumEmphasized = labelMediumEmphasized, + labelSmall = labelSmall, + labelSmallEmphasized = labelSmallEmphasized + ) +} + +private fun TextStyle.emphasized(): TextStyle { + return copy(fontWeight = FontWeight.Medium) +} diff --git a/app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt b/app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt index e4904f449..e5e0d4a1d 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AppThemeMode.kt @@ -13,7 +13,7 @@ enum class AppThemeMode { Yuuka, Phoebe, Mujika, - CUSTOM, + Custom, Transparent } diff --git a/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt b/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt new file mode 100644 index 000000000..483df3f66 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt @@ -0,0 +1,137 @@ +package io.legado.app.ui.theme + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.scale +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.materialkolor.quantize.QuantizerCelebi +import com.materialkolor.score.Score +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val IMAGE_COLOR_EXTRACT_SIZE_PX = 256 +private const val IMAGE_QUANTIZE_BITMAP_MAX_SIZE = 128 +private const val IMAGE_MAX_QUANTIZE_COLORS = 128 +private const val IMAGE_FALLBACK_SEED_COLOR = 0xFF4285F4.toInt() + +suspend fun ImageLoader.extractSeedColor( + context: Context, + data: Any, + configureRequest: ImageRequest.Builder.() -> Unit = {}, +): Color? { + val request = ImageRequest.Builder(context) + .data(data) + .allowHardware(false) + .apply(configureRequest) + .build() + + val result = withContext(Dispatchers.IO) { + execute(request) + } as? SuccessResult ?: return null + + return withContext(Dispatchers.Default) { + val bitmap = result.drawable.toSafeBitmap(IMAGE_COLOR_EXTRACT_SIZE_PX) + Color(bitmap.extractSeedColor()) + } +} + +@Composable +fun rememberImageSeedColor( + imageLoader: ImageLoader, + data: Any?, + requestKey: Any? = data, + configureRequest: ImageRequest.Builder.() -> Unit = {}, +): Color? { + val context = LocalContext.current + val seedColor by produceState(initialValue = null, imageLoader, requestKey) { + value = if (data == null) { + null + } else { + imageLoader.extractSeedColor( + context = context, + data = data, + configureRequest = configureRequest + ) + } + } + return seedColor +} + +internal fun Bitmap.extractSeedColor( + maxColors: Int = IMAGE_MAX_QUANTIZE_COLORS, + fallbackColorArgb: Int = IMAGE_FALLBACK_SEED_COLOR, +): Int { + val needsScaling = + width > IMAGE_QUANTIZE_BITMAP_MAX_SIZE || height > IMAGE_QUANTIZE_BITMAP_MAX_SIZE + + val scaledBitmap = if (needsScaling) { + val scale = minOf( + IMAGE_QUANTIZE_BITMAP_MAX_SIZE.toFloat() / width, + IMAGE_QUANTIZE_BITMAP_MAX_SIZE.toFloat() / height + ) + scale( + (width * scale).toInt().coerceAtLeast(1), + (height * scale).toInt().coerceAtLeast(1) + ) + } else { + this + } + + return try { + val pixels = IntArray(scaledBitmap.width * scaledBitmap.height) + scaledBitmap.getPixels( + pixels, + 0, + scaledBitmap.width, + 0, + 0, + scaledBitmap.width, + scaledBitmap.height + ) + + val quantized = QuantizerCelebi.quantize(pixels, maxColors) + Score.score(quantized, 1, fallbackColorArgb, true).first() + } finally { + if (scaledBitmap !== this) { + scaledBitmap.recycle() + } + } +} + +internal fun Drawable.toSafeBitmap(maxSizePx: Int): Bitmap { + if (this is BitmapDrawable && bitmap != null) { + val rawBitmap = bitmap + if (rawBitmap.width <= maxSizePx && rawBitmap.height <= maxSizePx) { + return rawBitmap + } + + val scale = minOf( + 1f, + maxSizePx.toFloat() / rawBitmap.width, + maxSizePx.toFloat() / rawBitmap.height + ) + return rawBitmap.scale( + (rawBitmap.width * scale).toInt().coerceAtLeast(1), + (rawBitmap.height * scale).toInt().coerceAtLeast(1) + ) + } + + val rawWidth = intrinsicWidth.takeIf { it > 0 } ?: maxSizePx + val rawHeight = intrinsicHeight.takeIf { it > 0 } ?: maxSizePx + val scale = minOf(1f, maxSizePx.toFloat() / rawWidth, maxSizePx.toFloat() / rawHeight) + + return toBitmap( + width = (rawWidth * scale).toInt().coerceAtLeast(1), + height = (rawHeight * scale).toInt().coerceAtLeast(1) + ) +} diff --git a/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt b/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt index 429cf7966..883dbc880 100644 --- a/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import io.legado.app.ui.config.themeConfig.ThemeConfig -import io.legado.app.ui.theme.ThemeManager.getColorScheme +import io.legado.app.ui.theme.ThemeEngine.getColorScheme @Composable fun rememberOpaqueColorScheme(): ColorScheme { @@ -27,4 +27,4 @@ fun rememberOpaqueColorScheme(): ColorScheme { forceOpaque = true ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt new file mode 100644 index 000000000..9d67a8e12 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt @@ -0,0 +1,242 @@ +package io.legado.app.ui.theme + +import androidx.compose.animation.animateColor +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import io.legado.app.ui.config.themeConfig.ThemeConfig +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController + +fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme { + return LegadoColorScheme( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + inversePrimary = inversePrimary, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = surfaceTint, + inverseSurface = inverseSurface, + inverseOnSurface = inverseOnSurface, + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + outline = outline, + outlineVariant = outlineVariant, + scrim = scrim, + surfaceBright = surfaceBright, + surfaceDim = surfaceDim, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceContainerHigh, + surfaceContainerHighest = surfaceContainerHighest, + surfaceContainerLow = surfaceContainerLow, + surfaceContainerLowest = surfaceContainerLowest, + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixed, + onPrimaryFixedVariant = onPrimaryFixedVariant, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixed, + onSecondaryFixedVariant = onSecondaryFixedVariant, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixed, + onTertiaryFixedVariant = onTertiaryFixedVariant, + cardContainer = primaryContainer.copy(alpha = 0.5f), + onCardContainer = primary + ) +} + +@Composable +fun ProvideColorSchemeOverride( + colorScheme: ColorScheme, + seedColor: Color = colorScheme.primary, + content: @Composable () -> Unit, +) { + val themeAnimationSpec = tween( + durationMillis = 700, + easing = FastOutSlowInEasing + ) + val baseThemeMode = LocalLegadoThemeColors.current + val animatedColorScheme = colorScheme.animateColorSchemeAsState(themeAnimationSpec) + val animatedSeedColor = animateColorAsState( + targetValue = seedColor, + animationSpec = themeAnimationSpec, + label = "theme_seed_animation" + ).value + val legadoColorScheme = remember(animatedColorScheme) { animatedColorScheme.toLegadoColorScheme() } + val overrideThemeMode = remember(baseThemeMode, animatedColorScheme, animatedSeedColor) { + baseThemeMode.copy( + colorScheme = animatedColorScheme, + seedColor = animatedSeedColor, + ) + } + val materialTypography = MaterialTheme.typography + val materialShapes = MaterialTheme.shapes + val isMiuixEngine = ThemeResolver.isMiuixEngine(overrideThemeMode.composeEngine) + val miuixColorSchemeMode = remember(overrideThemeMode.themeMode) { + overrideThemeMode.themeMode.toMiuixMonetMode() + } + val miuixPaletteStyle = remember(ThemeConfig.paletteStyle) { + ThemeResolver.resolveMiuixPaletteStyle(ThemeConfig.paletteStyle) + } + val miuixColorSpec = remember(ThemeConfig.materialVersion, ThemeConfig.paletteStyle) { + ThemeResolver.resolveMiuixColorSpec(ThemeConfig.materialVersion, ThemeConfig.paletteStyle) + } + val miuixController = remember( + isMiuixEngine, + miuixColorSchemeMode, + overrideThemeMode.isDark, + animatedSeedColor, + miuixPaletteStyle, + miuixColorSpec + ) { + if (!isMiuixEngine) { + null + } else { + ThemeController( + colorSchemeMode = miuixColorSchemeMode, + keyColor = animatedSeedColor, + paletteStyle = miuixPaletteStyle, + colorSpec = miuixColorSpec, + isDark = overrideThemeMode.isDark + ) + } + } + + CompositionLocalProvider( + LocalLegadoThemeColors provides overrideThemeMode, + LocalLegadoColorScheme provides legadoColorScheme + ) { + if (miuixController != null) { + MiuixTheme(controller = miuixController) { + MaterialTheme( + colorScheme = animatedColorScheme, + typography = materialTypography, + shapes = materialShapes + ) { + content() + } + } + } else { + MaterialTheme( + colorScheme = animatedColorScheme, + typography = materialTypography, + shapes = materialShapes + ) { + content() + } + } + } +} + +private fun ColorSchemeMode.toMiuixMonetMode(): ColorSchemeMode { + return when (this) { + ColorSchemeMode.Light, + ColorSchemeMode.MonetLight -> ColorSchemeMode.MonetLight + + ColorSchemeMode.Dark, + ColorSchemeMode.MonetDark -> ColorSchemeMode.MonetDark + + else -> ColorSchemeMode.MonetSystem + } +} + +@Composable +fun ColorScheme.animateColorSchemeAsState( + animationSpec: FiniteAnimationSpec = tween( + durationMillis = 700, + easing = FastOutSlowInEasing + ) +): ColorScheme { + val transition = updateTransition( + targetState = this, + label = "theme_color_scheme_transition" + ) + + @Composable + fun animateColor(label: String, color: ColorScheme.() -> Color): Color { + return transition.animateColor( + transitionSpec = { animationSpec }, + label = label + ) { scheme -> + scheme.color() + }.value + } + + return ColorScheme( + primary = animateColor("scheme-primary") { primary }, + onPrimary = animateColor("scheme-onPrimary") { onPrimary }, + primaryContainer = animateColor("scheme-primaryContainer") { primaryContainer }, + onPrimaryContainer = animateColor("scheme-onPrimaryContainer") { onPrimaryContainer }, + inversePrimary = animateColor("scheme-inversePrimary") { inversePrimary }, + secondary = animateColor("scheme-secondary") { secondary }, + onSecondary = animateColor("scheme-onSecondary") { onSecondary }, + secondaryContainer = animateColor("scheme-secondaryContainer") { secondaryContainer }, + onSecondaryContainer = animateColor("scheme-onSecondaryContainer") { onSecondaryContainer }, + tertiary = animateColor("scheme-tertiary") { tertiary }, + onTertiary = animateColor("scheme-onTertiary") { onTertiary }, + tertiaryContainer = animateColor("scheme-tertiaryContainer") { tertiaryContainer }, + onTertiaryContainer = animateColor("scheme-onTertiaryContainer") { onTertiaryContainer }, + background = animateColor("scheme-background") { background }, + onBackground = animateColor("scheme-onBackground") { onBackground }, + surface = animateColor("scheme-surface") { surface }, + onSurface = animateColor("scheme-onSurface") { onSurface }, + surfaceVariant = animateColor("scheme-surfaceVariant") { surfaceVariant }, + onSurfaceVariant = animateColor("scheme-onSurfaceVariant") { onSurfaceVariant }, + surfaceTint = animateColor("scheme-surfaceTint") { surfaceTint }, + inverseSurface = animateColor("scheme-inverseSurface") { inverseSurface }, + inverseOnSurface = animateColor("scheme-inverseOnSurface") { inverseOnSurface }, + error = animateColor("scheme-error") { error }, + onError = animateColor("scheme-onError") { onError }, + errorContainer = animateColor("scheme-errorContainer") { errorContainer }, + onErrorContainer = animateColor("scheme-onErrorContainer") { onErrorContainer }, + outline = animateColor("scheme-outline") { outline }, + outlineVariant = animateColor("scheme-outlineVariant") { outlineVariant }, + scrim = animateColor("scheme-scrim") { scrim }, + surfaceBright = animateColor("scheme-surfaceBright") { surfaceBright }, + surfaceDim = animateColor("scheme-surfaceDim") { surfaceDim }, + surfaceContainer = animateColor("scheme-surfaceContainer") { surfaceContainer }, + surfaceContainerHigh = animateColor("scheme-surfaceContainerHigh") { surfaceContainerHigh }, + surfaceContainerHighest = animateColor("scheme-surfaceContainerHighest") { surfaceContainerHighest }, + surfaceContainerLow = animateColor("scheme-surfaceContainerLow") { surfaceContainerLow }, + surfaceContainerLowest = animateColor("scheme-surfaceContainerLowest") { surfaceContainerLowest }, + primaryFixed = animateColor("scheme-primaryFixed") { primaryFixed }, + primaryFixedDim = animateColor("scheme-primaryFixedDim") { primaryFixedDim }, + onPrimaryFixed = animateColor("scheme-onPrimaryFixed") { onPrimaryFixed }, + onPrimaryFixedVariant = animateColor("scheme-onPrimaryFixedVariant") { onPrimaryFixedVariant }, + secondaryFixed = animateColor("scheme-secondaryFixed") { secondaryFixed }, + secondaryFixedDim = animateColor("scheme-secondaryFixedDim") { secondaryFixedDim }, + onSecondaryFixed = animateColor("scheme-onSecondaryFixed") { onSecondaryFixed }, + onSecondaryFixedVariant = animateColor("scheme-onSecondaryFixedVariant") { onSecondaryFixedVariant }, + tertiaryFixed = animateColor("scheme-tertiaryFixed") { tertiaryFixed }, + tertiaryFixedDim = animateColor("scheme-tertiaryFixedDim") { tertiaryFixedDim }, + onTertiaryFixed = animateColor("scheme-onTertiaryFixed") { onTertiaryFixed }, + onTertiaryFixedVariant = animateColor("scheme-onTertiaryFixedVariant") { onTertiaryFixedVariant } + ) +} diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt new file mode 100644 index 000000000..3b3d0e00c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt @@ -0,0 +1,142 @@ +package io.legado.app.ui.theme + +import android.content.Context +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.ui.graphics.Color +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.theme.ThemeResolver.resolvePaletteStyle +import io.legado.app.ui.theme.colorScheme.AugustColorScheme +import io.legado.app.ui.theme.colorScheme.CarlottaColorScheme +import io.legado.app.ui.theme.colorScheme.ElinkColorScheme +import io.legado.app.ui.theme.colorScheme.GRColorScheme +import io.legado.app.ui.theme.colorScheme.KoharuColorScheme +import io.legado.app.ui.theme.colorScheme.LemonColorScheme +import io.legado.app.ui.theme.colorScheme.MujikaColorScheme +import io.legado.app.ui.theme.colorScheme.PhoebeColorScheme +import io.legado.app.ui.theme.colorScheme.SoraColorScheme +import io.legado.app.ui.theme.colorScheme.TransparentColorScheme +import io.legado.app.ui.theme.colorScheme.WHColorScheme +import io.legado.app.ui.theme.colorScheme.YuukaColorScheme + +object ThemeEngine { + + private val predefinedColorSchemes: Map = mapOf( + AppThemeMode.GR to GRColorScheme, + AppThemeMode.Lemon to LemonColorScheme, + AppThemeMode.WH to WHColorScheme, + AppThemeMode.Elink to ElinkColorScheme, + AppThemeMode.Sora to SoraColorScheme, + AppThemeMode.August to AugustColorScheme, + AppThemeMode.Carlotta to CarlottaColorScheme, + AppThemeMode.Koharu to KoharuColorScheme, + AppThemeMode.Yuuka to YuukaColorScheme, + AppThemeMode.Phoebe to PhoebeColorScheme, + AppThemeMode.Mujika to MujikaColorScheme, + AppThemeMode.Transparent to TransparentColorScheme, + ) + + fun getColorScheme( + context: Context, + mode: AppThemeMode, + darkTheme: Boolean, + isAmoled: Boolean, + paletteStyle: String?, + materialVersion: String? = null, + forceOpaque: Boolean = false + ): ColorScheme { + val resolvedMode = resolveMode(mode = mode, forceOpaque = forceOpaque) + val baseColorScheme = resolveBaseColorScheme( + context = context, + mode = resolvedMode, + darkTheme = darkTheme, + paletteStyle = paletteStyle, + materialVersion = materialVersion + ) + + return baseColorScheme + .applyAmoledIfNeeded(darkTheme = darkTheme, isAmoled = isAmoled) + .applyTransparentIfNeeded(mode = resolvedMode, forceOpaque = forceOpaque) + } + + private fun resolveMode( + mode: AppThemeMode, + forceOpaque: Boolean + ): AppThemeMode { + return if (forceOpaque && mode == AppThemeMode.Transparent) { + AppThemeMode.WH + } else { + mode + } + } + + private fun resolveBaseColorScheme( + context: Context, + mode: AppThemeMode, + darkTheme: Boolean, + paletteStyle: String?, + materialVersion: String? + ): ColorScheme { + if (mode == AppThemeMode.Dynamic) { + return resolveDynamicColorScheme(context = context, darkTheme = darkTheme) + } + if (mode == AppThemeMode.Custom) { + return resolveCustomColorScheme( + seedColor = context.primaryColor, + darkTheme = darkTheme, + paletteStyle = paletteStyle, + materialVersion = materialVersion + ) + } + return (predefinedColorSchemes[mode] ?: GRColorScheme).getColorScheme(darkTheme) + } + + private fun resolveDynamicColorScheme( + context: Context, + darkTheme: Boolean + ): ColorScheme { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return GRColorScheme.getColorScheme(darkTheme) + } + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + private fun resolveCustomColorScheme( + seedColor: Int, + darkTheme: Boolean, + paletteStyle: String?, + materialVersion: String? + ): ColorScheme { + val style = resolvePaletteStyle(paletteStyle) + val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(materialVersion) + return CustomColorScheme(seedColor, style, colorSpec).getColorScheme(darkTheme) + } + + private fun ColorScheme.applyAmoledIfNeeded( + darkTheme: Boolean, + isAmoled: Boolean + ): ColorScheme { + if (!darkTheme || !isAmoled) return this + return copy( + surface = Color.Black, + background = Color.Black, + surfaceContainerLow = Color(0xFF0A0A0A), + surfaceContainer = Color(0xFF121212) + ) + } + + private fun ColorScheme.applyTransparentIfNeeded( + mode: AppThemeMode, + forceOpaque: Boolean + ): ColorScheme { + if (forceOpaque || mode != AppThemeMode.Transparent) return this + return copy( + surface = Color.Transparent, + background = Color.Transparent, + surfaceContainerLow = Color.Transparent, + surfaceContainer = Color.Transparent, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeManager.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeManager.kt deleted file mode 100644 index 4e2b0c773..000000000 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeManager.kt +++ /dev/null @@ -1,97 +0,0 @@ -package io.legado.app.ui.theme - -import android.content.Context -import android.os.Build -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.ui.graphics.Color -import io.legado.app.lib.theme.primaryColor -import io.legado.app.ui.theme.ThemeResolver.resolvePaletteStyle -import io.legado.app.ui.theme.colorScheme.AugustColorScheme -import io.legado.app.ui.theme.colorScheme.CarlottaColorScheme -import io.legado.app.ui.theme.colorScheme.ElinkColorScheme -import io.legado.app.ui.theme.colorScheme.GRColorScheme -import io.legado.app.ui.theme.colorScheme.KoharuColorScheme -import io.legado.app.ui.theme.colorScheme.LemonColorScheme -import io.legado.app.ui.theme.colorScheme.MujikaColorScheme -import io.legado.app.ui.theme.colorScheme.PhoebeColorScheme -import io.legado.app.ui.theme.colorScheme.SoraColorScheme -import io.legado.app.ui.theme.colorScheme.TransparentColorScheme -import io.legado.app.ui.theme.colorScheme.WHColorScheme -import io.legado.app.ui.theme.colorScheme.YuukaColorScheme - -object ThemeManager { - - val colorSchemes: Map = mapOf( - AppThemeMode.GR to GRColorScheme, - AppThemeMode.Lemon to LemonColorScheme, - AppThemeMode.WH to WHColorScheme, - AppThemeMode.Elink to ElinkColorScheme, - AppThemeMode.Sora to SoraColorScheme, - AppThemeMode.August to AugustColorScheme, - AppThemeMode.Carlotta to CarlottaColorScheme, - AppThemeMode.Koharu to KoharuColorScheme, - AppThemeMode.Yuuka to YuukaColorScheme, - AppThemeMode.Phoebe to PhoebeColorScheme, - AppThemeMode.Mujika to MujikaColorScheme, - AppThemeMode.Transparent to TransparentColorScheme, - ) - - - fun getColorScheme( - context: Context, - mode: AppThemeMode, - darkTheme: Boolean, - isAmoled: Boolean, - paletteStyle: String?, - materialVersion: String? = null, - forceOpaque: Boolean = false - ): ColorScheme { - - val style = resolvePaletteStyle(paletteStyle) - val actualMode = if (forceOpaque && mode == AppThemeMode.Transparent) { - AppThemeMode.WH - } else { - mode - } - - var scheme = when (actualMode) { - AppThemeMode.Dynamic -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme( - context - ) - } else GRColorScheme.getColorScheme(darkTheme) - } - - AppThemeMode.CUSTOM -> { - val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(materialVersion) - CustomColorScheme(context.primaryColor, style, colorSpec) - .getColorScheme(darkTheme) - } - - else -> (colorSchemes[actualMode] ?: GRColorScheme).getColorScheme(darkTheme) - } - - if (darkTheme && isAmoled) { - scheme = scheme.copy( - surface = Color.Black, - background = Color.Black, - surfaceContainerLow = Color(0xFF0A0A0A), - surfaceContainer = Color(0xFF121212) - ) - } - - if (!forceOpaque && actualMode == AppThemeMode.Transparent) { - return scheme.copy( - surface = Color.Transparent, - background = Color.Transparent, - surfaceContainerLow = Color.Transparent, - surfaceContainer = Color.Transparent, - ) - } - - return scheme - } -} diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt new file mode 100644 index 000000000..dedfcab4e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt @@ -0,0 +1,100 @@ +package io.legado.app.ui.theme + +import androidx.compose.material3.ColorScheme +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.runtime.withFrameNanos +import androidx.compose.ui.graphics.Color +import com.materialkolor.PaletteStyle +import com.materialkolor.dynamicColorScheme +import io.legado.app.ui.config.themeConfig.ThemeConfig + +data class ThemeOverrideState( + val seedColor: Color, + val colorScheme: ColorScheme, +) + +fun buildThemeOverrideState( + seedColor: Color, + isDark: Boolean, + paletteStyle: PaletteStyle, + colorSpec: ThemeColorSpec, + usePureBlack: Boolean, +): ThemeOverrideState { + var colorScheme = dynamicColorScheme( + seedColor = seedColor, + isDark = isDark, + isAmoled = false, + style = paletteStyle, + contrastLevel = ThemeResolver.resolveContrastLevel(), + specVersion = ThemeResolver.resolveColorSpecVersion(colorSpec) + ) + + if (isDark && usePureBlack) { + colorScheme = colorScheme.copy( + surface = Color.Black, + background = Color.Black, + surfaceContainerLow = Color(0xFF0A0A0A), + surfaceContainer = Color(0xFF121212) + ) + } + + return ThemeOverrideState( + seedColor = seedColor, + colorScheme = colorScheme + ) +} + +@Composable +fun ProvideThemeOverride( + theme: ThemeOverrideState?, + content: @Composable () -> Unit, +) { + var appliedTheme by remember { mutableStateOf(null) } + + LaunchedEffect(theme) { + if (theme == null) { + appliedTheme = null + } else { + withFrameNanos { } + appliedTheme = theme + } + } + + val currentTheme = appliedTheme + if (currentTheme == null) { + content() + } else { + ProvideColorSchemeOverride( + colorScheme = currentTheme.colorScheme, + seedColor = currentTheme.seedColor, + content = content + ) + } +} + +@Composable +fun rememberThemeOverride( + seedColor: Color?, +): ThemeOverrideState? { + val isDark = LegadoTheme.isDark + val paletteStyle = LegadoTheme.paletteStyle + val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(ThemeConfig.materialVersion) + val usePureBlack = ThemeConfig.isPureBlack + + return remember(seedColor, isDark, paletteStyle, colorSpec, usePureBlack) { + seedColor?.let { color -> + buildThemeOverrideState( + seedColor = color, + isDark = isDark, + paletteStyle = paletteStyle, + colorSpec = colorSpec, + usePureBlack = usePureBlack + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt index 6ed598a0a..268f702d8 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt @@ -5,58 +5,100 @@ import com.materialkolor.PaletteStyle import com.materialkolor.dynamiccolor.ColorSpec import io.legado.app.ui.config.themeConfig.ThemeConfig import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.ThemeColorSpec as MiuixThemeColorSpec +import top.yukonga.miuix.kmp.theme.ThemePaletteStyle as MiuixPaletteStyle object ThemeResolver { - fun resolveThemeMode(value: String): AppThemeMode = when (value) { - "0" -> AppThemeMode.Dynamic - "1" -> AppThemeMode.GR - "2" -> AppThemeMode.Lemon - "3" -> AppThemeMode.WH - "4" -> AppThemeMode.Elink - "5" -> AppThemeMode.Sora - "6" -> AppThemeMode.August - "7" -> AppThemeMode.Carlotta - "8" -> AppThemeMode.Koharu - "9" -> AppThemeMode.Yuuka - "10" -> AppThemeMode.Phoebe - "11" -> AppThemeMode.Mujika - "12" -> AppThemeMode.CUSTOM - "13" -> AppThemeMode.Transparent - else -> AppThemeMode.Dynamic + private const val COMPOSE_ENGINE_MIUIX = "miuix" + private const val MATERIAL_VERSION_EXPRESSIVE = "material3Expressive" + + private val appThemeModes = mapOf( + "0" to AppThemeMode.Dynamic, + "1" to AppThemeMode.GR, + "2" to AppThemeMode.Lemon, + "3" to AppThemeMode.WH, + "4" to AppThemeMode.Elink, + "5" to AppThemeMode.Sora, + "6" to AppThemeMode.August, + "7" to AppThemeMode.Carlotta, + "8" to AppThemeMode.Koharu, + "9" to AppThemeMode.Yuuka, + "10" to AppThemeMode.Phoebe, + "11" to AppThemeMode.Mujika, + "12" to AppThemeMode.Custom, + "13" to AppThemeMode.Transparent, + ) + + private val materialPaletteStyles = mapOf( + "tonalSpot" to PaletteStyle.TonalSpot, + "neutral" to PaletteStyle.Neutral, + "vibrant" to PaletteStyle.Vibrant, + "expressive" to PaletteStyle.Expressive, + "rainbow" to PaletteStyle.Rainbow, + "fruitSalad" to PaletteStyle.FruitSalad, + "monochrome" to PaletteStyle.Monochrome, + "fidelity" to PaletteStyle.Fidelity, + "content" to PaletteStyle.Content, + ) + + private val miuixPaletteStyles = mapOf( + "tonalSpot" to MiuixPaletteStyle.TonalSpot, + "neutral" to MiuixPaletteStyle.Neutral, + "vibrant" to MiuixPaletteStyle.Vibrant, + "expressive" to MiuixPaletteStyle.Expressive, + "rainbow" to MiuixPaletteStyle.Rainbow, + "fruitSalad" to MiuixPaletteStyle.FruitSalad, + "monochrome" to MiuixPaletteStyle.Monochrome, + "fidelity" to MiuixPaletteStyle.Fidelity, + "content" to MiuixPaletteStyle.Content, + ) + + private val supportedSpec2025PaletteStyles = setOf( + "tonalSpot", + "neutral", + "vibrant", + "expressive" + ) + + fun resolveThemeMode(value: String): AppThemeMode { + return appThemeModes[value] ?: AppThemeMode.Dynamic } fun resolvePaletteStyle(value: String?): PaletteStyle { - return when (value) { - "tonalSpot" -> PaletteStyle.TonalSpot - "neutral" -> PaletteStyle.Neutral - "vibrant" -> PaletteStyle.Vibrant - "expressive" -> PaletteStyle.Expressive - "rainbow" -> PaletteStyle.Rainbow - "fruitSalad" -> PaletteStyle.FruitSalad - "monochrome" -> PaletteStyle.Monochrome - "fidelity" -> PaletteStyle.Fidelity - "content" -> PaletteStyle.Content - else -> PaletteStyle.TonalSpot - } + return materialPaletteStyles[value] ?: PaletteStyle.TonalSpot } fun resolveContrastLevel(): Double { - return try { - Contrast.valueOf(ThemeConfig.customContrast).value - } catch (e: Exception) { - Contrast.Default.value + return runCatching { Contrast.valueOf(ThemeConfig.customContrast).value } + .getOrDefault(Contrast.Default.value) + } + + fun resolveColorSchemeMode(value: String): ColorSchemeMode { + return when (value) { + "1" -> ColorSchemeMode.Light + "2" -> ColorSchemeMode.Dark + else -> ColorSchemeMode.System } } - fun resolveColorSchemeMode(value: String): ColorSchemeMode = when (value) { - "0" -> ColorSchemeMode.System - "1" -> ColorSchemeMode.Light - "2" -> ColorSchemeMode.Dark - else -> ColorSchemeMode.System + fun resolveMiuixColorSchemeMode( + value: String, + useMonet: Boolean + ): ColorSchemeMode { + val baseMode = resolveColorSchemeMode(value) + if (!useMonet) return baseMode + + return when (baseMode) { + ColorSchemeMode.Light -> ColorSchemeMode.MonetLight + ColorSchemeMode.Dark -> ColorSchemeMode.MonetDark + else -> ColorSchemeMode.MonetSystem + } } - fun isMiuixEngine(composeEngine: String): Boolean = composeEngine == "miuix" + fun isMiuixEngine(composeEngine: String): Boolean { + return composeEngine.equals(COMPOSE_ENGINE_MIUIX, ignoreCase = true) + } fun resolveColorSpecVersion(colorSpec: ThemeColorSpec): ColorSpec.SpecVersion { return when (colorSpec) { @@ -66,11 +108,28 @@ object ThemeResolver { } fun resolveColorSpecFromMaterialVersion(value: String?): ThemeColorSpec { - return when (value) { - "material3Expressive" -> ThemeColorSpec.SPEC_2025 - "material3" -> ThemeColorSpec.SPEC_2021 - else -> ThemeColorSpec.SPEC_2021 + return if (value == MATERIAL_VERSION_EXPRESSIVE) { + ThemeColorSpec.SPEC_2025 + } else { + ThemeColorSpec.SPEC_2021 } } + fun resolveMiuixPaletteStyle(value: String?): MiuixPaletteStyle { + return miuixPaletteStyles[value] ?: MiuixPaletteStyle.TonalSpot + } + + fun resolveMiuixColorSpec( + materialVersion: String?, + paletteStyle: String? + ): MiuixThemeColorSpec { + val useSpec2025 = resolveColorSpecFromMaterialVersion(materialVersion) == ThemeColorSpec.SPEC_2025 + val supportsSpec2025 = paletteStyle in supportedSpec2025PaletteStyles + + return if (useSpec2025 && supportsSpec2025) { + MiuixThemeColorSpec.Spec2025 + } else { + MiuixThemeColorSpec.Spec2021 + } + } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt index 5c72e0a93..a4e9d8440 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.text.AppText @@ -20,6 +21,8 @@ fun AppFloatingActionButton( onClick: () -> Unit, modifier: Modifier = Modifier, tooltipText: String? = null, + containerColor: Color = LegadoTheme.colorScheme.primaryContainer, + contentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer, content: @Composable () -> Unit ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) @@ -42,6 +45,8 @@ fun AppFloatingActionButton( FloatingActionButton( onClick = onClick, modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, content = content ) } @@ -49,8 +54,10 @@ fun AppFloatingActionButton( FloatingActionButton( onClick = onClick, modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, content = content ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt index 02404ad16..d62311f37 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -18,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.button.MiuixPrimaryButton @@ -91,6 +94,11 @@ fun AppAlertDialog( AlertDialog( onDismissRequest = onDismissRequest, modifier = modifier, + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + iconContentColor = LegadoTheme.colorScheme.primary, + titleContentColor = LegadoTheme.colorScheme.onSurface, + textContentColor = LegadoTheme.colorScheme.onSurfaceVariant, + tonalElevation = AlertDialogDefaults.TonalElevation, title = title?.let { { Text(text = it) } }, text = { Column { @@ -108,7 +116,10 @@ fun AppAlertDialog( confirmButton = { if (onConfirm != null) { OutlinedButton( - onClick = onConfirm + onClick = onConfirm, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = LegadoTheme.colorScheme.primary + ) ) { Text(text = confirmText) } @@ -120,7 +131,10 @@ fun AppAlertDialog( onClick = { onDismiss() onDismissRequest() - } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = LegadoTheme.colorScheme.primary + ) ) { Text(text = dismissText) } @@ -167,4 +181,4 @@ fun AppAlertDialog( content = content?.let { { it(currentData) } } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AppButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/ConfirmDismissButtonsRow.kt similarity index 57% rename from app/src/main/java/io/legado/app/ui/widget/components/button/AppButton.kt rename to app/src/main/java/io/legado/app/ui/widget/components/button/ConfirmDismissButtonsRow.kt index 5595c43ff..1fe2dfff3 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/AppButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/ConfirmDismissButtonsRow.kt @@ -1,10 +1,16 @@ package io.legado.app.ui.widget.components.button +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Button import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.ThemeResolver import top.yukonga.miuix.kmp.basic.Button as MiuixButton @@ -71,4 +77,47 @@ fun SecondaryButton( ) } } -} \ No newline at end of file +} + +@Composable +fun ConfirmDismissButtonsRow( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + dismissText: String, + confirmText: String, + dismissEnabled: Boolean = true, + confirmEnabled: Boolean = true, +) { + val isMiuix = ThemeResolver.isMiuixEngine(composeEngine) + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (isMiuix) { + Arrangement.spacedBy(12.dp) + } else { + Arrangement.spacedBy(12.dp, Alignment.End) + } + ) { + SecondaryButton( + onClick = onDismiss, + modifier = if (isMiuix) { + Modifier.weight(1f) + } else { + Modifier.widthIn(min = 88.dp) + }, + enabled = dismissEnabled, + text = dismissText + ) + PrimaryButton( + onClick = onConfirm, + modifier = if (isMiuix) { + Modifier.weight(1f) + } else { + Modifier.widthIn(min = 88.dp) + }, + enabled = confirmEnabled, + text = confirmText + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt index 4aa076371..46facd67f 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme @@ -42,6 +43,7 @@ import top.yukonga.miuix.kmp.basic.Text as MiuixText fun MediumIconButton( onClick: () -> Unit, imageVector: ImageVector, + tint: Color = LegadoTheme.colorScheme.onSurface, modifier: Modifier = Modifier, contentDescription: String? = null ) { @@ -52,7 +54,8 @@ fun MediumIconButton( ) { MiuixIcon( imageVector = imageVector, - contentDescription = contentDescription + contentDescription = contentDescription, + tint = tint ) } } else { @@ -62,7 +65,8 @@ fun MediumIconButton( ) { Icon( imageVector = imageVector, - contentDescription = contentDescription + contentDescription = contentDescription, + tint = tint ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt index c3e926dcb..805b213ee 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/TopBarButton.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton @@ -72,7 +71,7 @@ fun TopBarButton( modifier = commonModifier, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = GlassTopAppBarDefaults.controlContainerColor(), - contentColor = MaterialTheme.colorScheme.onSurface + contentColor = LegadoTheme.colorScheme.onSurface ) ) { AnimatedIcon( @@ -162,7 +161,7 @@ fun TopbarNavigationButton( fun TopBarActionButton( onClick: () -> Unit, imageVector: ImageVector, - contentDescription: String?, + contentDescription: String? = "", modifier: Modifier = Modifier ) { val enableProgressive = ThemeConfig.enableProgressiveBlur diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt index 79dfc772e..73288bb7f 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt @@ -6,7 +6,6 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardElevation -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,8 +36,8 @@ private fun BaseCard( ) { if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { val colors = MiuixCardDefaults.defaultColors( - color = (containerColor ?: MiuixTheme.colorScheme.secondaryContainer.copy(alpha)), - contentColor = contentColor ?: MiuixTheme.colorScheme.onSurface + color = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer.copy(alpha)), + contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface ) MiuixCard( modifier = modifier, @@ -51,12 +50,12 @@ private fun BaseCard( ) } else { val colors = CardDefaults.cardColors( - containerColor = (containerColor ?: MaterialTheme.colorScheme.secondaryContainer).copy( + containerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer).copy( alpha = alpha ), - contentColor = contentColor ?: MaterialTheme.colorScheme.onSecondaryContainer, - disabledContainerColor = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f), - disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f) + contentColor = contentColor ?: LegadoTheme.colorScheme.onSecondaryContainer, + disabledContainerColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f), + disabledContentColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f) ) if (onClick != null) { Card( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt index 747ab58a8..05a39a824 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt @@ -56,6 +56,7 @@ fun SelectionItemCard( title: String, modifier: Modifier = Modifier, subtitle: String? = null, + supportingContent: @Composable (() -> Unit)? = null, isEnabled: Boolean = true, isSelected: Boolean = false, inSelectionMode: Boolean = false, @@ -75,10 +76,10 @@ fun SelectionItemCard( val animatedContainerColor by animateColorAsState( targetValue = if (isSelected) selectedContainerColor - ?: if (composeEngine) MiuixTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.secondaryContainer + ?: if (composeEngine) LegadoTheme.colorScheme.secondaryContainer else LegadoTheme.colorScheme.secondaryContainer else containerColor - ?: if (composeEngine) MiuixTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLow, + ?: if (composeEngine) LegadoTheme.colorScheme.surfaceContainer else LegadoTheme.colorScheme.surfaceContainerLow, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), label = "CardColor" ) @@ -130,13 +131,16 @@ fun SelectionItemCard( maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (!subtitle.isNullOrBlank()) { - AppText( - text = subtitle, - style = LegadoTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + when { + supportingContent != null -> supportingContent() + !subtitle.isNullOrBlank() -> { + AppText( + text = subtitle, + style = LegadoTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } else { @@ -150,16 +154,20 @@ fun SelectionItemCard( overflow = TextOverflow.Ellipsis ) }, - supportingContent = if (!subtitle.isNullOrBlank()) { - { - AppText( - text = subtitle, - style = LegadoTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + supportingContent = when { + supportingContent != null -> supportingContent + !subtitle.isNullOrBlank() -> { + { + AppText( + text = subtitle, + style = LegadoTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - } else null, + else -> null + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } @@ -218,6 +226,7 @@ fun LazyItemScope.ReorderableSelectionItem( title: String, modifier: Modifier = Modifier, subtitle: String? = null, + supportingContent: @Composable (() -> Unit)? = null, isEnabled: Boolean = true, isSelected: Boolean = false, inSelectionMode: Boolean = false, @@ -242,6 +251,7 @@ fun LazyItemScope.ReorderableSelectionItem( SelectionItemCard( title = title, subtitle = subtitle, + supportingContent = supportingContent, isEnabled = isEnabled, isSelected = isSelected, inSelectionMode = inSelectionMode, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt index 47886a9e7..0ecb22b16 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt @@ -31,7 +31,7 @@ fun TextCard( contentColor: Color? = null, cornerRadius: Dp = 8.dp, horizontalPadding: Dp = 8.dp, - verticalPadding: Dp = 2.dp, + verticalPadding: Dp = 4.dp, iconSize: Dp = 14.dp, spacing: Dp = 4.dp, textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized diff --git a/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt index 76bae95ae..5e6bc6e08 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt @@ -2,7 +2,6 @@ package io.legado.app.ui.widget.components.dialog import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,20 +9,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextButton 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 @@ -34,15 +30,20 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.github.skydoves.colorpicker.compose.AlphaSlider -import com.github.skydoves.colorpicker.compose.BrightnessSlider -import com.github.skydoves.colorpicker.compose.HsvColorPicker -import com.github.skydoves.colorpicker.compose.rememberColorPickerController import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.isHex +import top.yukonga.miuix.kmp.basic.ColorPalette +import top.yukonga.miuix.kmp.basic.ColorPicker +import top.yukonga.miuix.kmp.basic.ColorSpace @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -52,111 +53,50 @@ fun ColorPickerSheet( onDismissRequest: () -> Unit, onColorSelected: (Int) -> Unit ) { - val controller = rememberColorPickerController() var currentColor by remember { mutableStateOf(Color(initialColor)) } + var hexInput by remember { mutableStateOf(initialColor.asHexColorString()) } + var isHexInputError by remember { mutableStateOf(false) } - val presets = remember { - listOf( - Color(0xFFF44336), - Color(0xFFE91E63), - Color(0xFF9C27B0), - Color(0xFF673AB7), - Color(0xFF3F51B5), - Color(0xFF2196F3), - Color(0xFF03A9F4), - Color(0xFF00BCD4), - Color(0xFF009688), - Color(0xFF4CAF50), - Color(0xFF8BC34A), - Color(0xFFCDDC39), - Color(0xFFFFEB3B), - Color(0xFFFFC107), - Color(0xFFFF9800), - Color(0xFFFF5722), - Color(0xFF795548), - Color(0xFF9E9E9E), - Color(0xFF607D8B), - Color(0xFF000000), - Color(0xFFFFFFFF) - ) + LaunchedEffect(show, initialColor) { + if (show) { + currentColor = Color(initialColor) + hexInput = initialColor.asHexColorString() + isHexInputError = false + } } - AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest) { + val parsedHexColor = parseHexColor(hexInput) + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = "选择颜色", + ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - AppText( - text = "颜色选择", - style = LegadoTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp) + ColorPalette( + color = currentColor, + onColorChanged = { color -> + currentColor = color + hexInput = color.toArgb().asHexColorString() + isHexInputError = false + }, + rows = 8, + hueColumns = 12, + modifier = Modifier.fillMaxWidth(), + showPreview = false ) - HsvColorPicker( - modifier = Modifier - .fillMaxWidth() - .height(240.dp) - .padding(10.dp), - controller = controller, - initialColor = Color(initialColor), - onColorChanged = { envelope -> - currentColor = envelope.color - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - AlphaSlider( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) - .height(35.dp), - controller = controller, - ) - - BrightnessSlider( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) - .height(35.dp), - controller = controller, - ) - - LazyVerticalGrid( - columns = GridCells.Adaptive(36.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .heightIn(max = 80.dp) - .padding(horizontal = 10.dp) - ) { - items(presets) { color -> - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(color) - .border( - if (color.toArgb() == currentColor.toArgb()) 2.dp else 0.dp, - MaterialTheme.colorScheme.primary, - CircleShape - ) - .clickable { - controller.selectByColor(color, true) - } - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(32.dp)) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) + modifier = Modifier.fillMaxWidth() ) { Box( modifier = Modifier @@ -165,32 +105,68 @@ fun ColorPickerSheet( .background(currentColor) .border( 1.dp, - MaterialTheme.colorScheme.outlineVariant, + LegadoTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp) ) ) Spacer(modifier = Modifier.width(12.dp)) - AppText( - text = "#${Integer.toHexString(currentColor.toArgb()).uppercase()}", - style = LegadoTheme.typography.bodyLarge - ) - - Row( + AppTextField( + value = hexInput, + onValueChange = { value -> + hexInput = normalizeHexInput(value) + val parsedColor = parseHexColor(hexInput) + if (parsedColor != null) { + currentColor = Color(parsedColor) + isHexInputError = false + } else { + isHexInputError = hexInput.isNotBlank() + } + }, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { - AppText(stringResource(R.string.cancel)) - } - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - onColorSelected(currentColor.toArgb()) - onDismissRequest() - }) { - AppText(stringResource(R.string.ok)) - } - } + label = "色值", + singleLine = true, + isError = isHexInputError, + backgroundColor = LegadoTheme.colorScheme.surface, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done + ) + ) } + + Spacer(modifier = Modifier.height(16.dp)) + + ConfirmDismissButtonsRow( + modifier = Modifier.fillMaxWidth(), + onDismiss = onDismissRequest, + onConfirm = { + onColorSelected(currentColor.toArgb()) + onDismissRequest() + }, + dismissText = stringResource(R.string.cancel), + confirmText = stringResource(R.string.ok), + confirmEnabled = parsedHexColor != null && !isHexInputError + ) } } } + +private fun normalizeHexInput(input: String): String { + val trimmed = input.trim().uppercase() + return if (trimmed.startsWith("#")) { + "#${trimmed.removePrefix("#")}" + } else { + trimmed + } +} + +private fun parseHexColor(input: String): Int? { + val hex = input.trim().removePrefix("#") + if (hex.length !in setOf(6, 8) || !hex.isHex()) return null + val argb = if (hex.length == 6) "FF$hex" else hex + return argb.toLong(16).toInt() +} + +private fun Int.asHexColorString(): String = + "#${Integer.toHexString(this).uppercase().padStart(8, '0')}" diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt index 1ab23f7b3..e78a8c1fe 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.widget.components.menuItem import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer @@ -13,15 +14,20 @@ import androidx.compose.material3.MotionScheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ProvideAppContentColor import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.rememberOpaqueColorScheme import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.overlay.OverlayListPopup +import top.yukonga.miuix.kmp.window.WindowListPopup + +val LocalUseMiuixWindowPopup = staticCompositionLocalOf { false } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -29,25 +35,28 @@ fun RoundDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - // M3 专属视觉参数,Miuix 模式下会优雅降级(忽略) shape: Shape = MaterialTheme.shapes.medium, shadowElevation: Dp = 4.dp, verticalSpacing: Dp = 8.dp, content: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) + val popupContainerColor = LegadoTheme.colorScheme.surfaceContainer if (isMiuix) { + val popupContentColor = LegadoTheme.colorScheme.onSurface OverlayListPopup( show = expanded, onDismissRequest = onDismissRequest, popupModifier = modifier ) { - ListPopupColumn { - Column() { - Spacer(Modifier.height(12.dp)) - content(onDismissRequest) - Spacer(Modifier.height(12.dp)) + ProvideAppContentColor(popupContentColor) { + ListPopupColumn { + Column(modifier = Modifier.background(popupContainerColor)) { + Spacer(Modifier.height(12.dp)) + content(onDismissRequest) + Spacer(Modifier.height(12.dp)) + } } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt index 437baaf81..60b0f1b3c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.widget.components.menuItem +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -13,10 +14,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -33,13 +37,16 @@ import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.rememberOpaqueColorScheme import io.legado.app.ui.widget.components.icon.AppIcon +import top.yukonga.miuix.kmp.basic.DropdownDefaults +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.basic.Check import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.Text as MiuixText @Composable fun RoundDropdownMenuItem( text: String, - color: Color = LegadoTheme.colorScheme.surface, + color: Color = Color.Unspecified, onClick: () -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, @@ -51,20 +58,21 @@ fun RoundDropdownMenuItem( ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val interaction = interactionSource ?: remember { MutableInteractionSource() } + val hasCustomContentColor = color != Color.Unspecified if (isMiuix) { - val backgroundColor = if (isSelected) { - LegadoTheme.colorScheme.primaryContainer + val dropdownColors = DropdownDefaults.dropdownColors() + val (textColor, backgroundColor) = if (isSelected) { + dropdownColors.selectedContentColor to dropdownColors.selectedContainerColor + } else { + dropdownColors.contentColor to dropdownColors.containerColor + } + val checkColor = if (isSelected) { + dropdownColors.selectedContentColor } else { Color.Transparent } - val contentColor = if (isSelected) { - LegadoTheme.colorScheme.onPrimaryContainer - } else { - LegadoTheme.colorScheme.onSurface - } - Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -73,16 +81,13 @@ fun RoundDropdownMenuItem( .drawBehind { drawRect(backgroundColor) } .clickable( interactionSource = interaction, - indication = androidx.compose.foundation.LocalIndication.current, + indication = LocalIndication.current, enabled = enabled, onClick = onClick ) .padding(horizontal = 20.dp, vertical = 12.dp) ) { - CompositionLocalProvider( - LocalContentColor provides contentColor, - LocalTextStyle provides MiuixTheme.textStyles.body1 - ) { + CompositionLocalProvider(LocalContentColor provides textColor) { if (leadingIcon != null) { leadingIcon() Spacer(Modifier.width(12.dp)) @@ -93,24 +98,46 @@ fun RoundDropdownMenuItem( contentAlignment = Alignment.CenterStart ) { MiuixText( + modifier = Modifier.widthIn(max = 200.dp), text = text, - fontWeight = FontWeight.Medium + fontSize = MiuixTheme.textStyles.body1.fontSize, + fontWeight = FontWeight.Medium, + color = textColor, ) } if (trailingIcon != null) { Spacer(Modifier.width(12.dp)) trailingIcon() + } else { + Spacer(Modifier.width(12.dp)) + AppIcon( + imageVector = MiuixIcons.Basic.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = checkColor + ) } } } } else { val colorScheme = rememberOpaqueColorScheme() + val selectedContentColor = colorScheme.onPrimaryContainer + val defaultContentColor = colorScheme.onSurface val contentColor = if (enabled) { - MaterialTheme.colorScheme.onSurface + when { + hasCustomContentColor -> color + isSelected -> selectedContentColor + else -> defaultContentColor + } } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + when { + hasCustomContentColor -> color.copy(alpha = 0.38f) + isSelected -> selectedContentColor.copy(alpha = 0.38f) + else -> defaultContentColor.copy(alpha = 0.38f) + } } + val containerColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surface Surface( onClick = onClick, @@ -119,7 +146,8 @@ fun RoundDropdownMenuItem( .fillMaxWidth(), enabled = enabled, shape = MaterialTheme.shapes.small, - color = colorScheme.surface, + color = containerColor, + contentColor = contentColor, interactionSource = interaction ) { Row( @@ -141,13 +169,22 @@ fun RoundDropdownMenuItem( Text( modifier = Modifier.widthIn(max = 200.dp), text = text, - style = LegadoTheme.typography.bodyMediumEmphasized + style = LegadoTheme.typography.bodyMediumEmphasized, + color = contentColor ) } if (trailingIcon != null) { Spacer(Modifier.width(8.dp)) trailingIcon() + } else { + Spacer(Modifier.width(8.dp)) + AppIcon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (isSelected) contentColor else Color.Transparent + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt index cc42c9f1e..fefb7b05e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,8 +29,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.LocalLegadoThemeColors +import io.legado.app.ui.theme.ProvideAppContentColor import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.theme.rememberOpaqueColorScheme +import io.legado.app.ui.widget.components.menuItem.LocalUseMiuixWindowPopup import top.yukonga.miuix.kmp.window.WindowBottomSheet @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -43,30 +46,56 @@ fun AppModalBottomSheet( endAction: @Composable (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) { + val colorScheme = LocalLegadoThemeColors.current.colorScheme + val sheetContainerColor = LegadoTheme.colorScheme.surfaceContainer + val sheetContentColor = LegadoTheme.colorScheme.onSurface + val sheetDragHandleColor = LegadoTheme.colorScheme.onSurfaceVariant + if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { WindowBottomSheet( show = show, modifier = modifier, title = title, - startAction = startAction, - endAction = endAction, + startAction = startAction?.let { action -> + { + ProvideAppContentColor(sheetContentColor) { + CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) { + action() + } + } + } + }, + endAction = endAction?.let { action -> + { + ProvideAppContentColor(sheetContentColor) { + CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) { + action() + } + } + } + }, insideMargin = DpSize(16.dp, 12.dp), + backgroundColor = sheetContainerColor, + dragHandleColor = sheetDragHandleColor, onDismissRequest = onDismissRequest, onDismissFinished = onDismissRequest, enableWindowDim = true, allowDismiss = true ) { - Column( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - content = content - ) + ProvideAppContentColor(sheetContentColor) { + CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + content = content + ) + } + } } } else { if (show) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val colorScheme = rememberOpaqueColorScheme() val density = LocalDensity.current val maxHeight = with(density) { LocalWindowInfo.current.containerSize.height.toDp() * 0.8f @@ -75,8 +104,9 @@ fun AppModalBottomSheet( ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, - contentColor = colorScheme.onSurface, - dragHandle = { BottomSheetDefaults.DragHandle() } + containerColor = sheetContainerColor, + contentColor = sheetContentColor, + dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) } ) { MaterialExpressiveTheme( colorScheme = colorScheme, @@ -112,7 +142,7 @@ fun AppModalBottomSheet( Text( text = title, style = LegadoTheme.typography.titleMediumEmphasized, - color = colorScheme.onSurface, + color = sheetContentColor, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -134,4 +164,4 @@ fun AppModalBottomSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt index eefe85ac7..fa03749da 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt @@ -1,17 +1,12 @@ package io.legado.app.ui.widget.components.settingItem import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Replay import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,7 +20,7 @@ import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.widget.components.button.SmallTextButton +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.text.AppText import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField @@ -76,28 +71,18 @@ fun InputSettingItem( } ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.End - ) { - SmallTextButton( - text = "默认", - imageVector = Icons.Default.Replay, - onClick = { - state.edit { replace(0, length, defaultValue.toString()) } - } - ) - SmallTextButton( - text = "确认", - imageVector = Icons.Default.Check, - onClick = { - onConfirm(state.text.toString()) - expanded = false - } - ) - } + ConfirmDismissButtonsRow( + modifier = Modifier.padding(top = 16.dp), + onDismiss = { + state.edit { replace(0, length, defaultValue.toString()) } + }, + onConfirm = { + onConfirm(state.text.toString()) + expanded = false + }, + dismissText = stringResource(R.string.text_default), + confirmText = stringResource(R.string.confirm) + ) } } } @@ -127,26 +112,18 @@ fun InputSettingItem( } ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - SmallTextButton( - text = "默认", - imageVector = Icons.Default.Replay, - onClick = { - state.edit { replace(0, length, defaultValue.toString()) } - } - ) - SmallTextButton( - text = "确认", - imageVector = Icons.Default.Check, - onClick = { - onConfirm(state.text.toString()) - expanded = false - } - ) - } + ConfirmDismissButtonsRow( + modifier = Modifier.padding(top = 16.dp), + onDismiss = { + state.edit { replace(0, length, defaultValue.toString()) } + }, + onConfirm = { + onConfirm(state.text.toString()) + expanded = false + }, + dismissText = stringResource(R.string.text_default), + confirmText = stringResource(R.string.confirm) + ) } ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt index 833350d91..f2fb4fce9 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt @@ -2,15 +2,12 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState @@ -29,11 +26,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.widget.components.button.SmallTextButton +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.text.AppText import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider @@ -78,7 +77,11 @@ fun SliderSettingItem( MiuixTextField( state = textFieldState, lineLimits = TextFieldLineLimits.SingleLine, - label = "杈撳叆鏁板€?(${valueRange.start.toInt()}-${valueRange.endInclusive.toInt()})", + label = stringResource( + R.string.input_value_range, + valueRange.start.toInt(), + valueRange.endInclusive.toInt() + ), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), inputTransformation = { @@ -113,35 +116,26 @@ fun SliderSettingItem( } } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.End - ) { - SmallTextButton( - text = if (isInputMode) "婊戝潡" else "杈撳叆", - imageVector = if (isInputMode) Icons.Default.LinearScale else Icons.Default.Edit, - onClick = { isInputMode = !isInputMode } - ) - - Spacer(Modifier.width(8.dp)) - - SmallTextButton( - text = "榛樿", - imageVector = Icons.Default.RestartAlt, - onClick = { - onValueChange(defaultValue) - textFieldState.edit { - replace( - 0, - length, - defaultValue.toInt().toString() - ) - } + ConfirmDismissButtonsRow( + modifier = Modifier.padding(top = 16.dp), + onDismiss = { isInputMode = !isInputMode }, + onConfirm = { + onValueChange(defaultValue) + textFieldState.edit { + replace( + 0, + length, + defaultValue.toInt().toString() + ) } - ) - } + }, + dismissText = if (isInputMode) { + stringResource(R.string.slider) + } else { + stringResource(R.string.edit) + }, + confirmText = stringResource(R.string.text_default) + ) } } } @@ -163,7 +157,15 @@ fun SliderSettingItem( TextField( state = textFieldState, lineLimits = TextFieldLineLimits.SingleLine, - label = { AppText("杈撳叆鏁板€?(${valueRange.start.toInt()}-${valueRange.endInclusive.toInt()})") }, + label = { + AppText( + stringResource( + R.string.input_value_range, + valueRange.start.toInt(), + valueRange.endInclusive.toInt() + ) + ) + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier .fillMaxWidth() @@ -207,33 +209,26 @@ fun SliderSettingItem( } } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - SmallTextButton( - text = if (isInputMode) "婊戝潡" else "杈撳叆", - imageVector = if (isInputMode) Icons.Default.LinearScale else Icons.Default.Edit, - onClick = { isInputMode = !isInputMode } - ) - - Spacer(Modifier.width(8.dp)) - - SmallTextButton( - text = "榛樿", - imageVector = Icons.Default.RestartAlt, - onClick = { - onValueChange(defaultValue) - textFieldState.edit { - replace( - 0, - length, - defaultValue.toInt().toString() - ) - } + ConfirmDismissButtonsRow( + modifier = Modifier.padding(top = 16.dp), + onDismiss = { isInputMode = !isInputMode }, + onConfirm = { + onValueChange(defaultValue) + textFieldState.edit { + replace( + 0, + length, + defaultValue.toInt().toString() + ) } - ) - } + }, + dismissText = if (isInputMode) { + stringResource(R.string.slider) + } else { + stringResource(R.string.edit) + }, + confirmText = stringResource(R.string.text_default) + ) } ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt index f393d8b8e..52a576005 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/MiuixScrollBehavior.kt @@ -33,4 +33,4 @@ class MiuixGlassScrollBehavior( override val collapsedFraction: Float get() = miuixBehavior.state.collapsedFraction -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/dialog_content_edit.xml b/app/src/main/res/layout/dialog_content_edit.xml index 6900cc49b..4b7163be5 100644 --- a/app/src/main/res/layout/dialog_content_edit.xml +++ b/app/src/main/res/layout/dialog_content_edit.xml @@ -33,7 +33,7 @@ 沉浸式导航栏 导航栏颜色透明 放入书架 + 点击删除 继续阅读 封面地址 覆盖 @@ -1237,6 +1238,8 @@ 刷新发现 未分组 动态取色 + 使用基于壁纸的主题颜色 + 输入数值(%1$d-%2$d) 图标已修改 配置替换规则 更多设置 @@ -1289,6 +1292,8 @@ 浅色 深色 纯黑深色模式 + 自定义主题色 + 为 Miuix 使用自定义主题配色 透明主题请于设置背景图片后设置 使用种子颜色生成主题颜色 种子色 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 36151103c..b4674b9c0 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -370,6 +370,7 @@ 沉浸式导航栏 导航栏颜色透明 放入書架 + 點擊刪除 繼續閲讀 封面地址 覆蓋 @@ -1223,4 +1224,8 @@ %d 網頁文件 顯示分組書籍數量 + 使用基於桌布的主題顏色 + 輸入數值(%1$d-%2$d) + Miuix Monet + 為 Miuix 使用 Monet 演算法生成配色。目前主題為動態主題且系統為 Android 12 或以上時,會優先使用系統取色;否則使用下方種子色。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 44cdbf65f..d7efe30e4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -372,6 +372,7 @@ 沉浸式導航欄 導航欄顏色透明 放入書架 + 點擊刪除 繼續閱讀 封面地址 覆蓋 @@ -1225,4 +1226,8 @@ %d 網頁文件 顯示分組書籍數量 + 使用基於桌布的主題顏色 + 輸入數值(%1$d-%2$d) + Miuix Monet + 為 Miuix 使用 Monet 演算法產生配色。目前主題為動態主題且系統為 Android 12 以上時,會優先使用系統取色;否則使用下方種子色。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 762183443..657971090 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -397,6 +397,7 @@ immersion navigation bar The navigation bar becomes transparent Add to Bookshelf + Tap to remove Continue reading Cover path Cover @@ -1240,6 +1241,8 @@ Refresh Explore Ungrouped Dynamic Colors + Use wallpaper-based theme colors + Enter value (%1$d-%2$d) Icon Updated Configure Replace Rules More Settings @@ -1292,6 +1295,8 @@ Light Dark True Black Mode + Miuix Monet + Use Monet to generate the Miuix palette. If the current theme is Dynamic on Android 12+, the system accent is used; otherwise the seed color below is used. Please set the transparent theme after setting a background image. Create a theme from a seed color Seed Color