From f8473077dc290c13fdda12918f22b6fe42a95135 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:08:39 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 5 - .../main/java/io/legado/app/di/appModule.kt | 4 + .../java/io/legado/app/help/JsExtensions.kt | 7 +- .../java/io/legado/app/model/CacheBook.kt | 307 +++---- .../legado/app/model/webBook/SearchModel.kt | 6 +- .../io/legado/app/service/CacheBookService.kt | 15 +- .../legado/app/service/CheckSourceService.kt | 6 +- .../legado/app/service/ExportBookService.kt | 30 +- .../legado/app/ui/book/cache/CacheActivity.kt | 599 -------------- .../legado/app/ui/book/cache/CacheAdapter.kt | 50 +- .../legado/app/ui/book/cache/CacheScreen.kt | 769 ++++++++++++++++++ .../app/ui/book/cache/CacheViewModel.kt | 337 +++++++- .../book/changecover/ChangeCoverViewModel.kt | 4 +- .../changesource/ChangeBookSourceViewModel.kt | 3 +- .../app/ui/book/manga/ReadMangaViewModel.kt | 5 +- .../app/ui/book/read/ReadBookViewModel.kt | 3 +- .../legado/app/ui/book/search/SearchScreen.kt | 23 +- .../app/ui/config/cacheConfig/CacheConfig.kt | 36 + .../io/legado/app/ui/main/MainActivity.kt | 152 +++- .../java/io/legado/app/ui/main/MainScreen.kt | 9 +- .../legado/app/ui/theme/OpaqueColorScheme.kt | 44 +- .../components/button/SmallTextButton.kt | 55 ++ .../app/ui/widget/components/card/TextCard.kt | 5 +- .../ui/widget/components/list/ListScaffold.kt | 4 + .../components/menuItem/RoundDropdownMenu.kt | 68 ++ .../components/topbar/DynamicTopAppBar.kt | 9 + 26 files changed, 1676 insertions(+), 879 deletions(-) delete mode 100644 app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 655aeb230..50232b2be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -283,11 +283,6 @@ android:name=".ui.book.bookmark.AllBookmarkActivity" android:enableOnBackInvokedCallback="true" android:launchMode="singleTop" /> - - { DirectLinkUploadRepository() } single { ExploreRepositoryImpl(get()) } @@ -122,6 +125,7 @@ val appModule = module { viewModelOf(::ExploreViewModel) viewModelOf(::RssViewModel) viewModelOf(::SearchViewModel) + viewModelOf(::CacheViewModel) viewModel { (route: ReplaceEditRoute) -> ReplaceEditViewModel( diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt index de6f2eea0..502d57d99 100644 --- a/app/src/main/java/io/legado/app/help/JsExtensions.kt +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -29,6 +29,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.QueryTTF import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.association.OpenUrlConfirmActivity +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ChineseUtils import io.legado.app.utils.EncoderUtils @@ -130,7 +131,7 @@ interface JsExtensions : JsEncodeUtils { } fun ajaxAll(urlList: Array, skipRateLimit: Boolean): Array { return runBlocking(context) { - urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> + urlList.asFlow().mapAsync(OtherConfig.threadCount) { url -> val analyzeUrl = AnalyzeUrl( url, source = getSource(), @@ -153,7 +154,7 @@ interface JsExtensions : JsEncodeUtils { skipRateLimit: Boolean ): Array { return runBlocking(context) { - urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> + urlList.asFlow().mapAsync(OtherConfig.threadCount) { url -> val analyzeUrl = AnalyzeUrl( url, source = getSource(), @@ -1211,4 +1212,4 @@ interface JsExtensions : JsEncodeUtils { return OldThemeConfig.getDurConfig(appCtx).toMap() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index ade4f55f9..6f236aadd 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -11,11 +11,11 @@ import io.legado.app.data.entities.BookSource import io.legado.app.exception.ConcurrentException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.isLocal -import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.CompositeCoroutine import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.webBook.WebBook import io.legado.app.service.CacheBookService +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent import io.legado.app.utils.startService @@ -44,79 +44,111 @@ import kotlin.coroutines.CoroutineContext object CacheBook { + private data class QueueStats( + val waitingCount: Int, + val downloadingCount: Int + ) + + private class CacheBookCoordinator { + val taskMap = ConcurrentHashMap() + private val processMutex = Mutex() + private val workingState = MutableStateFlow(true) + + fun setWorkingState(value: Boolean) { + workingState.value = value + } + + suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock { + setWorkingState(true) + flow { + while (currentCoroutineContext().isActive && taskMap.isNotEmpty()) { + if (!workingState.value) { + workingState.first { it } + } + var emitted = false + taskMap.forEach { (_, model) -> + if (!model.isLoading()) { + emit(model) + emitted = true + } + } + if (!emitted) delay(800) + } + }.onStart { + postEvent(EventBus.UP_DOWNLOAD_STATE, "") + updateSummary() + }.onEachParallel(OtherConfig.threadCount.coerceAtLeast(1)) { + coroutineScope { + it.download(this, context) + } + }.onCompletion { + postEvent(EventBus.UP_DOWNLOAD_STATE, "") + updateSummary() + }.collect() + } + } + + private val coordinator = CacheBookCoordinator() + private val _cacheSuccessFlow = MutableSharedFlow(extraBufferCapacity = 64) val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow() private val _downloadSummaryFlow = MutableStateFlow("") - val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow() private val _downloadingIndicesFlow = MutableStateFlow>>("" to emptySet()) - val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow() private val _downloadErrorFlow = MutableStateFlow>>("" to emptySet()) - val downloadErrorFlow = _downloadErrorFlow.asStateFlow() + val successDownloadSet = ConcurrentHashMap.newKeySet() + val errorDownloadMap = ConcurrentHashMap() + private val errorIndexMap = ConcurrentHashMap>() + + val cacheBookMap: ConcurrentHashMap + get() = coordinator.taskMap + + private fun collectQueueStats(): QueueStats { + var waiting = 0 + var downloading = 0 + cacheBookMap.forEach { (_, model) -> + val (w, d) = model.queueCounts() + waiting += w + downloading += d + } + return QueueStats(waiting, downloading) + } + private fun updateSummary() { _downloadSummaryFlow.value = downloadSummary } - val cacheBookMap = ConcurrentHashMap() - private val workingState = MutableStateFlow(true) - private val mutex = Mutex() - val totalCount: Int - get() { - var total = 0 - cacheBookMap.forEach { (_, model) -> - total += model.waitCount + model.onDownloadCount - } - total += successDownloadSet.size - total += errorDownloadMap.size - return total - } - - val completedCount: Int - get() = successDownloadSet.size + errorDownloadMap.size - @Synchronized fun getOrCreate(bookUrl: String): CacheBookModel? { val book = appDb.bookDao.getBook(bookUrl) ?: return null - val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null - updateBookSource(bookSource) - var cacheBook = cacheBookMap[bookUrl] - if (cacheBook != null) { - //存在时更新,书源可能会变化,必须更新 - cacheBook.bookSource = bookSource - cacheBook.book = book - return cacheBook - } - cacheBook = CacheBookModel(bookSource, book) - cacheBookMap[bookUrl] = cacheBook - return cacheBook + val source = appDb.bookSourceDao.getBookSource(book.origin) ?: return null + return getOrCreate(source, book) } @Synchronized fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel { updateBookSource(bookSource) - var cacheBook = cacheBookMap[book.bookUrl] - if (cacheBook != null) { - //存在时更新,书源可能会变化,必须更新 - cacheBook.bookSource = bookSource - cacheBook.book = book - return cacheBook + cacheBookMap[book.bookUrl]?.let { model -> + model.bookSource = bookSource + model.book = book + return model } - cacheBook = CacheBookModel(bookSource, book) - cacheBookMap[book.bookUrl] = cacheBook - return cacheBook + val model = CacheBookModel(bookSource, book) + cacheBookMap[book.bookUrl] = model + updateSummary() + return model } private fun updateBookSource(newBookSource: BookSource) { - cacheBookMap.forEach { - val model = it.value + cacheBookMap.forEach { (_, model) -> if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) { model.bookSource = newBookSource } @@ -149,100 +181,60 @@ object CacheBook { } fun close() { - cacheBookMap.forEach { it.value.stop() } + cacheBookMap.forEach { (_, model) -> model.stop() } cacheBookMap.clear() successDownloadSet.clear() errorDownloadMap.clear() + errorIndexMap.clear() + updateSummary() } fun setWorkingState(value: Boolean) { - workingState.value = value + coordinator.setWorkingState(value) } - suspend fun startProcessJob(context: CoroutineContext) = mutex.withLock { - setWorkingState(true) - flow { - while (currentCoroutineContext().isActive && cacheBookMap.isNotEmpty()) { - var emitted = false - - cacheBookMap.forEach { (_, model) -> - if (!model.isLoading()) { - emit(model) - emitted = true - } - workingState.first { it } - } - - if (!emitted) { - delay(1000) - } - } - }.onStart { - postEvent(EventBus.UP_DOWNLOAD_STATE, "") - updateSummary() - }.onEachParallel(AppConfig.threadCount) { - coroutineScope { - it.download(this, context) - } - }.onCompletion { - postEvent(EventBus.UP_DOWNLOAD_STATE, "") - updateSummary() - }.collect() + suspend fun startProcessJob(context: CoroutineContext) { + coordinator.startProcessJob(context) } + val totalCount: Int + get() { + val stats = collectQueueStats() + return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size + } + + val completedCount: Int + get() = successDownloadSet.size + errorDownloadMap.size + val downloadSummary: String get() { - return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}" + val stats = collectQueueStats() + return "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}" } val isRun: Boolean get() { - cacheBookMap.forEach { - if (it.value.isRun()) { - return true - } + cacheBookMap.forEach { (_, model) -> + if (model.isRun()) return true } return false } - private val waitCount: Int - get() { - var count = 0 - cacheBookMap.forEach { - count += it.value.waitCount - } - return count - } + private fun onTaskQueuesChanged(bookUrl: String) { + updateSummary() + postEvent(EventBus.UP_DOWNLOAD, bookUrl) + } - val onDownloadCount: Int - get() { - var count = 0 - cacheBookMap.forEach { - count += it.value.onDownloadCount - } - return count - } + private fun onTaskRemoved(bookUrl: String) { + cacheBookMap.remove(bookUrl) + updateSummary() + postEvent(EventBus.UP_DOWNLOAD, bookUrl) + } - val successDownloadSet = linkedSetOf() - val errorDownloadMap = hashMapOf() - - class CacheBookModel(var bookSource: BookSource, var book: Book) { - - private fun notifyDownloadSetChanged() { - _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet()) - } - - private fun notifyErrorChanged() { - val errorIndices = errorDownloadMap - .filterKeys { it.startsWith(book.bookUrl) } - .mapNotNull { - val chapterIndex = it.key.substringAfterLast("_").toIntOrNull() - chapterIndex - } - .toSet() - - _downloadErrorFlow.tryEmit(book.bookUrl to errorIndices) - } + class CacheBookModel( + @Volatile var bookSource: BookSource, + @Volatile var book: Book + ) { private val waitDownloadSet = linkedSetOf() private val onDownloadSet = linkedSetOf() @@ -251,13 +243,22 @@ object CacheBook { private var waitingRetry = false private var isLoading = false - val waitCount get() = waitDownloadSet.size - val onDownloadCount get() = onDownloadSet.size - init { postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) } + private fun notifyDownloadSetChanged() { + _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet()) + } + + private fun notifyErrorChanged() { + val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet() + _downloadErrorFlow.tryEmit(book.bookUrl to errors) + } + + @Synchronized + fun queueCounts(): Pair = waitDownloadSet.size to onDownloadSet.size + @Synchronized fun isRun(): Boolean { return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading @@ -269,13 +270,12 @@ object CacheBook { } @Synchronized - fun isLoading(): Boolean { - return isLoading - } + fun isLoading(): Boolean = isLoading @Synchronized fun setLoading() { isLoading = true + CacheBook.onTaskQueuesChanged(book.bookUrl) } @Synchronized @@ -284,7 +284,9 @@ object CacheBook { tasks.clear() isStopped = true isLoading = false - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) + onDownloadSet.clear() + notifyDownloadSetChanged() + CacheBook.onTaskQueuesChanged(book.bookUrl) } @Synchronized @@ -297,7 +299,8 @@ object CacheBook { } cacheBookMap[book.bookUrl] = this isLoading = false - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) + notifyDownloadSetChanged() + CacheBook.onTaskQueuesChanged(book.bookUrl) } fun addDownload(index: Int) { @@ -309,7 +312,9 @@ object CacheBook { onDownloadSet.remove(chapter.index) successDownloadSet.add(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr()) + errorIndexMap[book.bookUrl]?.remove(chapter.index) notifyDownloadSetChanged() + notifyErrorChanged() _cacheSuccessFlow.tryEmit(chapter) } @@ -317,22 +322,20 @@ object CacheBook { private fun onPreError(chapter: BookChapter, error: Throwable) { waitingRetry = true if (error !is ConcurrentException) { - errorDownloadMap[chapter.primaryStr()] = - (errorDownloadMap[chapter.primaryStr()] ?: 0) + 1 + errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc } + errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() } + .add(chapter.index) } onDownloadSet.remove(chapter.index) } @Synchronized private fun onPostError(chapter: BookChapter, error: Throwable) { - //重试3次 - if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) { + val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0 + if (retryCount < 3 && !isStopped) { waitDownloadSet.add(chapter.index) } else { - AppLog.put( - "下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", - error - ) + AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error) } waitingRetry = false } @@ -354,12 +357,13 @@ object CacheBook { @Synchronized private fun onFinally() { + val bookUrl = book.bookUrl if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { - cacheBookMap.remove(book.bookUrl) + CacheBook.onTaskRemoved(bookUrl) + } else { + CacheBook.onTaskQueuesChanged(bookUrl) } - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) notifyDownloadSetChanged() - updateSummary() } /** @@ -370,7 +374,7 @@ object CacheBook { val chapterIndex = waitDownloadSet.firstOrNull() if (chapterIndex == null) { if (!isLoading && onDownloadSet.isEmpty()) { - cacheBookMap.remove(book.bookUrl) + CacheBook.onTaskRemoved(book.bookUrl) } return } @@ -378,12 +382,11 @@ object CacheBook { waitDownloadSet.remove(chapterIndex) return } - val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let { + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run { waitDownloadSet.remove(chapterIndex) return } if (chapter.isVolume) { - /** 修正下载计数 */ postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter)) _cacheSuccessFlow.tryEmit(chapter) waitDownloadSet.remove(chapterIndex) @@ -393,9 +396,11 @@ object CacheBook { waitDownloadSet.remove(chapterIndex) return } + waitDownloadSet.remove(chapterIndex) onDownloadSet.add(chapterIndex) notifyDownloadSetChanged() + if (BookHelp.hasContent(book, chapter)) { Coroutine.async(scope, context, executeContext = context) { BookHelp.getContent(book, chapter)?.let { @@ -405,7 +410,6 @@ object CacheBook { onSuccess(chapter) }.onError { onPreError(chapter, it) - //出现错误等待一秒后重新加入待下载列表 delay(1000) onPostError(chapter, it) }.onCancel { @@ -415,6 +419,7 @@ object CacheBook { } return } + WebBook.getContent( scope, bookSource, @@ -428,7 +433,6 @@ object CacheBook { downloadFinish(chapter, content) }.onError { onPreError(chapter, it) - //出现错误等待一秒后重新加入待下载列表 delay(1000) onPostError(chapter, it) downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}") @@ -445,6 +449,7 @@ object CacheBook { synchronized(this) { onDownloadSet.add(chapter.index) waitDownloadSet.remove(chapter.index) + notifyDownloadSetChanged() } try { val content = WebBook.getContentAwait(bookSource, book, chapter) @@ -453,15 +458,13 @@ object CacheBook { ReadBook.downloadFailChapters.remove(chapter.index) return content } catch (e: Exception) { - if (e is CancellationException) { - onCancel(chapter.index) - } + if (e is CancellationException) onCancel(chapter.index) onError(chapter, e) ReadBook.downloadFailChapters[chapter.index] = (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1 return "获取正文失败\n${e.localizedMessage}" } finally { - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) + CacheBook.onTaskQueuesChanged(book.bookUrl) } } @@ -472,11 +475,11 @@ object CacheBook { semaphore: Semaphore?, resetPageOffset: Boolean = false ) { - if (onDownloadSet.contains(chapter.index)) { - return - } + if (onDownloadSet.contains(chapter.index)) return onDownloadSet.add(chapter.index) waitDownloadSet.remove(chapter.index) + notifyDownloadSetChanged() + WebBook.getContent( scope, bookSource, @@ -499,7 +502,7 @@ object CacheBook { onCancel(chapter.index) downloadFinish(chapter, "download canceled", resetPageOffset, true) }.onFinally { - postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) + CacheBook.onTaskQueuesChanged(book.bookUrl) }.start() } @@ -511,13 +514,13 @@ object CacheBook { ) { if (ReadBook.book?.bookUrl == book.bookUrl) { ReadBook.contentLoadFinish( - book, chapter, content, + book = book, + chapter = chapter, + content = content, resetPageOffset = resetPageOffset, canceled = canceled ) } } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt index 09ab427af..eb80de3d0 100644 --- a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt +++ b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt @@ -7,7 +7,7 @@ import io.legado.app.data.appDb import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.exception.NoStackTraceException -import io.legado.app.help.config.AppConfig +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.book.search.SearchScope import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.mapParallelSafe @@ -33,7 +33,7 @@ import kotlin.coroutines.coroutineContext import kotlin.math.min class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { - val threadCount = AppConfig.threadCount + val threadCount = OtherConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null private var mSearchId = 0L private var searchPage = 1 @@ -210,4 +210,4 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB fun onSearchCancel(exception: Throwable? = null) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt index e940d10a7..f100947a6 100644 --- a/app/src/main/java/io/legado/app/service/CacheBookService.kt +++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt @@ -12,10 +12,10 @@ import io.legado.app.constant.IntentAction import io.legado.app.constant.NotificationId import io.legado.app.data.appDb import io.legado.app.help.book.update -import io.legado.app.help.config.AppConfig import io.legado.app.model.CacheBook import io.legado.app.model.webBook.WebBook -import io.legado.app.ui.book.cache.CacheActivity +import io.legado.app.ui.config.otherConfig.OtherConfig +import io.legado.app.ui.main.MainActivity import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.postEvent import io.legado.app.utils.servicePendingIntent @@ -41,7 +41,7 @@ class CacheBookService : BaseService() { private set } - private val threadCount = AppConfig.threadCount + private val threadCount = OtherConfig.threadCount private var cachePool = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private var downloadJob: Job? = null @@ -53,7 +53,12 @@ class CacheBookService : BaseService() { .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(getString(R.string.offline_cache)) - .setContentIntent(activityPendingIntent("cacheActivity")) + .setContentIntent( + activityPendingIntent( + MainActivity.createCacheIntent(this), + "cacheActivity" + ) + ) builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.cancel), @@ -223,4 +228,4 @@ class CacheBookService : BaseService() { startForeground(NotificationId.CacheBookService, notification) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/service/CheckSourceService.kt b/app/src/main/java/io/legado/app/service/CheckSourceService.kt index 4d08b1d6d..c6bea9e67 100644 --- a/app/src/main/java/io/legado/app/service/CheckSourceService.kt +++ b/app/src/main/java/io/legado/app/service/CheckSourceService.kt @@ -18,12 +18,12 @@ import io.legado.app.exception.ContentEmptyException import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.TocEmptyException import io.legado.app.help.IntentData -import io.legado.app.help.config.AppConfig import io.legado.app.help.source.exploreKinds import io.legado.app.model.CheckSource import io.legado.app.model.Debug import io.legado.app.model.webBook.WebBook import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent @@ -51,7 +51,7 @@ import kotlin.math.min * 校验书源 */ class CheckSourceService : BaseService() { - private var threadCount = AppConfig.threadCount + private var threadCount = OtherConfig.threadCount private var searchCoroutine = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private var notificationMsg = appCtx.getString(R.string.service_starting) @@ -258,4 +258,4 @@ class CheckSourceService : BaseService() { startForeground(NotificationId.CheckSourceService, notificationBuilder.build()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/service/ExportBookService.kt b/app/src/main/java/io/legado/app/service/ExportBookService.kt index c94873036..fde49a920 100644 --- a/app/src/main/java/io/legado/app/service/ExportBookService.kt +++ b/app/src/main/java/io/legado/app/service/ExportBookService.kt @@ -25,7 +25,7 @@ import io.legado.app.help.book.isLocalModified import io.legado.app.help.config.AppConfig import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook -import io.legado.app.ui.book.cache.CacheActivity +import io.legado.app.ui.main.MainActivity import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.HtmlFormatter @@ -48,6 +48,8 @@ import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive @@ -79,6 +81,8 @@ class ExportBookService : BaseService() { companion object { val exportProgress = ConcurrentHashMap() val exportMsg = ConcurrentHashMap() + private val _exportBookUpdateFlow = MutableSharedFlow(extraBufferCapacity = 64) + val exportBookUpdateFlow = _exportBookUpdateFlow.asSharedFlow() } data class ExportConfig( @@ -107,7 +111,7 @@ class ExportBookService : BaseService() { ) waitExportBooks[bookUrl] = exportConfig exportMsg[bookUrl] = getString(R.string.export_wait) - postEvent(EventBus.EXPORT_BOOK, bookUrl) + notifyExportBookChanged(bookUrl) export() } }.onFailure { @@ -127,7 +131,7 @@ class ExportBookService : BaseService() { exportProgress.clear() exportMsg.clear() waitExportBooks.keys.forEach { - postEvent(EventBus.EXPORT_BOOK, it) + notifyExportBookChanged(it) } } @@ -146,7 +150,12 @@ class ExportBookService : BaseService() { val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) .setSmallIcon(R.drawable.ic_export) .setSubText(getString(R.string.export_book)) - .setContentIntent(activityPendingIntent("cacheActivity")) + .setContentIntent( + activityPendingIntent( + MainActivity.createCacheIntent(this), + "cacheActivity" + ) + ) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentText(notificationContentText) .setDeleteIntent(servicePendingIntent(IntentAction.stop)) @@ -206,7 +215,7 @@ class ExportBookService : BaseService() { AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e) } finally { exportProgress.remove(bookUrl) - postEvent(EventBus.EXPORT_BOOK, bookUrl) + notifyExportBookChanged(bookUrl) } } } @@ -234,7 +243,7 @@ class ExportBookService : BaseService() { private suspend fun exportTxt(path: String, book: Book) { exportMsg.remove(book.bookUrl) - postEvent(EventBus.EXPORT_BOOK, book.bookUrl) + notifyExportBookChanged(book.bookUrl) val fileDoc = FileDoc.fromDir(path) exportTxt(fileDoc, book) } @@ -296,13 +305,18 @@ class ExportBookService : BaseService() { }.mapAsync(threads) { chapter -> getExportData(book, chapter, contentProcessor, useReplace) }.collectIndexed { index, result -> - postEvent(EventBus.EXPORT_BOOK, book.bookUrl) + notifyExportBookChanged(book.bookUrl) exportProgress[book.bookUrl] = index append.invoke(result.first, result.second) } } + private fun notifyExportBookChanged(bookUrl: String) { + postEvent(EventBus.EXPORT_BOOK, bookUrl) + _exportBookUpdateFlow.tryEmit(bookUrl) + } + private fun getExportData( book: Book, chapter: BookChapter, @@ -875,4 +889,4 @@ class ExportBookService : BaseService() { return result } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt deleted file mode 100644 index 3e2b4cfc1..000000000 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt +++ /dev/null @@ -1,599 +0,0 @@ -package io.legado.app.ui.book.cache - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.PopupMenu -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.textfield.TextInputLayout -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.AppConst.charsets -import io.legado.app.constant.AppLog -import io.legado.app.constant.EventBus -import io.legado.app.constant.IntentAction -import io.legado.app.data.AppDatabase -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.databinding.ActivityCacheBookBinding -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogSelectSectionExportBinding -import io.legado.app.help.book.getExportFileName -import io.legado.app.help.book.isAudio -import io.legado.app.help.book.tryParesExportFileName -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.dialogs.selector -import io.legado.app.model.CacheBook -import io.legado.app.service.ExportBookService -import io.legado.app.ui.about.AppLogDialog -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.utils.ACache -import io.legado.app.utils.FileDoc -import io.legado.app.utils.applyOpenTint -import io.legado.app.utils.applyTint -import io.legado.app.utils.checkWrite -import io.legado.app.utils.cnCompare -import io.legado.app.utils.enableCustomExport -import io.legado.app.utils.flowWithLifecycleAndDatabaseChange -import io.legado.app.utils.iconItemOnLongClick -import io.legado.app.utils.isContentScheme -import io.legado.app.utils.observeEvent -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.startService -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.verificationField -import io.legado.app.utils.viewbindingdelegate.viewBinding -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import splitties.init.appCtx -import kotlin.math.max - -/** - * cache/download 缓存界面 - */ -class CacheActivity : VMBaseActivity(), - PopupMenu.OnMenuItemClickListener, - CacheAdapter.CallBack { - - override val binding by viewBinding(ActivityCacheBookBinding::inflate) - override val viewModel by viewModels() - - private val exportBookPathKey = "exportBookPath" - private val exportTypes = arrayListOf("txt", "epub") - private val layoutManager by lazy { LinearLayoutManager(this) } - private val adapter by lazy { CacheAdapter(this, this) } - private var booksFlowJob: Job? = null - private var menu: Menu? = null - private val groupList: ArrayList = arrayListOf() - private var groupId: Long = -1 - - private val exportDir = registerForActivityResult(HandleFileContract()) { result -> - var isReadyPath = false - var dirPath = "" - result.uri?.let { uri -> - if (uri.isContentScheme()) { - ACache.get().put(exportBookPathKey, uri.toString()) - dirPath = uri.toString() - isReadyPath = true - } else { - uri.path?.let { path -> - ACache.get().put(exportBookPathKey, path) - dirPath = path - isReadyPath = true - } - } - } - if (!isReadyPath) { - return@registerForActivityResult - } - if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub - configExportSection(dirPath, result.requestCode) - } else { - startExport(dirPath, result.requestCode) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - groupId = intent.getLongExtra("groupId", -1) - lifecycleScope.launch { - binding.titleBar.subtitle = withContext(IO) { - appDb.bookGroupDao.getByID(groupId)?.groupName - ?: getString(R.string.no_group) - } - } - initRecyclerView() - initGroupData() - initBookData() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_cache, menu) - menu.iconItemOnLongClick(R.id.menu_download) { - PopupMenu(this, it).apply { - inflate(R.menu.book_cache_download) - this.menu.applyOpenTint(this@CacheActivity) - setOnMenuItemClickListener(this@CacheActivity) - }.show() - } - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - this.menu = menu - upMenu() - return super.onPrepareOptionsMenu(menu) - } - - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - menu.findItem(R.id.menu_enable_replace)?.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace) - // 菜单打开时读取状态[enableCustomExport] - menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport - menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName - menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav - menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile - menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook - menu.findItem(R.id.menu_export_type)?.title = - "${getString(R.string.export_type)}(${getTypeName()})" - menu.findItem(R.id.menu_export_charset)?.title = - "${getString(R.string.export_charset)}(${AppConfig.exportCharset})" - return super.onMenuOpened(featureId, menu) - } - - private fun upMenu() { - menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu -> - subMenu.removeGroup(R.id.menu_group) - groupList.forEach { bookGroup -> - subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName) - } - } - } - - /** - * 菜单按下回调 - */ - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_download, - R.id.menu_download_after -> { - if (!CacheBook.isRun) sureCacheBook { - adapter.getItems().forEach { book -> - val indices = (book.durChapterIndex..book.lastChapterIndex).toList() - CacheBook.start(this@CacheActivity, book, indices) - } - } else { - CacheBook.stop(this@CacheActivity) - } - } - - R.id.menu_download_all -> { - if (!CacheBook.isRun) sureCacheBook { - adapter.getItems().forEach { book -> - val indices = (0..book.lastChapterIndex).toList() - CacheBook.start(this@CacheActivity, book, indices) - } - } else { - CacheBook.stop(this@CacheActivity) - } - } - - R.id.menu_export_all -> exportAll() - R.id.menu_enable_replace -> { - AppConfig.exportUseReplace = !AppConfig.exportUseReplace - item.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace) - val message = if (AppConfig.exportUseReplace) { - "替换净化功能已开启" - } else { - "替换净化功能已关闭" - } - toastOnUi(message) - } - // 更改菜单状态[enableCustomExport] - R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked - R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked - R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked - R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked - R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked - R.id.menu_export_folder -> { - selectExportFolder(-1) - } - - R.id.menu_export_file_name -> alertExportFileName() - R.id.menu_export_type -> showExportTypeConfig() - R.id.menu_export_charset -> showCharsetConfig() - R.id.menu_log -> showDialogFragment() - else -> if (item.groupId == R.id.menu_group) { - binding.titleBar.subtitle = item.title - groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0 - initBookData() - } - } - return super.onCompatOptionsItemSelected(item) - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - return onCompatOptionsItemSelected(item) - } - - private fun initRecyclerView() { - binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.adapter = adapter - } - - private fun initBookData() { - booksFlowJob?.cancel() - booksFlowJob = lifecycleScope.launch { - appDb.bookDao.flowByGroup(groupId).map { books -> - val booksDownload = books.filter { - !it.isAudio - } - when (AppConfig.getBookSortByGroupId(groupId)) { - 1 -> booksDownload.sortedByDescending { it.latestChapterTime } - 2 -> booksDownload.sortedWith { o1, o2 -> - o1.name.cnCompare(o2.name) - } - - 3 -> booksDownload.sortedBy { it.order } - 4 -> booksDownload.sortedByDescending { - max(it.latestChapterTime, it.durChapterTime) - } - - else -> booksDownload.sortedByDescending { it.durChapterTime } - } - }.flowWithLifecycleAndDatabaseChange( - lifecycle, table = AppDatabase.BOOK_TABLE_NAME - ).catch { - AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it) - }.flowOn(IO).conflate().collect { books -> - adapter.setItems(books) - viewModel.loadCacheFiles(books) - } - } - } - - @SuppressLint("NotifyDataSetChanged") - private fun initGroupData() { - lifecycleScope.launch { - appDb.bookGroupDao.flowAll().catch { - AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it) - }.flowOn(IO).conflate().collect { - groupList.clear() - groupList.addAll(it) - adapter.notifyDataSetChanged() - upMenu() - } - } - } - - private fun notifyItemChanged(bookUrl: String) { - kotlin.runCatching { - adapter.getItems().forEachIndexed { index, book -> - if (bookUrl == book.bookUrl) { - adapter.notifyItemChanged(index, true) - return - } - } - } - } - - override fun observeLiveBus() { - viewModel.upAdapterLiveData.observe(this) { - notifyItemChanged(it) - } - observeEvent(EventBus.EXPORT_BOOK) { - notifyItemChanged(it) - } - observeEvent(EventBus.UP_DOWNLOAD) { - notifyItemChanged(it) - } - observeEvent(EventBus.UP_DOWNLOAD_STATE) { - if (!CacheBook.isRun) { - menu?.findItem(R.id.menu_download)?.let { item -> - item.setIcon(R.drawable.ic_play) - item.setTitle(R.string.download_start) - } - menu?.applyTint(this) - } else { - menu?.findItem(R.id.menu_download)?.let { item -> - item.setIcon(R.drawable.ic_stop_black_24dp) - item.setTitle(R.string.stop) - } - menu?.applyTint(this) - } - } - observeEvent>(EventBus.SAVE_CONTENT) { (book, chapter) -> - viewModel.cacheChapters[book.bookUrl]?.add(chapter.url) - notifyItemChanged(book.bookUrl) - } - } - - override fun export(position: Int) { - val path = ACache.get().getAsString(exportBookPathKey) - lifecycleScope.launch { - if (path.isNullOrEmpty() || - withContext(IO) { !FileDoc.fromDir(path).checkWrite() } - ) { - selectExportFolder(position) - } else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub - configExportSection(path, position) - } else { - startExport(path, position) - } - } - } - - private fun exportAll() { - val path = ACache.get().getAsString(exportBookPathKey) - if (path.isNullOrEmpty()) { - selectExportFolder(-10) - } else { - startExport(path, -10) - } - } - - override fun deleteDownload(book: Book) { - viewModel.clearCacheForBook(book) - notifyItemChanged(book.bookUrl) - } - - /** - * 配置自定义导出对话框 - * - * @param path 导出路径 - * @param position book位置 - * @author Discut - * @since 1.0.0 - */ - private fun configExportSection(path: String, position: Int) { - - val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater) - .apply { - fun verifyExportFileNameJsStr(js: String): Boolean { - return tryParesExportFileName(js) && etEpubFilename.text.toString() - .isNotEmpty() - } - - fun enableLyEtEpubFilenameIcon() { - lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM - lyEtEpubFilename.setEndIconOnClickListener { - adapter.getItem(position)?.run { - lyEtEpubFilename.helperText = - if (verifyExportFileNameJsStr(etEpubFilename.text.toString())) - "${resources.getString(R.string.result_analyzed)}: ${ - getExportFileName( - "epub", - 1, - etEpubFilename.text.toString() - ) - }" - else "Error" - } ?: run { - lyEtEpubFilename.helperText = "Error" - AppLog.put("未找到书籍,position is $position") - } - } - } - etEpubSize.setText("1") - // lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE - etEpubFilename.text?.append(AppConfig.episodeExportFileName) - // 存储解析文件名的jsStr - etEpubFilename.let { - it.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) - return@setOnFocusChangeListener - it.text?.run { - if (verifyExportFileNameJsStr(toString())) { - AppConfig.episodeExportFileName = toString() - } - } - } - } - tvAllExport.setOnClickListener { - cbAllExport.callOnClick() - } - tvSelectExport.setOnClickListener { - cbSelectExport.callOnClick() - } - cbSelectExport.setOnCheckedChangeListener{ _, isChecked -> - if (isChecked) { - etEpubSize.isEnabled = true - etInputScope.isEnabled = true - etEpubFilename.isEnabled = true - enableLyEtEpubFilenameIcon() - cbAllExport.isChecked = false - } - } - cbAllExport.setOnCheckedChangeListener{ _, isChecked -> - if (isChecked) { - etEpubSize.isEnabled = false - etInputScope.isEnabled = false - etEpubFilename.isEnabled = false - lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE - cbSelectExport.isChecked = false - } - } - - etInputScope.onFocusChangeListener = - View.OnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - etInputScope.hint = "1-5,8,10-18" - } else { - etInputScope.hint = "" - } - } - - // 默认选择自定义导出 - cbSelectExport.callOnClick() - } - val alertDialog = alert(titleResource = R.string.select_section_export) { - customView { alertBinding.root } - positiveButton(R.string.ok) - cancelButton() - } - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - alertBinding.apply { - if (cbAllExport.isChecked) { - startExport(path, position) - alertDialog.hide() - return@apply - } - val epubScope = etInputScope.text.toString() - if (!verificationField(epubScope)) { - etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围" - return@apply - } - etInputScope.error = null - val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1 - adapter.getItem(position)?.let { book -> - startService { - action = IntentAction.start - putExtra("bookUrl", book.bookUrl) - putExtra("exportType", "epub") - putExtra("exportPath", path) - putExtra("epubSize", epubSize) - putExtra("epubScope", epubScope) - } - } - alertDialog.hide() - } - - } - } - - private fun selectExportFolder(exportPosition: Int) { - val default = arrayListOf>() - val path = ACache.get().getAsString(exportBookPathKey) - if (!path.isNullOrEmpty()) { - default.add(SelectItem(path, -1)) - } - exportDir.launch { - otherActions = default - requestCode = exportPosition - } - } - - private fun startExport(path: String, exportPosition: Int) { - val exportType = when (AppConfig.exportType) { - 1 -> "epub" - else -> "txt" - } - if (exportPosition == -10) { - if (adapter.getItems().isNotEmpty()) { - adapter.getItems().forEach { book -> - startService { - action = IntentAction.start - putExtra("bookUrl", book.bookUrl) - putExtra("exportType", exportType) - putExtra("exportPath", path) - } - } - } else { - toastOnUi(R.string.no_book) - } - } else if (exportPosition >= 0) { - adapter.getItem(exportPosition)?.let { book -> - startService { - action = IntentAction.start - putExtra("bookUrl", book.bookUrl) - putExtra("exportType", exportType) - putExtra("exportPath", path) - } - } - } - } - - @SuppressLint("SetTextI18n") - private fun alertExportFileName() { - alert(R.string.export_file_name) { - val message = """ -支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。 -可以在字段前后加任意字符。 - -示例: -书名:《{name}》 作者:{author} -输出:书名:《三体》 作者:刘慈欣 - -书名:《{name}》-作者:{author}_备注 -输出:书名:《三体》-作者:刘慈欣_备注 - """.trimIndent() - - setMessage(message) - - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editLayout.hint = "书名:《{name}》 作者:{author}" - editView.setText(AppConfig.bookExportFileName) - } - - customView { alertBinding.root } - - okButton { - AppConfig.bookExportFileName = alertBinding.editView.text?.toString() - } - cancelButton() - } - } - - private fun sureCacheBook(action: () -> Unit) { - alert(R.string.draw) { - setMessage(R.string.sure_cache_book) - noButton() - yesButton { - action.invoke() - } - } - } - - private fun getTypeName(): String { - return exportTypes.getOrElse(AppConfig.exportType) { - exportTypes[0] - } - } - - private fun showExportTypeConfig() { - selector(R.string.export_type, exportTypes) { _, i -> - AppConfig.exportType = i - } - } - - private fun showCharsetConfig() { - alert(R.string.set_charset) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "charset name" - editView.setFilterValues(charsets) - editView.setText(AppConfig.exportCharset) - } - customView { alertBinding.root } - okButton { - AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8" - } - cancelButton() - } - } - - override val cacheChapters: HashMap> - get() = viewModel.cacheChapters - - override fun exportProgress(bookUrl: String): Int? { - return ExportBookService.exportProgress[bookUrl] - } - - override fun exportMsg(bookUrl: String): String? { - return ExportBookService.exportMsg[bookUrl] - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt index 8d274ea8f..f235a51a6 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt @@ -1,8 +1,6 @@ package io.legado.app.ui.book.cache import android.content.Context -import android.os.Handler -import android.os.Looper import android.view.ViewGroup import android.widget.ProgressBar import android.widget.TextView @@ -15,7 +13,6 @@ import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.data.entities.Book import io.legado.app.databinding.ItemDownloadBinding import io.legado.app.help.book.isLocal -import io.legado.app.model.CacheBook import io.legado.app.utils.gone import io.legado.app.utils.visible @@ -72,8 +69,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) : } else { val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0 tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum) - val cacheTask = CacheBook.cacheBookMap[item.bookUrl] - if (cacheTask != null && !cacheTask.isStop()) { + if (callBack.isBookDownloading(item.bookUrl)) { val progress = if (item.totalChapterNum > 0) cacheSize * 100 / item.totalChapterNum else 0 progressDownload.progress = progress progressDownload.visible() @@ -90,17 +86,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) : binding.run { ivDownload.setOnClickListener { getItem(holder.layoutPosition)?.let { book -> - CacheBook.cacheBookMap[book.bookUrl]?.let { - if (!it.isStop()) { - CacheBook.remove(context, book.bookUrl) - } else { - val indices = (0..book.lastChapterIndex).toList() - CacheBook.start(context, book, indices) - } - } ?: let { - val indices = (0..book.lastChapterIndex).toList() - CacheBook.start(context, book, indices) - } + callBack.toggleBookDownload(book) } } tvExport.setOnClickListener { @@ -108,18 +94,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) : } ivDelete.setOnClickListener { getItem(holder.layoutPosition)?.let { book -> - CacheBook.cacheBookMap[book.bookUrl]?.let { - if (!it.isStop()) { - CacheBook.remove(context, book.bookUrl) - Handler(Looper.getMainLooper()).postDelayed({ - callBack.deleteDownload(book) - }, 10) - } else { - callBack.deleteDownload(book) - } - } ?: run { - callBack.deleteDownload(book) - } + callBack.deleteDownload(book) } } @@ -131,15 +106,12 @@ class CacheAdapter(context: Context, private val callBack: CallBack) : button.gone() } else { button.visible() - CacheBook.cacheBookMap[book.bookUrl]?.let { - if (!it.isStop()) { - button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp) - } else { - button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play) - } - } ?: run { - button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play) + val iconRes = if (callBack.isBookDownloading(book.bookUrl)) { + R.drawable.ic_stop_black_24dp + } else { + R.drawable.ic_play } + button.icon = ContextCompat.getDrawable(context, iconRes) } } @@ -162,11 +134,13 @@ class CacheAdapter(context: Context, private val callBack: CallBack) : } interface CallBack { - val cacheChapters: HashMap> + val cacheChapters: Map> + fun isBookDownloading(bookUrl: String): Boolean + fun toggleBookDownload(book: Book) fun export(position: Int) fun exportProgress(bookUrl: String): Int? fun exportMsg(bookUrl: String): String? fun deleteDownload(book: Book) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt new file mode 100644 index 000000000..9b0b8d7fd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt @@ -0,0 +1,769 @@ +package io.legado.app.ui.book.cache + +import androidx.activity.compose.rememberLauncherForActivityResult +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.RadioButton +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.constant.IntentAction +import io.legado.app.data.entities.Book +import io.legado.app.help.book.getExportFileName +import io.legado.app.help.book.isLocal +import io.legado.app.help.book.tryParesExportFileName +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.service.ExportBookService +import io.legado.app.ui.about.AppLogSheet +import io.legado.app.ui.file.HandleFileContract +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppLinearProgressIndicator +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.AppFloatingActionButton +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.SmallTonalIconButton +import io.legado.app.ui.widget.components.button.TopBarActionButton +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.divider.PillDivider +import io.legado.app.ui.widget.components.filePicker.FilePickerSheet +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.icon.AppIcons +import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard +import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet +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.utils.ACache +import io.legado.app.utils.FileDoc +import io.legado.app.utils.checkWrite +import io.legado.app.utils.isContentScheme +import io.legado.app.utils.startService +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.verificationField +import org.koin.androidx.compose.koinViewModel + +@Composable +fun CacheRouteScreen( + groupId: Long, + onBackClick: () -> Unit, + viewModel: CacheViewModel = koinViewModel() +) { + LaunchedEffect(groupId) { + viewModel.dispatch(CacheIntent.Initialize(groupId)) + } + CacheScreen(viewModel = viewModel, onBackClick = onBackClick) +} + +@Composable +private fun CacheScreen( + viewModel: CacheViewModel, + onBackClick: () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + var showGroupMenu by remember { mutableStateOf(false) } + var showFilePickerSheet by remember { mutableStateOf(false) } + var showDownloadConfirmDialog by remember { mutableStateOf(false) } + var showDownloadAllConfirmDialog by remember { mutableStateOf(false) } + var showExportTypeDialog by remember { mutableStateOf(false) } + var showExportFileNameDialog by remember { mutableStateOf(false) } + var showCharsetDialog by remember { mutableStateOf(false) } + var showLogSheet by remember { mutableStateOf(false) } + var showCustomExportDialog by remember { mutableStateOf(false) } + var pendingExportBookUrl by remember { mutableStateOf(null) } + var pendingExportAll by remember { mutableStateOf(false) } + var customExportPath by remember { mutableStateOf("") } + var customExportBook by remember { mutableStateOf(null) } + var customExportAllChapter by remember { mutableStateOf(false) } + var customEpubScopeInput by remember { mutableStateOf("") } + var customEpubScopeError by remember { mutableStateOf(null) } + var customEpubSizeInput by remember { mutableStateOf("1") } + var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) } + var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) } + var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) } + val exportBookPathKey = remember { "exportBookPath" } + val exportTypes = remember { arrayListOf("txt", "epub") } + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val exportFolderText = stringResource(R.string.export_folder) + val exportAllText = stringResource(R.string.export_all) + val exportChapterIndexText = stringResource(R.string.export_chapter_index) + val fileContainsNumberText = stringResource(R.string.file_contains_number) + val exportFileNameText = stringResource(R.string.export_file_name) + val resultAnalyzedText = stringResource(R.string.result_analyzed) + val errorScopeInputText = stringResource(R.string.error_scope_input) + val exportFileNameHintText = "书名:《{name}》 作者:{author}" + val exportFileNameHelpText = """ +支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。 +可以在字段前后加任意字符。 + +示例: +书名:《{name}》 作者:{author} +输出:书名:《三体》 作者:刘慈欣 + +书名:《{name}》-作者:{author}_备注 +输出:书名:《三体》-作者:刘慈欣_备注 + """.trimIndent() + + val exportDir = rememberLauncherForActivityResult(HandleFileContract()) { result -> + var isReadyPath = false + var dirPath = "" + result.uri?.let { uri -> + if (uri.isContentScheme()) { + ACache.get().put(exportBookPathKey, uri.toString()) + dirPath = uri.toString() + isReadyPath = true + } else { + uri.path?.let { path -> + ACache.get().put(exportBookPathKey, path) + dirPath = path + isReadyPath = true + } + } + } + if (!isReadyPath) return@rememberLauncherForActivityResult + if (pendingExportAll) { + state.books.forEach { book -> + startExport(context, dirPath, book, state.exportConfig.exportType) + } + return@rememberLauncherForActivityResult + } + val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult + val book = state.books.firstOrNull { it.bookUrl == bookUrl } ?: return@rememberLauncherForActivityResult + if (state.exportConfig.enableCustomExport) { + customExportPath = dirPath + customExportBook = book + customExportAllChapter = false + customEpubScopeInput = "" + customEpubScopeError = null + customEpubSizeInput = "1" + customEpisodeExportNameInput = state.exportConfig.episodeExportFileName + showCustomExportDialog = true + } else { + startExport(context, dirPath, book, state.exportConfig.exportType) + } + } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is CacheEffect.ShowMessage -> context.toastOnUi(effect.message) + is CacheEffect.NotifyBookChanged -> Unit + } + } + } + + fun selectExportFolder(bookUrl: String? = null, forAll: Boolean = false) { + pendingExportBookUrl = bookUrl + pendingExportAll = forAll + showFilePickerSheet = true + } + + fun exportBook(book: Book) { + val path = ACache.get().getAsString(exportBookPathKey) + if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) { + selectExportFolder(book.bookUrl) + } else if (state.exportConfig.enableCustomExport) { + customExportPath = path + customExportBook = book + customExportAllChapter = false + customEpubScopeInput = "" + customEpubScopeError = null + customEpubSizeInput = "1" + customEpisodeExportNameInput = state.exportConfig.episodeExportFileName + showCustomExportDialog = true + } else { + startExport(context, path, book, state.exportConfig.exportType) + } + } + + fun exportAll() { + val path = ACache.get().getAsString(exportBookPathKey) + if (path.isNullOrEmpty()) { + selectExportFolder(forAll = true) + } else { + state.books.forEach { book -> + startExport(context, path, book, state.exportConfig.exportType) + } + } + } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = state.groupName ?: stringResource(R.string.offline_cache), + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + }, + actions = { + if (state.groupList.isNotEmpty()) { + TopBarActionButton( + onClick = { showGroupMenu = true }, + imageVector = AppIcons.Filter, + contentDescription = null + ) + RoundDropdownMenu( + expanded = showGroupMenu, + onDismissRequest = { showGroupMenu = false } + ) { dismiss -> + state.groupList.forEach { group -> + RoundDropdownMenuItem( + text = "${stringResource(R.string.group)}: ${group.groupName}", + isSelected = group.groupId == state.groupId, + onClick = { + dismiss() + viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId)) + } + ) + } + } + } + TopBarActionButton( + onClick = { showMenu = true }, + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + RoundDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + RoundDropdownMenuItem( + text = stringResource(R.string.download_all), + onClick = { + showMenu = false + if (state.isDownloadRunning) { + viewModel.dispatch(CacheIntent.StopDownload) + } else { + showDownloadAllConfirmDialog = true + } + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.export_all), + onClick = { showMenu = false; exportAll() } + ) + PillDivider() + RoundDropdownMenuItem( + text = stringResource(R.string.export_folder), + onClick = { showMenu = false; selectExportFolder() } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.export_file_name), + onClick = { + showMenu = false + exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty() + showExportFileNameDialog = true + } + ) + RoundDropdownMenuItem( + text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})", + onClick = { + showMenu = false + showExportTypeDialog = true + } + ) + RoundDropdownMenuItem( + text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})", + onClick = { + showMenu = false + exportCharsetInput = state.exportConfig.exportCharset + showCharsetDialog = true + } + ) + PillDivider() + RoundDropdownMenuItem( + text = "替换净化", + isSelected = state.exportConfig.exportUseReplace, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace) + ) + } + ) + RoundDropdownMenuItem( + text = "自定义导出", + isSelected = state.exportConfig.enableCustomExport, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport) + ) + } + ) + RoundDropdownMenuItem( + text = "导出包含章节名", + isSelected = !state.exportConfig.exportNoChapterName, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName) + ) + } + ) + RoundDropdownMenuItem( + text = "导出到WebDav", + isSelected = state.exportConfig.exportToWebDav, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav) + ) + } + ) + RoundDropdownMenuItem( + text = "导出插图文件", + isSelected = state.exportConfig.exportPictureFile, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile) + ) + } + ) + RoundDropdownMenuItem( + text = "并行导出", + isSelected = state.exportConfig.parallelExportBook, + onClick = { + showMenu = false + viewModel.dispatch( + CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook) + ) + } + ) + PillDivider() + RoundDropdownMenuItem( + text = stringResource(R.string.log), + onClick = { + showMenu = false + showLogSheet = true + } + ) + } + } + ) + }, + floatingActionButton = { + AppFloatingActionButton( + onClick = { + if (state.isDownloadRunning) { + viewModel.dispatch(CacheIntent.StopDownload) + } else { + showDownloadConfirmDialog = true + } + } + ) { + Icon( + imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download, + contentDescription = null + ) + } + } + ) { paddingValues -> + val renderVersion by rememberUpdatedState(state.cacheVersion) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 80.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.books, key = { it.bookUrl }) { book -> + val cacheCount = remember(renderVersion, book.bookUrl) { + viewModel.getCacheChapters(book.bookUrl)?.size ?: 0 + } + val isDownloading = remember(renderVersion, book.bookUrl) { + viewModel.isBookDownloading(book.bookUrl) + } + val exportMsg = remember(renderVersion, book.bookUrl) { + ExportBookService.exportMsg[book.bookUrl] + } + val exportProgress = remember(renderVersion, book.bookUrl) { + ExportBookService.exportProgress[book.bookUrl] + } + GlassCard( + containerColor = LegadoTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + AppText( + text = book.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum)) + } + AppText( + text = stringResource(R.string.author_show, book.getRealAuthor()), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + AppText( + text = if (book.isLocal) { + stringResource(R.string.local_book) + } else { + stringResource(R.string.download_count, cacheCount, book.totalChapterNum) + } + ) + if (isDownloading && book.totalChapterNum > 0) { + AppLinearProgressIndicator( + progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) + ) + } + if (exportMsg != null) { + AppText(text = exportMsg, modifier = Modifier.padding(top = 6.dp)) + } else if (exportProgress != null && book.totalChapterNum > 0) { + AppLinearProgressIndicator( + progress = (exportProgress.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f), + modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + SmallTonalIconButton( + onClick = { + if (!book.isLocal) { + viewModel.dispatch(CacheIntent.ToggleBookDownload(book)) + } + }, + imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download, + contentDescription = null + ) + SmallTonalIconButton( + onClick = { exportBook(book) }, + modifier = Modifier.padding(start = 8.dp), + imageVector = Icons.Default.Upload, + contentDescription = null + ) + SmallTonalIconButton( + onClick = { + viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl)) + viewModel.dispatch(CacheIntent.ClearBookCache(book)) + }, + modifier = Modifier.padding(start = 8.dp), + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } + } + } + } + } + } + + FilePickerSheet( + show = showFilePickerSheet, + onDismissRequest = { showFilePickerSheet = false }, + title = exportFolderText, + onSelectSysDir = { + showFilePickerSheet = false + val default = arrayListOf>() + val path = ACache.get().getAsString(exportBookPathKey) + if (!path.isNullOrEmpty()) default.add(SelectItem(path, -1)) + exportDir.launch { + mode = HandleFileContract.DIR_SYS + title = exportFolderText + otherActions = default + requestCode = 0 + } + } + ) + + AppAlertDialog( + show = showDownloadConfirmDialog, + onDismissRequest = { showDownloadConfirmDialog = false }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_cache_book), + confirmText = stringResource(android.R.string.ok), + onConfirm = { + showDownloadConfirmDialog = false + viewModel.dispatch( + CacheIntent.StartDownloadForVisibleBooks( + books = state.books, + downloadAllChapters = false + ) + ) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { showDownloadConfirmDialog = false } + ) + + AppAlertDialog( + show = showDownloadAllConfirmDialog, + onDismissRequest = { showDownloadAllConfirmDialog = false }, + title = stringResource(R.string.draw), + text = stringResource(R.string.sure_cache_book), + confirmText = stringResource(android.R.string.ok), + onConfirm = { + showDownloadAllConfirmDialog = false + viewModel.dispatch( + CacheIntent.StartDownloadForVisibleBooks( + books = state.books, + downloadAllChapters = true + ) + ) + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { showDownloadAllConfirmDialog = false } + ) + + OptionSheet( + show = showExportTypeDialog, + onDismissRequest = { showExportTypeDialog = false }, + title = stringResource(R.string.export_type) + ) { + exportTypes.forEachIndexed { index, type -> + OptionCard( + icon = if (type == "epub") Icons.Default.Upload else Icons.Default.Download, + text = type, + onClick = { + viewModel.dispatch(CacheIntent.SetExportType(index)) + showExportTypeDialog = false + } + ) + } + } + + AppAlertDialog( + show = showExportFileNameDialog, + onDismissRequest = { showExportFileNameDialog = false }, + title = stringResource(R.string.export_file_name), + content = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AppText(text = exportFileNameHelpText) + AppTextField( + value = exportFileNameInput, + onValueChange = { exportFileNameInput = it }, + modifier = Modifier.fillMaxWidth(), + label = exportFileNameText, + placeholder = { AppText(exportFileNameHintText) } + ) + } + }, + confirmText = stringResource(android.R.string.ok), + onConfirm = { + viewModel.dispatch(CacheIntent.SetBookExportFileName(exportFileNameInput)) + showExportFileNameDialog = false + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { showExportFileNameDialog = false } + ) + + AppAlertDialog( + show = showCharsetDialog, + onDismissRequest = { showCharsetDialog = false }, + title = stringResource(R.string.set_charset), + content = { + AppTextField( + value = exportCharsetInput, + onValueChange = { exportCharsetInput = it }, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.set_charset) + ) + }, + confirmText = stringResource(android.R.string.ok), + onConfirm = { + viewModel.dispatch( + CacheIntent.SetExportCharset( + exportCharsetInput.ifBlank { "UTF-8" } + ) + ) + showCharsetDialog = false + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { showCharsetDialog = false } + ) + + AppLogSheet( + show = showLogSheet, + onDismissRequest = { showLogSheet = false } + ) + + val currentCustomBook = customExportBook + AppAlertDialog( + show = showCustomExportDialog && currentCustomBook != null, + onDismissRequest = { + showCustomExportDialog = false + customEpubScopeError = null + }, + title = stringResource(R.string.select_section_export), + content = { + val episodeTemplateValid = customEpisodeExportNameInput.isNotBlank() + && tryParesExportFileName(customEpisodeExportNameInput) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + RadioButton( + selected = customExportAllChapter, + onClick = { customExportAllChapter = true } + ) + AppText( + text = exportAllText, + modifier = Modifier.padding(top = 12.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + RadioButton( + selected = !customExportAllChapter, + onClick = { customExportAllChapter = false } + ) + AppText( + text = exportChapterIndexText, + modifier = Modifier.padding(top = 12.dp) + ) + } + if (!customExportAllChapter) { + AppTextField( + value = customEpubScopeInput, + onValueChange = { + customEpubScopeInput = it + customEpubScopeError = null + }, + modifier = Modifier.fillMaxWidth(), + label = exportChapterIndexText, + placeholder = { AppText("1-5,8,10-18") }, + supportingText = { + customEpubScopeError?.let { msg -> + AppText(text = msg) + } + }, + isError = customEpubScopeError != null + ) + AppTextField( + value = customEpubSizeInput, + onValueChange = { customEpubSizeInput = it }, + modifier = Modifier.fillMaxWidth(), + label = fileContainsNumberText + ) + AppTextField( + value = customEpisodeExportNameInput, + onValueChange = { customEpisodeExportNameInput = it }, + modifier = Modifier.fillMaxWidth(), + label = exportFileNameText, + placeholder = { AppText(exportFileNameHintText) } + ) + if (episodeTemplateValid) { + AppText( + text = "$resultAnalyzedText: ${ + currentCustomBook?.getExportFileName( + "epub", + 1, + customEpisodeExportNameInput + ).orEmpty() + }" + ) + } else if (customEpisodeExportNameInput.isNotBlank()) { + AppText(text = "Error") + } + } + } + }, + confirmText = stringResource(android.R.string.ok), + onConfirm = { + val book = customExportBook ?: return@AppAlertDialog + if (customExportAllChapter) { + context.startService { + action = IntentAction.start + putExtra("bookUrl", book.bookUrl) + putExtra("exportType", "epub") + putExtra("exportPath", customExportPath) + } + showCustomExportDialog = false + return@AppAlertDialog + } + if (!verificationField(customEpubScopeInput)) { + customEpubScopeError = errorScopeInputText + return@AppAlertDialog + } + customEpubScopeError = null + if (customEpisodeExportNameInput.isNotBlank() && tryParesExportFileName( + customEpisodeExportNameInput + ) + ) { + viewModel.dispatch(CacheIntent.SetEpisodeExportFileName(customEpisodeExportNameInput)) + } + val epubSize = customEpubSizeInput.toIntOrNull()?.coerceAtLeast(1) ?: 1 + context.startService { + action = IntentAction.start + putExtra("bookUrl", book.bookUrl) + putExtra("exportType", "epub") + putExtra("exportPath", customExportPath) + putExtra("epubSize", epubSize) + putExtra("epubScope", customEpubScopeInput) + } + showCustomExportDialog = false + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { + showCustomExportDialog = false + customEpubScopeError = null + } + ) +} + +private fun startExport( + context: android.content.Context, + path: String, + book: Book, + exportTypeIndex: Int +) { + val exportType = if (exportTypeIndex == 1) "epub" else "txt" + context.startService { + action = IntentAction.start + putExtra("bookUrl", book.bookUrl) + putExtra("exportType", exportType) + putExtra("exportPath", path) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt index f2e26727e..d1a3d8af5 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt @@ -1,32 +1,299 @@ package io.legado.app.ui.book.cache import android.app.Application -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel -import io.legado.app.data.appDb +import io.legado.app.data.dao.BookChapterDao +import io.legado.app.data.dao.BookDao +import io.legado.app.data.dao.BookGroupDao 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.help.book.BookHelp +import io.legado.app.help.book.isAudio import io.legado.app.help.book.isLocal -import io.legado.app.help.coroutine.Coroutine -import io.legado.app.utils.sendValue +import io.legado.app.model.CacheBook +import io.legado.app.service.ExportBookService +import io.legado.app.ui.config.cacheConfig.CacheConfig +import io.legado.app.utils.cnCompare +import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.math.max +data class CacheExportConfig( + val exportUseReplace: Boolean = true, + val enableCustomExport: Boolean = false, + val exportNoChapterName: Boolean = false, + val exportToWebDav: Boolean = false, + val exportPictureFile: Boolean = false, + val parallelExportBook: Boolean = false, + val exportType: Int = 0, + val exportCharset: String = "UTF-8", + val bookExportFileName: String? = null, + val episodeExportFileName: String = "" +) -class CacheViewModel(application: Application) : BaseViewModel(application) { - val upAdapterLiveData = MutableLiveData() +data class CacheUiState( + val groupId: Long = -1, + val groupName: String? = null, + val groupList: List = emptyList(), + val books: List = emptyList(), + val isDownloadRunning: Boolean = false, + val cacheVersion: Long = 0, + val exportConfig: CacheExportConfig = CacheExportConfig() +) - private var loadChapterCoroutine: Coroutine? = null - val cacheChapters = hashMapOf>() +sealed interface CacheIntent { + data class Initialize(val groupId: Long) : CacheIntent + data class ChangeGroup(val groupId: Long) : CacheIntent + data class StartDownloadForVisibleBooks( + val books: List, + val downloadAllChapters: Boolean + ) : CacheIntent + data object StopDownload : CacheIntent + data class ToggleBookDownload(val book: Book) : CacheIntent + data class DeleteBookDownload(val bookUrl: String) : CacheIntent + data class ClearBookCache(val book: Book) : CacheIntent + data class SetExportUseReplace(val enabled: Boolean) : CacheIntent + data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent + data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent + data class SetExportToWebDav(val enabled: Boolean) : CacheIntent + data class SetExportPictureFile(val enabled: Boolean) : CacheIntent + data class SetParallelExportBook(val enabled: Boolean) : CacheIntent + data class SetExportType(val type: Int) : CacheIntent + data class SetExportCharset(val charset: String) : CacheIntent + data class SetBookExportFileName(val fileName: String?) : CacheIntent + data class SetEpisodeExportFileName(val fileName: String) : CacheIntent +} - fun loadCacheFiles(books: List) { - loadChapterCoroutine?.cancel() - loadChapterCoroutine = execute { +sealed interface CacheEffect { + data class NotifyBookChanged(val bookUrl: String) : CacheEffect + data class ShowMessage(val message: String) : CacheEffect +} + +class CacheViewModel( + application: Application, + private val bookDao: BookDao, + private val bookGroupDao: BookGroupDao, + private val bookChapterDao: BookChapterDao, + val cacheConfig: CacheConfig +) : BaseViewModel(application) { + + private val _uiState = MutableStateFlow(CacheUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 32) + val effects = _effects.asSharedFlow() + + private val cacheChapters = hashMapOf>() + private var booksJob: Job? = null + private var groupsJob: Job? = null + private var observersStarted = false + + fun dispatch(intent: CacheIntent) { + when (intent) { + is CacheIntent.Initialize -> initialize(intent.groupId) + is CacheIntent.ChangeGroup -> changeGroup(intent.groupId) + is CacheIntent.StartDownloadForVisibleBooks -> startDownloadForVisibleBooks( + intent.books, + intent.downloadAllChapters + ) + + CacheIntent.StopDownload -> CacheBook.stop(context) + is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book) + is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl) + is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book) + is CacheIntent.SetExportUseReplace -> { + cacheConfig.exportUseReplace = intent.enabled + syncExportConfig() + val msg = if (intent.enabled) "替换净化功能已开启" else "替换净化功能已关闭" + _effects.tryEmit(CacheEffect.ShowMessage(msg)) + } + + is CacheIntent.SetEnableCustomExport -> { + cacheConfig.enableCustomExport = intent.enabled + syncExportConfig() + } + + is CacheIntent.SetExportNoChapterName -> { + cacheConfig.exportNoChapterName = intent.enabled + syncExportConfig() + } + + is CacheIntent.SetExportToWebDav -> { + cacheConfig.exportToWebDav = intent.enabled + syncExportConfig() + } + + is CacheIntent.SetExportPictureFile -> { + cacheConfig.exportPictureFile = intent.enabled + syncExportConfig() + } + + is CacheIntent.SetParallelExportBook -> { + cacheConfig.parallelExportBook = intent.enabled + syncExportConfig() + } + + is CacheIntent.SetExportType -> { + cacheConfig.exportType = intent.type + syncExportConfig() + } + + is CacheIntent.SetExportCharset -> { + cacheConfig.exportCharset = intent.charset + syncExportConfig() + } + + is CacheIntent.SetBookExportFileName -> { + cacheConfig.bookExportFileName = intent.fileName + syncExportConfig() + } + + is CacheIntent.SetEpisodeExportFileName -> { + cacheConfig.episodeExportFileName = intent.fileName + syncExportConfig() + } + } + } + + fun getCacheChapters(bookUrl: String): Set? = cacheChapters[bookUrl] + + fun getAllCacheChapters(): Map> = cacheChapters + + fun isBookDownloading(bookUrl: String): Boolean { + return CacheBook.cacheBookMap[bookUrl]?.isStop() == false + } + + private fun initialize(groupId: Long) { + _uiState.update { it.copy(groupId = groupId) } + syncExportConfig() + observeGroups() + observeBooks(groupId) + observeDownloadAndExportChanges() + refreshGroupName(groupId) + } + + private fun changeGroup(groupId: Long) { + _uiState.update { it.copy(groupId = groupId) } + observeBooks(groupId) + refreshGroupName(groupId) + } + + private fun observeGroups() { + groupsJob?.cancel() + groupsJob = viewModelScope.launch { + bookGroupDao.flowAll().collect { groups -> + _uiState.update { it.copy(groupList = groups) } + } + } + } + + private fun observeBooks(groupId: Long) { + booksJob?.cancel() + booksJob = viewModelScope.launch { + bookDao.flowByGroup(groupId).map { books -> + val booksDownload = books.filter { !it.isAudio } + when (cacheConfig.getBookSortByGroupId(groupId)) { + 1 -> booksDownload.sortedByDescending { it.latestChapterTime } + 2 -> booksDownload.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } + 3 -> booksDownload.sortedBy { it.order } + 4 -> booksDownload.sortedByDescending { + max(it.latestChapterTime, it.durChapterTime) + } + + else -> booksDownload.sortedByDescending { it.durChapterTime } + } + }.collect { books -> + _uiState.update { it.copy(books = books) } + loadCacheFiles(books) + } + } + } + + private fun observeDownloadAndExportChanges() { + if (observersStarted) return + observersStarted = true + viewModelScope.launch { + CacheBook.cacheSuccessFlow.collect { chapter -> + onChapterCached(chapter) + } + } + viewModelScope.launch { + CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) -> + syncDownloadRunning() + if (bookUrl.isNotEmpty()) { + emitBookChanged(bookUrl) + } + } + } + viewModelScope.launch { + CacheBook.downloadErrorFlow.collect { (bookUrl, _) -> + syncDownloadRunning() + if (bookUrl.isNotEmpty()) { + emitBookChanged(bookUrl) + } + } + } + viewModelScope.launch { + CacheBook.downloadSummaryFlow.collect { + syncDownloadRunning() + } + } + viewModelScope.launch { + ExportBookService.exportBookUpdateFlow.collect { bookUrl -> + emitBookChanged(bookUrl) + } + } + } + + private fun syncExportConfig() { + _uiState.update { + it.copy( + exportConfig = CacheExportConfig( + exportUseReplace = cacheConfig.exportUseReplace, + enableCustomExport = cacheConfig.enableCustomExport, + exportNoChapterName = cacheConfig.exportNoChapterName, + exportToWebDav = cacheConfig.exportToWebDav, + exportPictureFile = cacheConfig.exportPictureFile, + parallelExportBook = cacheConfig.parallelExportBook, + exportType = cacheConfig.exportType, + exportCharset = cacheConfig.exportCharset, + bookExportFileName = cacheConfig.bookExportFileName, + episodeExportFileName = cacheConfig.episodeExportFileName + ) + ) + } + } + + private fun syncDownloadRunning() { + _uiState.update { it.copy(isDownloadRunning = CacheBook.isRun) } + } + + private fun refreshGroupName(groupId: Long) { + execute { + val title = bookGroupDao.getByID(groupId)?.groupName + title ?: context.getString(io.legado.app.R.string.no_group) + }.onSuccess { groupName -> + _uiState.update { it.copy(groupName = groupName) } + } + } + + private fun loadCacheFiles(books: List) { + execute { books.forEach { book -> if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) { val chapterCaches = hashSetOf() val cacheNames = BookHelp.getChapterFiles(book) if (cacheNames.isNotEmpty()) { - appDb.bookChapterDao.getChapterList(book.bookUrl).also { + bookChapterDao.getChapterList(book.bookUrl).also { book.totalChapterNum = it.size }.forEach { chapter -> if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) { @@ -35,17 +302,51 @@ class CacheViewModel(application: Application) : BaseViewModel(application) { } } cacheChapters[book.bookUrl] = chapterCaches - upAdapterLiveData.sendValue(book.bookUrl) + emitBookChanged(book.bookUrl) } ensureActive() } } } - fun clearCacheForBook(book: Book) { - BookHelp.clearCache(book) - cacheChapters[book.bookUrl] = hashSetOf() - upAdapterLiveData.sendValue(book.bookUrl) + private fun onChapterCached(chapter: BookChapter) { + val bookUrl = chapter.bookUrl + val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() } + chapterSet.add(chapter.url) + emitBookChanged(bookUrl) } -} \ No newline at end of file + private fun startDownloadForVisibleBooks(books: List, downloadAllChapters: Boolean) { + books.forEach { book -> + val indices = if (downloadAllChapters) { + (0..book.lastChapterIndex).toList() + } else { + (book.durChapterIndex..book.lastChapterIndex).toList() + } + CacheBook.start(context, book, indices) + } + syncDownloadRunning() + } + + private fun toggleBookDownload(book: Book) { + if (book.isLocal) return + if (isBookDownloading(book.bookUrl)) { + CacheBook.remove(context, book.bookUrl) + } else { + CacheBook.start(context, book, (0..book.lastChapterIndex).toList()) + } + syncDownloadRunning() + } + + private fun clearCacheForBook(book: Book) { + BookHelp.clearCache(book) + cacheChapters[book.bookUrl] = hashSetOf() + emitBookChanged(book.bookUrl) + } + + private fun emitBookChanged(bookUrl: String) { + _uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) } + _effects.tryEmit(CacheEffect.NotifyBookChanged(bookUrl)) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt index f99ac2406..74075ed78 100644 --- a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt @@ -12,8 +12,8 @@ import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook -import io.legado.app.help.config.AppConfig import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.mapParallelSafe import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExecutorCoroutineDispatcher @@ -36,7 +36,7 @@ import java.util.concurrent.Executors import kotlin.math.min class ChangeCoverViewModel(application: Application) : BaseViewModel(application) { - private val threadCount = AppConfig.threadCount + private val threadCount = OtherConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null private var searchSuccess: ((SearchBook) -> Unit)? = null private var upAdapter: (() -> Unit)? = null 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 2f8a37956..c101be7be 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 @@ -25,6 +25,7 @@ import io.legado.app.help.config.SourceConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.source.SourceHelp import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.internString import io.legado.app.utils.mapParallel import io.legado.app.utils.mapParallelSafe @@ -59,7 +60,7 @@ import kotlin.math.min @Suppress("MemberVisibilityCanBePrivate") open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) { - private val threadCount = AppConfig.threadCount + private val threadCount = OtherConfig.threadCount private var searchPool: ExecutorCoroutineDispatcher? = null val searchStateData = MutableLiveData() private val _isSearching = MutableStateFlow(false) diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt index cccdedd7d..d35acce89 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt @@ -28,6 +28,7 @@ import io.legado.app.model.ReadBook import io.legado.app.model.ReadManga import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.postEvent @@ -201,7 +202,7 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application) }.onStart { // 自动换源 - }.mapParallelSafe(AppConfig.threadCount) { source -> + }.mapParallelSafe(OtherConfig.threadCount) { source -> val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) @@ -395,4 +396,4 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application) context.toastOnUi("添加书籍失败") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt index 00a3aab5c..a3e5911d5 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -31,6 +31,7 @@ import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.postEvent @@ -307,7 +308,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) { } }.onStart { ReadBook.upMsg(context.getString(R.string.source_auto_changing)) - }.mapParallelSafe(AppConfig.threadCount) { source -> + }.mapParallelSafe(OtherConfig.threadCount) { source -> val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() if (book.tocUrl.isEmpty()) { WebBook.getBookInfoAwait(source, book) diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 096c10c99..3ed0dc4e8 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -242,20 +242,15 @@ fun SearchScreen( onSearch = submitSearch, placeholder = searchLabel, trailingIcon = { - Row( - modifier = Modifier.padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (queryInput.isNotEmpty()) { - TopBarActionButton( - onClick = { - queryInput = "" - viewModel.onIntent(SearchIntent.UpdateQuery("")) - }, - imageVector = AppIcons.Close, - contentDescription = stringResource(R.string.clear) - ) - } + if (queryInput.isNotEmpty()) { + TopBarActionButton( + onClick = { + queryInput = "" + viewModel.onIntent(SearchIntent.UpdateQuery("")) + }, + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.clear) + ) } }, ) diff --git a/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt b/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt new file mode 100644 index 000000000..121cdba97 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt @@ -0,0 +1,36 @@ +package io.legado.app.ui.config.cacheConfig + +import io.legado.app.constant.PreferKey +import io.legado.app.data.dao.BookGroupDao +import io.legado.app.ui.config.prefDelegate + +class CacheConfig( + private val bookGroupDao: BookGroupDao +) { + + var bookExportFileName by prefDelegate(PreferKey.bookExportFileName, null) + + var episodeExportFileName by prefDelegate(PreferKey.episodeExportFileName, "") + + var exportCharset by prefDelegate(PreferKey.exportCharset, "UTF-8") + + var exportUseReplace by prefDelegate(PreferKey.exportUseReplace, true) + + var exportToWebDav by prefDelegate(PreferKey.exportToWebDav, false) + + var exportNoChapterName by prefDelegate(PreferKey.exportNoChapterName, false) + + var enableCustomExport by prefDelegate(PreferKey.enableCustomExport, false) + + var exportType by prefDelegate(PreferKey.exportType, 0) + + var exportPictureFile by prefDelegate(PreferKey.exportPictureFile, false) + + var parallelExportBook by prefDelegate(PreferKey.parallelExportBook, false) + + var bookshelfSort by prefDelegate(PreferKey.bookshelfSort, 0) + + fun getBookSortByGroupId(groupId: Long): Int { + return bookGroupDao.getByID(groupId)?.getRealBookSort() ?: bookshelfSort + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index d112bd6fd..b28f75885 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -13,9 +13,13 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -37,8 +41,14 @@ import io.legado.app.lib.dialogs.alert import io.legado.app.service.WebService import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.UpdateDialog +import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.import.local.ImportBookScreen import io.legado.app.ui.book.import.remote.RemoteBookScreen +import io.legado.app.ui.book.search.SearchIntent +import io.legado.app.ui.book.search.SearchScreen +import io.legado.app.ui.book.search.SearchViewModel +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.book.cache.CacheRouteScreen import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.config.ConfigNavScreen import io.legado.app.ui.config.ConfigTag @@ -62,6 +72,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -82,8 +93,13 @@ open class MainActivity : BaseComposeActivity() { private const val ROUTE_SETTINGS_BACKUP = "settings/backup" private const val ROUTE_IMPORT_LOCAL = "import/local" private const val ROUTE_IMPORT_REMOTE = "import/remote" + private const val ROUTE_CACHE = "cache" + private const val ROUTE_SEARCH = "search" private const val ROUTE_RSS_SORT = "rss/sort" private const val ROUTE_RSS_READ = "rss/read" + private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id" + private const val EXTRA_SEARCH_KEY = "extra_search_key" + private const val EXTRA_SEARCH_SCOPE = "extra_search_scope" private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url" private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url" @@ -146,6 +162,28 @@ open class MainActivity : BaseComposeActivity() { } } + fun createCacheIntent( + context: Context, + groupId: Long = -1L + ): Intent { + return createLauncherIntent(context).apply { + putExtra(EXTRA_START_ROUTE, ROUTE_CACHE) + putExtra(EXTRA_CACHE_GROUP_ID, groupId) + } + } + + fun createSearchIntent( + context: Context, + key: String? = null, + scopeRaw: String? = null + ): Intent { + return createLauncherIntent(context).apply { + putExtra(EXTRA_START_ROUTE, ROUTE_SEARCH) + putExtra(EXTRA_SEARCH_KEY, key) + putExtra(EXTRA_SEARCH_SCOPE, scopeRaw) + } + } + private fun routeForConfigTag(configTag: String?): String { return when (configTag) { ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER @@ -191,6 +229,15 @@ open class MainActivity : BaseComposeActivity() { @Serializable private data object MainRouteImportRemote : MainRoute + @Serializable + private data class MainRouteCache(val groupId: Long) : MainRoute + + @Serializable + private data class MainRouteSearch( + val key: String?, + val scopeRaw: String? = null + ) : MainRoute + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -332,12 +379,23 @@ open class MainActivity : BaseComposeActivity() { onOpenSettings = { navigateToRoute(backStack, MainRouteSettings) }, + onNavigateToSearch = { key -> + navigateToRoute( + backStack, + MainRouteSearch( + key = key?.trim()?.takeIf { it.isNotEmpty() } + ) + ) + }, onNavigateToRemoteImport = { navigateToRoute(backStack, MainRouteImportRemote) }, onNavigateToLocalImport = { navigateToRoute(backStack, MainRouteImportLocal) }, + onNavigateToCache = { groupId -> + navigateToRoute(backStack, MainRouteCache(groupId)) + }, onNavigateToRssSort = { sourceUrl, sortUrl, key -> navigateToRoute( backStack, @@ -405,6 +463,64 @@ open class MainActivity : BaseComposeActivity() { ) } + entry { route -> + CacheRouteScreen( + groupId = route.groupId, + onBackClick = { navigateBack(backStack) } + ) + } + + entry { route -> + val searchViewModel = koinViewModel() + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(route.key, route.scopeRaw, searchViewModel) { + searchViewModel.onIntent( + SearchIntent.Initialize( + key = route.key, + scopeRaw = route.scopeRaw + ) + ) + } + + DisposableEffect(lifecycleOwner, searchViewModel) { + searchViewModel.onIntent(SearchIntent.ResumeEngine) + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + searchViewModel.onIntent(SearchIntent.ResumeEngine) + } + + Lifecycle.Event.ON_PAUSE -> { + searchViewModel.onIntent(SearchIntent.PauseEngine) + } + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + searchViewModel.onIntent(SearchIntent.PauseEngine) + } + } + + SearchScreen( + viewModel = searchViewModel, + onBack = { navigateBack(backStack) }, + onOpenBookInfo = { name, author, bookUrl -> + this@MainActivity.startActivity { + putExtra("name", name) + putExtra("author", author) + putExtra("bookUrl", bookUrl) + } + }, + onOpenSourceManage = { + this@MainActivity.startActivity() + } + ) + } + entry { route -> RssSortRouteScreen( sourceUrl = route.sourceUrl, @@ -469,7 +585,18 @@ open class MainActivity : BaseComposeActivity() { } MainRouteImportLocal, - MainRouteImportRemote -> { + MainRouteImportRemote, + is MainRouteCache -> { + if (currentRoute == MainRouteHome) { + backStack.add(route) + } else { + backStack.clear() + backStack.add(MainRouteHome) + backStack.add(route) + } + } + + is MainRouteSearch -> { if (currentRoute == MainRouteHome) { backStack.add(route) } else { @@ -660,15 +787,20 @@ open class MainActivity : BaseComposeActivity() { private fun resolveStartRoute(route: String?): MainRoute { return when (route) { - "main" -> MainRouteHome - "settings" -> MainRouteSettings - "settings/other" -> MainRouteSettingsOther - "settings/read" -> MainRouteSettingsRead - "settings/cover" -> MainRouteSettingsCover - "settings/theme" -> MainRouteSettingsTheme - "settings/backup" -> MainRouteSettingsBackup - "import/local" -> MainRouteImportLocal - "import/remote" -> MainRouteImportRemote + ROUTE_MAIN -> MainRouteHome + ROUTE_SETTINGS -> MainRouteSettings + ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther + ROUTE_SETTINGS_READ -> MainRouteSettingsRead + ROUTE_SETTINGS_COVER -> MainRouteSettingsCover + ROUTE_SETTINGS_THEME -> MainRouteSettingsTheme + ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup + ROUTE_IMPORT_LOCAL -> MainRouteImportLocal + ROUTE_IMPORT_REMOTE -> MainRouteImportRemote + ROUTE_CACHE -> MainRouteCache(intent?.getLongExtra(EXTRA_CACHE_GROUP_ID, -1L) ?: -1L) + ROUTE_SEARCH -> MainRouteSearch( + key = intent?.getStringExtra(EXTRA_SEARCH_KEY), + scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE) + ) else -> MainRouteHome } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 3e8697bbf..fe7bae282 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -57,7 +57,6 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import io.legado.app.R import io.legado.app.ui.book.info.BookInfoActivity -import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfViewModel @@ -90,8 +89,10 @@ fun MainScreen( viewModel: MainViewModel = koinViewModel(), useRail: Boolean, onOpenSettings: () -> Unit, + onNavigateToSearch: (String?) -> Unit, onNavigateToRemoteImport: () -> Unit, onNavigateToLocalImport: () -> Unit, + onNavigateToCache: (Long) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit ) { @@ -174,7 +175,7 @@ fun MainScreen( ExtendedFloatingActionButton( modifier = Modifier.padding(start = 20.dp), - onClick = { context.startActivity() }, + onClick = { onNavigateToSearch(null) }, expanded = expanded, icon = { Icon(Icons.Default.Search, contentDescription = null) }, text = { AppText(stringResource(R.string.search)) } @@ -377,8 +378,10 @@ fun MainScreen( putExtra("bookUrl", book.bookUrl) } }, + onNavigateToSearch = { query -> onNavigateToSearch(query) }, onNavigateToRemoteImport = onNavigateToRemoteImport, - onNavigateToLocalImport = onNavigateToLocalImport + onNavigateToLocalImport = onNavigateToLocalImport, + onNavigateToCache = onNavigateToCache ) MainDestination.Explore -> ExploreScreen() 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 883dbc880..34278737f 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 @@ -1,9 +1,10 @@ package io.legado.app.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.ThemeEngine.getColorScheme @@ -11,20 +12,41 @@ import io.legado.app.ui.theme.ThemeEngine.getColorScheme @Composable fun rememberOpaqueColorScheme(): ColorScheme { val context = LocalContext.current - val isDark = isSystemInDarkTheme() + val currentTheme = LocalLegadoThemeColors.current val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme) + val isDark = currentTheme.isDark val isPureBlack = ThemeConfig.isPureBlack val hasImageBg = ThemeConfig.hasImageBg(isDark) val paletteStyle = ThemeConfig.paletteStyle + val materialVersion = ThemeConfig.materialVersion + val seedColorInt = currentTheme.seedColor + .takeUnless { it == Color.Unspecified } + ?.toArgb() - return remember(context, appThemeMode, isDark, isPureBlack, hasImageBg, paletteStyle) { - getColorScheme( - context = context, - mode = appThemeMode, - darkTheme = isDark, - isAmoled = isPureBlack, - paletteStyle = paletteStyle, - forceOpaque = true - ) + return remember( + context, + currentTheme.colorScheme, + appThemeMode, + isDark, + isPureBlack, + hasImageBg, + paletteStyle, + materialVersion, + seedColorInt + ) { + if (appThemeMode != AppThemeMode.Transparent) { + currentTheme.colorScheme + } else { + getColorScheme( + context = context, + mode = appThemeMode, + darkTheme = isDark, + isAmoled = isPureBlack, + paletteStyle = paletteStyle, + materialVersion = materialVersion, + forceOpaque = true, + customSeedColor = seedColorInt + ) + } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt index 19d9399ef..a67fd5221 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextButton @@ -81,3 +82,57 @@ fun SmallTextButton( } } } + +@Composable +fun SmallTonalTextButton( + text: String, + imageVector: ImageVector, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + Card( + onClick = onClick, + modifier = modifier, + showIndication = true, + colors = CardDefaults.defaultColors( + color = LegadoTheme.colorScheme.surfaceContainer, + contentColor = LegadoTheme.colorScheme.onSurfaceVariant + ) + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + MiuixIcon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + AppText( + text = text, + style = LegadoTheme.typography.labelMedium + ) + } + } + } else { + FilledTonalButton( + onClick = onClick, + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + AppText( + text = text, + style = LegadoTheme.typography.labelMedium + ) + } + } +} 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 7b44b6c74..5f20daa6c 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme @@ -70,7 +71,9 @@ fun TextCard( AnimatedTextLine( text = text, style = textStyle, - color = finalContentColor + color = finalContentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt index bc682832b..46d95f15e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt @@ -48,6 +48,8 @@ fun ListScaffold( showSearchAction: Boolean = true, onSearchToggle: (Boolean) -> Unit, onSearchQueryChange: (String) -> Unit, + onSearchSubmit: (String) -> Unit = {}, + searchTrailingIcon: @Composable (() -> Unit)? = null, searchPlaceholder: String = "搜索...", topBarActions: @Composable RowScope.() -> Unit = {}, bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null, @@ -93,6 +95,8 @@ fun ListScaffold( showSearchAction = showSearchAction, onSearchToggle = onSearchToggle, onSearchQueryChange = onSearchQueryChange, + onSearchSubmit = onSearchSubmit, + searchTrailingIcon = searchTrailingIcon, searchPlaceholder = searchPlaceholder, onClearSelection = { selectionActions?.onClearSelection?.invoke() }, topBarActions = topBarActions, 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 234234f05..e6c46ce79 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 @@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme @@ -86,3 +89,68 @@ fun RoundDropdownMenu( } } } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RoundDropdownMenuLazy( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.medium, + shadowElevation: Dp = 4.dp, + verticalSpacing: Dp = 8.dp, + maxHeight: Dp = 320.dp, + content: LazyListScope.(dismiss: () -> Unit) -> Unit +) { + val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) + val popupContainerColor = LegadoTheme.colorScheme.surfaceContainer + + if (isMiuix) { + val popupContentColor = LegadoTheme.colorScheme.onSurface + WindowListPopup( + show = expanded, + onDismissRequest = onDismissRequest, + popupModifier = modifier + ) { + ProvideAppContentColor(popupContentColor) { + ListPopupColumn { + LazyColumn( + modifier = Modifier + .background(popupContainerColor) + .heightIn(max = maxHeight), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + item { Spacer(Modifier.height(12.dp)) } + content(onDismissRequest) + item { Spacer(Modifier.height(12.dp)) } + } + } + } + } + } else { + val colorScheme = rememberOpaqueColorScheme() + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier, + shape = shape, + shadowElevation = shadowElevation, + containerColor = colorScheme.surfaceContainerLow + ) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = Typography(), + motionScheme = MotionScheme.expressive(), + shapes = Shapes() + ) { + LazyColumn( + modifier = Modifier.heightIn(max = maxHeight), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + content(onDismissRequest) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt index 6d1fad1b1..4fd61fe0f 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.widget.components.SearchBar +import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.button.TopBarActionButton import io.legado.app.ui.widget.components.button.TopBarNavigationButton import io.legado.app.ui.widget.components.icon.AppIcons @@ -41,6 +42,7 @@ fun DynamicTopAppBar( showSearchAction: Boolean = true, onSearchToggle: (Boolean) -> Unit, onSearchQueryChange: (String) -> Unit, + onSearchSubmit: (String) -> Unit = {}, searchPlaceholder: String, searchLeadingIcon: ImageVector = Icons.Default.Search, searchTrailingIcon: @Composable (() -> Unit)? = null, @@ -114,7 +116,14 @@ fun DynamicTopAppBar( SearchBar( query = state.searchKey, onQueryChange = onSearchQueryChange, + onSearch = onSearchSubmit, placeholder = searchPlaceholder, + leadingIcon = { + AppIcon( + imageVector = searchLeadingIcon, + contentDescription = null + ) + }, trailingIcon = searchTrailingIcon, dropdownMenu = searchDropdownMenu )