Merge origin/main into feature/private-group

Resolve conflict in BookInfoSheets.kt: keep MediumPlainButton style from main
while preserving showAddGroup logic from PR for correct new group creation.
This commit is contained in:
HapeLee
2026-05-27 01:10:06 +08:00
129 changed files with 6295 additions and 1914 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Deploy Docs
on:
push:
branches: [ main ]
paths: [ 'docs/**' ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: docs/package-lock.json
- run: cd docs && npm ci
- run: cd docs && npx vitepress build
- uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+222 -1
View File
@@ -138,7 +138,228 @@ Legacy View-based theme still exists in `lib/theme/` (used by non-migrated scree
The app is mid-migration from Views to Compose. View-based screens (reader, book info, source management) coexist with Compose screens (main tabs, settings, search, RSS, cache management). XML layouts, `viewBinding`, and traditional Activities are still heavily used. The `viewBinding` build feature is enabled but Compose screens are the target.
Compose code follows **MVI/UDF**: ViewModel owns `StateFlow`/`SharedFlow` state, Screen observes and emits user actions, no business logic in composables. For detailed Compose review conventions and migration patterns, see `.claude/skills/legado-compose-review/`.
## Jetpack Compose Requirements (new screens MUST follow)
All **new** UI screens must be implemented in Jetpack Compose following the patterns below. Do **not
** create new View-based Activities/Fragments/XML layouts. Existing View-based screens can remain
until migrated.
### MVI/UDF Architecture
Every Compose screen follows a strict **Model-View-Intent** pattern with three artifacts defined in
a `*Contract.kt` file:
```
ui/{feature}/
├── XxxContract.kt // UiState, Intent, Effect (and optionally Sheet/Dialog)
├── XxxViewModel.kt // ViewModel
├── XxxScreen.kt // Screen composable
└── XxxRouteScreen.kt // (optional) outer wrapper for activity results / lifecycle
```
**Contract definitions:**
```kotlin
// @Stable data class — all screen state in one place
@Stable
data class XxxUiState(
val loading: Boolean = false,
val items: ImmutableList<ItemUi> = persistentListOf(),
val activeSheet: XxxSheet? = null,
val activeDialog: XxxDialog? = null,
)
// sealed interface — every user action is an Intent
sealed interface XxxIntent {
data class LoadData(val id: Long) : XxxIntent
data object Refresh : XxxIntent
}
// sealed interface — one-shot side effects (navigation, toast, etc.)
sealed interface XxxEffect {
data class ShowToast(val message: String) : XxxEffect
data class NavigateTo(val route: MainRoute) : XxxEffect
}
// (optional) sealed interface for multi-sheet/dialog scenarios
sealed interface XxxSheet { data object Filter : XxxSheet }
sealed interface XxxDialog { data class Confirm(val msg: String) : XxxDialog }
```
**Naming rules:**
- State: `{Feature}UiState``@Stable data class`
- Intent: `{Feature}Intent``sealed interface` with `data class` / `data object` members
- Effect: `{Feature}Effect``sealed interface`
- Sheet/Dialog: `{Feature}Sheet`, `{Feature}Dialog``sealed interfaces` stored in UiState
### ViewModel
```kotlin
class XxxViewModel(/* injected dependencies */) : ViewModel() {
private val _uiState = MutableStateFlow(XxxUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<XxxEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
fun onIntent(intent: XxxIntent) {
when (intent) {
is XxxIntent.LoadData -> loadData(intent.id)
is XxxIntent.Refresh -> refresh()
}
}
private fun loadData(id: Long) {
// Use viewModelScope, update _uiState via update { it.copy(...) }
}
}
```
Key rules:
- Extend `ViewModel()` directly (not `BaseViewModel`).
- `_uiState` is `MutableStateFlow`, exposed as `StateFlow` via `.asStateFlow()`.
- `_effects` is `MutableSharedFlow(extraBufferCapacity = 16)`, exposed via `.asSharedFlow()`.
- Emit effects via `_effects.tryEmit(...)`.
- Single `onIntent()` entry point, dispatched via `when`.
### Screen Composable
```kotlin
// Stateless screen — ViewModel wired in entry provider or RouteScreen
@Composable
fun XxxScreen(
state: XxxUiState,
onIntent: (XxxIntent) -> Unit,
effects: Flow<XxxEffect>, // one-shot effects from ViewModel
onBack: () -> Unit,
onNavigateToYyy: (YyyRoute) -> Unit,
) {
// Collect effects
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is XxxEffect.ShowToast -> { /* ... */ }
is XxxEffect.NavigateTo -> onNavigateToYyy(effect.route)
}
}
}
AppScaffold(
topBar = {
GlassMediumFlexibleTopAppBar(
title = { Text("Title") },
scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior(),
navigationButton = { TopBarNavigationButton(onBack) },
)
},
) { contentPadding ->
// UI content, no business logic here
}
}
```
Key rules:
- Screen is **stateless** — receives `state`, `onIntent`, `effects`, never accesses ViewModel
directly.
- Effects collected in `LaunchedEffect(Unit) { ... }` using `collectLatest`.
- Alternatively, effects can be collected in the outer `RouteScreen` or entry provider if the screen
doesn't need them directly.
- Use project custom widgets: `AppScaffold`, `AppText`, `AppIcon`, `AppIcons`, `AppAlertDialog`,
`AppModalBottomSheet`, `NormalCard`, `GlassMediumFlexibleTopAppBar`, `TopBarNavigationButton`,
`TopBarActionButton`, etc.
- No business logic, no direct DB/network calls in composables.
Two input patterns are acceptable:
- **Stateless (preferred for new screens):** `state: XxxUiState` + `onIntent: (XxxIntent) -> Unit`
ViewModel wired in entry provider or RouteScreen.
- **ViewModel as default param:** `viewModel: XxxViewModel = koinViewModel()` — simpler for
standalone screens.
### Stability
- All `UiState` and UI item data classes **must** be annotated with `@Stable`.
- Use `ImmutableList` (from `kotlinx.collections.immutable`) for list properties in state classes,
not `List` or `MutableList`.
- Prefer `persistentListOf()` / `toImmutableList()` for default values.
### Navigation
Uses **Navigation 3** (`androidx.navigation3`). Routes are `@Serializable` sealed interfaces:
```kotlin
// In MainNavKey.kt
@Serializable
data class MainRouteXxx(val id: Long) : MainRoute
```
Entry registered in `MainNavGraph.kt`:
```kotlin
entry<MainRouteXxx> { route ->
val viewModel = koinViewModel<XxxViewModel>()
XxxScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
onBack = { onNavigateBack() },
onNavigateToYyy = { onNavigateToRoute(it) },
)
}
```
Key rules:
- Screens **never** reference the navigator directly — receive `onBack`, `onNavigateToXxx` lambdas.
- Navigation is callback-based, wired by the entry provider.
- New routes added to the `MainRoute` sealed interface in `MainNavKey.kt`.
### Koin DI
- Register ViewModels in `di/appModule.kt` with `viewModelOf(::XxxViewModel)`.
- Inject in Compose via `koinViewModel()` (default param or explicit in entry provider).
- For keyed ViewModels (e.g. per-book): `koinViewModel<XxxViewModel>(key = route.bookUrl)`.
- Repositories/gateways/use cases registered as `singleOf(::...)`.
### Activity Base Class
New standalone Compose activities extend `BaseComposeActivity`:
```kotlin
class XxxActivity : BaseComposeActivity() {
@Composable
override fun Content() {
// Screen content — AppTheme is already applied by the base class
}
}
```
### RouteScreen Wrapper
For screens needing activity result handling, lifecycle observation, or permission requests, use a
two-layer pattern:
- Outer `XxxRouteScreen`: handles `ActivityResultLauncher`, lifecycle callbacks, file pickers,
permission requests. Wires ViewModel.
- Inner `XxxScreen`: pure UI, stateless with `state` + `onIntent`.
### Material 3 vs Miuix
The project supports two Compose theme engines. If a screen needs engine-specific UI, branch on:
```kotlin
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
// Miuix implementation
} else {
// Material 3 implementation
}
```
For detailed Compose review conventions and migration patterns, see
`.claude/skills/legado-compose-review/`.
## Rhino JavaScript Engine
+68 -14
View File
@@ -1,14 +1,68 @@
* 阅读是一款解析指定规则并获取内容的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。
* 当您搜索一本书的时,阅读会您所使用的规则将该书的书名以关键词的形式提交到各个第三方网络文学网站。
各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。
任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。
第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。
您应该对使用搜索引擎的结果自行承担风险。
* 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。
因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。
阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。
* 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。
阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。
阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。
任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明
阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。
---
# 用户协议与免责声明
## 【特别提示】
在下载、安装或使用本软件前,请您务必仔细阅读并充分理解本协议及免责声明的全部内容。您一旦下载、安装或使用本软件,即视为已阅读、理解并同意接受本声明的全部内容。
---
# 一、软件性质说明
1. 本软件是一款用户可配置的本地化网页内容浏览工具,提供网页访问、内容解析、文本提取、阅读排版与数据管理等技术功能
2. 本软件默认状态下不预置、不内置、不提供任何第三方网站内容、数据资源或解析规则。
3. 本软件开发者不提供任何内容运营、内容存储、内容发布或内容传播服务。
4. 用户可基于自身需求,自行配置或导入第三方规则,以实现对公开网页内容的个性化浏览与处理。
---
# 二、用户行为与规则说明
1. 用户可自行创建、编辑、导入或使用第三方分享的解析规则(以下简称“规则”)。
2. 相关规则仅用于定义网页内容的获取、提取与显示方式,其来源、合法性、准确性与适用性均由用户自行判断并承担责任。
3. 用户使用规则访问第三方网站时,相关网络请求由用户设备直接向目标网站发起并接收数据。本软件仅提供本地解析与展示能力,不对第三方网站内容进行修改、编辑或再分发。
4. 用户应遵守所在地法律法规、网络安全要求以及相关网站的服务协议与版权规范,不得利用本软件从事侵犯知识产权、非法传播、未授权数据获取、破坏网络服务或其他违法违规行为。
---
# 三、第三方内容与社区说明
1. 任何由第三方建立或维护的规则分享平台、论坛、交流群组、网站或其他社区,均属于独立运营的第三方平台,与本软件开发者无隶属关系。
2. 开发者不参与第三方规则、内容或社区的制作、发布、运营、维护与传播行为,亦不对相关内容承担主动审查义务。
3. 用户因使用第三方规则或访问第三方网站而产生的风险,包括但不限于版权纠纷、数据安全风险、网络访问风险或其他法律风险,应由相关行为主体依法承担相应责任。
---
# 四、隐私与数据说明
1. 本软件主要功能运行于用户本地设备,不设立用于提供网页内容服务的自有内容服务器。
2. 本软件不会主动收集、上传或存储用户的阅读内容、规则列表、浏览记录或其他个人隐私数据。
3. 为提升软件稳定性与兼容性,本软件可能集成第三方统计或崩溃分析服务(如 Firebase Crashlytics
等),用于收集匿名化的崩溃日志、性能信息及基础设备信息。
4. 部分网络、存储或同步权限,仅用于实现用户主动启用的本地备份、WebDAV 同步或跨设备数据同步等功能。
---
# 五、知识产权保护
1. 开发者尊重并保护知识产权权利人的合法权益,并反对任何侵犯著作权、商标权或其他合法权益的行为。
2. 用户应确保其使用本软件获取、处理或访问相关内容的行为符合适用法律法规及权利要求。
3. 若权利人认为某些第三方规则存在涉嫌侵权情形,可依法向相关内容实际托管方主张权利。
4. 权利人亦可向开发者提交包含身份证明、权属证明、具体规则信息及相关说明的有效通知。开发者将在合理技术能力范围内,对涉嫌违规规则采取必要处理措施。
---
@@ -4,10 +4,13 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name = "local_ui_status")
object LocalPreferencesKeys {
val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode")
val MATCH_MODE = intPreferencesKey("match_mode")
}
@@ -80,9 +80,10 @@ class ExploreRepositoryImpl(
override suspend fun exploreBooks(
bookSource: BookSource,
url: String,
page: Int
page: Int,
key: String?
): List<SearchBook> {
return WebBook.exploreBookSuspend(bookSource, url, page)
return WebBook.exploreBookSuspend(bookSource, url, page, key = key, isSearch = key != null)
}
override suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind> = withContext(IO) {
@@ -5,5 +5,10 @@ import io.legado.app.data.entities.SearchBook
interface ExploreBooksGateway {
suspend fun getBookSource(sourceUrl: String): BookSource?
suspend fun exploreBooks(bookSource: BookSource, url: String, page: Int): List<SearchBook>
suspend fun exploreBooks(
bookSource: BookSource,
url: String,
page: Int,
key: String? = null
): List<SearchBook>
}
@@ -0,0 +1,10 @@
package io.legado.app.domain.model
enum class MatchMode(val value: Int) {
DEFAULT(0),
EXACT(1);
companion object {
fun of(value: Int) = entries.getOrElse(value) { DEFAULT }
}
}
@@ -20,14 +20,17 @@ class ExploreBooksUseCase(
sourceUrl: String,
moduleUrl: String?,
args: String?,
page: Int = 1
page: Int = 1,
key: String? = null,
): ExploreResult = withContext(Dispatchers.IO) {
val base = gateway.getBookSource(sourceUrl)
?: throw SourceNotFound(sourceUrl)
val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
val resolvedUrl = moduleUrl ?: source.exploreUrl
?: throw NoExploreUrl(sourceUrl)
val books = gateway.exploreBooks(source, resolvedUrl, page)
val resolvedUrl = moduleUrl
?: (if (key != null) source.searchUrl else null)
?: source.exploreUrl
?: throw NoExploreUrl(sourceUrl)
val books = gateway.exploreBooks(source, resolvedUrl, page, key = key)
ExploreResult(resolvedUrl, books)
}
@@ -6,6 +6,7 @@ import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CancellationException
@@ -30,7 +31,7 @@ data class BookSearchRequest(
val keyword: String,
val page: Int,
val scope: BookSearchScope,
val precision: Boolean,
val matchMode: MatchMode,
val concurrency: Int,
val types: Set<Int>? = null,
)
@@ -103,7 +104,7 @@ class SearchBooksUseCase(
throw NoStackTraceException("可搜索书源为空")
}
val merger = SearchResultMerger(keyword, request.precision)
val merger = SearchResultMerger(keyword, request.matchMode)
val concurrency = request.concurrency.coerceAtLeast(1)
var hasMore = false
var processedSources = 0
@@ -116,7 +117,7 @@ class SearchBooksUseCase(
.flatMapMerge(concurrency) { searchableSource ->
flow {
control.awaitResumed()
emit(searchSource(searchableSource, keyword, request.page, request.precision))
emit(searchSource(searchableSource, keyword, request.page, request.matchMode))
}.flowOn(Dispatchers.IO)
}
.collect { result ->
@@ -177,7 +178,7 @@ class SearchBooksUseCase(
searchableSource: SearchableSource,
keyword: String,
page: Int,
precision: Boolean,
matchMode: MatchMode,
): SourceSearchResult {
return try {
val source = searchableSource.source
@@ -191,7 +192,7 @@ class SearchBooksUseCase(
keyword,
page,
filter = { name, author ->
!precision ||
matchMode == MatchMode.DEFAULT ||
name.contains(keyword, ignoreCase = true) ||
author.contains(keyword, ignoreCase = true)
}
@@ -222,7 +223,7 @@ class SearchBooksUseCase(
private class SearchResultMerger(
private val keyword: String,
private val precision: Boolean,
private val matchMode: MatchMode,
) {
private companion object {
const val MAX_RETAINED_SEARCH_RESULTS = 1000
@@ -267,9 +268,13 @@ class SearchBooksUseCase(
book.name.equals(keyword, ignoreCase = true) ||
book.author.equals(keyword, ignoreCase = true) -> equalBooks
book.name.contains(keyword, ignoreCase = true) ||
book.author.contains(keyword, ignoreCase = true) -> containsBooks
!precision -> otherBooks
else -> null
book.author.contains(
keyword,
ignoreCase = true
) -> if (matchMode == MatchMode.EXACT) null else containsBooks
matchMode != MatchMode.DEFAULT -> null
else -> otherBooks
}
}
@@ -109,9 +109,12 @@ object WebBook {
url: String,
page: Int? = 1,
ruleData: RuleDataInterface = RuleData(),
key: String? = null,
isSearch: Boolean = false,
): ArrayList<SearchBook> {
val analyzeUrl = AnalyzeUrl(
mUrl = url,
key = key,
page = page,
baseUrl = bookSource.bookSourceUrl,
source = bookSource,
@@ -137,7 +140,7 @@ object WebBook {
analyzeUrl = analyzeUrl,
baseUrl = res.url,
body = res.body,
isSearch = false
isSearch = isSearch
)
}
@@ -146,8 +149,10 @@ object WebBook {
url: String,
page: Int? = 1,
ruleData: RuleDataInterface = RuleData(),
key: String? = null,
isSearch: Boolean = false,
): List<SearchBook> {
return exploreBookAwait(bookSource, url, page, ruleData)
return exploreBookAwait(bookSource, url, page, ruleData, key, isSearch)
}
suspend fun exploreBookWithResolvedUrl(
@@ -40,7 +40,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
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.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.icon.AppIcon
@@ -347,7 +347,7 @@ private fun BookCacheBookCard(
color = LegadoTheme.colorScheme.onSurfaceVariant
)
if (item.hasDownloadTask || item.cachedCount < item.totalCount) {
SmallTonalIconButton(
SmallTonalButton(
onClick = {
if (item.hasActiveDownload) {
onIntent(BookCacheManageIntent.StopBookDownload(item.bookUrl))
@@ -355,7 +355,7 @@ private fun BookCacheBookCard(
onIntent(BookCacheManageIntent.StartBookDownload(item.bookUrl))
}
},
imageVector = if (item.hasActiveDownload) Icons.Default.Stop else Icons.Default.PlayArrow,
icon = if (item.hasActiveDownload) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = when {
item.hasActiveDownload -> "暂停本书下载"
item.isPaused -> "继续本书下载"
@@ -363,9 +363,9 @@ private fun BookCacheBookCard(
}
)
}
SmallTonalIconButton(
SmallTonalButton(
onClick = { onDeleteBook(item) },
imageVector = Icons.Default.Delete,
icon = Icons.Default.Delete,
contentDescription = null
)
}
@@ -412,21 +412,21 @@ private fun BookCacheChapterRow(
)
}
if (item.isWaiting || item.isDownloading) {
SmallTonalIconButton(
SmallTonalButton(
onClick = onStop,
imageVector = Icons.Default.Stop,
icon = Icons.Default.Stop,
contentDescription = "暂停章节下载"
)
} else if (item.isPaused || !item.isCached) {
SmallTonalIconButton(
SmallTonalButton(
onClick = onDownload,
imageVector = Icons.Default.Download,
icon = Icons.Default.Download,
contentDescription = if (item.isPaused) "继续章节下载" else "下载章节"
)
}
SmallTonalIconButton(
SmallTonalButton(
onClick = onDelete,
imageVector = Icons.Default.Delete,
icon = Icons.Default.Delete,
contentDescription = null
)
}
@@ -0,0 +1,65 @@
package io.legado.app.ui.book.explore
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.domain.model.BookShelfState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class ExploreShowUiState(
val sourceUrl: String? = null,
val books: ImmutableList<ExploreBookItemUi> = persistentListOf(),
val kinds: ImmutableList<ExploreKind> = persistentListOf(),
val selectedKindTitle: String? = null,
val layoutState: Int = 0,
val gridCount: Int = 3,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isEnd: Boolean = false,
val errorMsg: String? = null,
val sheet: ExploreShowSheet = ExploreShowSheet.None,
)
@Stable
data class ExploreBookItemUi(
val book: SearchBook,
val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
)
sealed interface ExploreShowSheet {
data object None : ExploreShowSheet
data object KindSelect : ExploreShowSheet
data object GridCount : ExploreShowSheet
}
sealed interface ExploreShowIntent {
data class InitData(
val sourceUrl: String?,
val exploreUrl: String?,
) : ExploreShowIntent
data object LoadMore : ExploreShowIntent
data object Refresh : ExploreShowIntent
data class SwitchKind(val kind: ExploreKind) : ExploreShowIntent
data object ToggleLayout : ExploreShowIntent
data class SaveGridCount(val count: Int) : ExploreShowIntent
data class ShowSheet(val sheet: ExploreShowSheet) : ExploreShowIntent
data object DismissSheet : ExploreShowIntent
data class OpenBook(val book: SearchBook, val sharedCoverKey: String?) : ExploreShowIntent
data class AddToShelf(val book: SearchBook) : ExploreShowIntent
}
sealed interface ExploreShowEffect {
data class OpenBookInfo(
val name: String,
val author: String,
val bookUrl: String,
val origin: String?,
val coverPath: String?,
val sharedCoverKey: String?,
) : ExploreShowEffect
data class ShowMessage(val message: String) : ExploreShowEffect
}
@@ -6,7 +6,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -33,11 +32,8 @@ import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -48,19 +44,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppSlider
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.book.SearchBookListItem
@@ -74,7 +70,17 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
private enum class BookFilterState(val id: Int) {
SHOW_ALL(0),
HIDE_IN_SHELF(1),
HIDE_SAME_NAME_AUTHOR(2),
SHOW_NOT_IN_SHELF_ONLY(3);
companion object {
fun fromId(id: Int) = entries.getOrElse(id) { SHOW_ALL }
}
}
@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
@OptIn(
@@ -83,55 +89,51 @@ import org.koin.compose.koinInject
)
@Composable
fun ExploreShowScreen(
title: String,
sourceUrl: String?,
exploreUrl: String?,
viewModel: ExploreShowViewModel = koinViewModel(),
title: String = "",
onBack: () -> Unit,
onBookClick: (SearchBook, String?) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel(),
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
is ExploreShowEffect.OpenBookInfo -> onBookClick(
SearchBook(
name = effect.name,
author = effect.author,
bookUrl = effect.bookUrl,
origin = effect.origin ?: "",
coverUrl = effect.coverPath,
),
effect.sharedCoverKey,
)
is ExploreShowEffect.ShowMessage -> {}
}
}
}
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
viewModel.initData(sourceUrl, exploreUrl)
}
val rawBooks by viewModel.uiBooks.collectAsState()
val filterStateId = CoverConfig.exploreFilterState
val books = remember(rawBooks, filterStateId) {
val books = remember(state.books, filterStateId) {
val filter = BookFilterState.fromId(filterStateId)
when (filter) {
BookFilterState.SHOW_ALL -> rawBooks
BookFilterState.HIDE_IN_SHELF -> rawBooks.filter { it.shelfState != BookShelfState.IN_SHELF }
BookFilterState.HIDE_SAME_NAME_AUTHOR -> rawBooks.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR }
BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> rawBooks.filter { it.shelfState == BookShelfState.NOT_IN_SHELF }
BookFilterState.SHOW_ALL -> state.books
BookFilterState.HIDE_IN_SHELF -> state.books.filter { it.shelfState != BookShelfState.IN_SHELF }
BookFilterState.HIDE_SAME_NAME_AUTHOR -> state.books.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR }
BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> state.books.filter { it.shelfState == BookShelfState.NOT_IN_SHELF }
}
}
val isBookEnd by viewModel.isEnd.collectAsState()
val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState()
val selectedTitle by viewModel.selectedKindTitle.collectAsState()
val listState = rememberLazyListState()
val gridState = rememberLazyGridState()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
var showKindSheet by remember { mutableStateOf(false) }
val layoutState by viewModel.layoutState.collectAsState()
val isGridMode = layoutState == 1
var showGridCountSheet by remember { mutableStateOf(false) }
val gridColumnCount by viewModel.gridCount.collectAsState()
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) {
exploreKindUseCase.warmUp(sourceUrl)
}
val isRefreshing by viewModel.isRefreshing.collectAsState()
val isGridMode = state.layoutState == 1
val hazeState = remember { HazeState() }
val shouldLoadMoreList = remember {
derivedStateOf {
@@ -150,17 +152,11 @@ fun ExploreShowScreen(
}
LaunchedEffect(shouldLoadMoreList.value, isGridMode) {
if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore()
if (!isGridMode && shouldLoadMoreList.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
}
LaunchedEffect(shouldLoadMoreGrid.value, isGridMode) {
if (isGridMode && shouldLoadMoreGrid.value) viewModel.loadMore()
}
LaunchedEffect(shouldTriggerAutoLoad) {
if (shouldTriggerAutoLoad) {
viewModel.loadMore()
}
if (isGridMode && shouldLoadMoreGrid.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
}
LaunchedEffect(isGridMode) {
@@ -175,17 +171,14 @@ fun ExploreShowScreen(
}
}
AppModalBottomSheet(
show = showGridCountSheet,
modifier = Modifier
.padding(16.dp),
onDismissRequest = { showGridCountSheet = false }
show = state.sheet == ExploreShowSheet.GridCount,
onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
.padding(bottom = 32.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -193,49 +186,38 @@ fun ExploreShowScreen(
text = "布局列数",
style = LegadoTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
Spacer(modifier = Modifier.width(12.dp))
TextCard(
text = "$gridColumnCount",
text = "${state.gridCount}",
textStyle = LegadoTheme.typography.titleSmall,
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
verticalPadding = 4.dp,
horizontalPadding = 12.dp,
cornerRadius = 12.dp
)
}
Slider(
value = gridColumnCount.toFloat(),
AppSlider(
value = state.gridCount.toFloat(),
onValueChange = {
val col = it.toInt().coerceIn(1, 10)
viewModel.saveGridCount(col)
viewModel.onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
},
valueRange = 1f..10f,
steps = 8,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(Modifier.height(24.dp))
Spacer(modifier = Modifier.height(32.dp))
OutlinedButton(
onClick = { showGridCountSheet = false },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
AppText("完成")
}
}
ExploreKindSelectSheet(
show = showKindSheet,
onDismissRequest = { showKindSheet = false },
sourceUrl = sourceUrl,
show = state.sheet == ExploreShowSheet.KindSelect,
onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) },
sourceUrl = state.sourceUrl,
onSelected = { selectedKinds ->
selectedKinds.firstOrNull()?.let { kind ->
viewModel.switchExploreUrl(kind)
viewModel.onIntent(ExploreShowIntent.SwitchKind(kind))
}
}
)
@@ -245,39 +227,39 @@ fun ExploreShowScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
GlassMediumFlexibleTopAppBar(
modifier = Modifier.responsiveHazeEffect(
state = hazeState
),
title = selectedTitle ?: title,
modifier = Modifier.responsiveHazeEffect(state = hazeState),
title = state.selectedKindTitle ?: title,
navigationIcon = {
TopBarNavigationButton(onClick = onBack)
},
actions = {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.animateContentSize(tween(300))
AnimatedVisibility(
visible = isGridMode,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(300))
) {
TopBarActionButton(
onClick = { showKindSheet = true },
imageVector = Icons.Outlined.FilterAlt,
contentDescription = "分类"
onClick = {
viewModel.onIntent(
ExploreShowIntent.ShowSheet(
ExploreShowSheet.GridCount
)
)
},
imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
contentDescription = "列数设置"
)
AnimatedVisibility(
visible = isGridMode,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(300))
) {
TopBarActionButton(
onClick = { showGridCountSheet = true },
imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
contentDescription = "列数设置"
)
}
}
TopBarActionButton(
onClick = { viewModel.setLayout() },
onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
imageVector = Icons.Outlined.FilterAlt,
contentDescription = "分类"
)
TopBarActionButton(
onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
contentDescription = "切换布局"
)
@@ -288,8 +270,8 @@ fun ExploreShowScreen(
) { paddingValues ->
AppPullToRefresh(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
onRefresh = { viewModel.loadMore(isRefresh = true) },
isRefreshing = state.isRefreshing,
onRefresh = { viewModel.onIntent(ExploreShowIntent.Refresh) },
topPadding = paddingValues.calculateTopPadding()
) {
Crossfade(
@@ -303,7 +285,7 @@ fun ExploreShowScreen(
modifier = Modifier
.fillMaxSize()
.responsiveHazeSource(hazeState),
columns = GridCells.Fixed(gridColumnCount),
columns = GridCells.Fixed(state.gridCount),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 12.dp,
bottom = paddingValues.calculateBottomPadding() + 12.dp,
@@ -324,7 +306,14 @@ fun ExploreShowScreen(
ExploreBookGridItem(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book, sharedCoverKey) },
onClick = {
viewModel.onIntent(
ExploreShowIntent.OpenBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
@@ -338,10 +327,10 @@ fun ExploreShowScreen(
item(span = { GridItemSpan(maxLineSpan) }) {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isBookEnd,
onRetry = viewModel::loadMore
isLoading = state.isLoading,
errorMsg = state.errorMsg,
isEnd = state.isEnd,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) }
)
}
}
@@ -367,7 +356,14 @@ fun ExploreShowScreen(
ExploreBookItem(
book = item.book,
shelfState = item.shelfState,
onClick = { onBookClick(item.book, sharedCoverKey) },
onClick = {
viewModel.onIntent(
ExploreShowIntent.OpenBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
@@ -381,15 +377,14 @@ fun ExploreShowScreen(
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isBookEnd,
onRetry = viewModel::loadMore
isLoading = state.isLoading,
errorMsg = state.errorMsg,
isEnd = state.isEnd,
onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) }
)
}
}
}
}
}
}
@@ -408,7 +403,7 @@ fun ExploreShowScreen(
onBookClick(book, sharedCoverKey)
},
onAddToShelf = { book ->
viewModel.onAddToShelf(book)
viewModel.onIntent(ExploreShowIntent.AddToShelf(book))
},
)
}
@@ -454,10 +449,9 @@ fun ExploreBookGridItem(
shelfState = shelfState,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
modifier = modifier.padding(4.dp),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
@@ -6,7 +6,6 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.usecase.AddToBookshelfUseCase
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ExploreBooksUseCase
@@ -15,43 +14,16 @@ import io.legado.app.domain.usecase.SaveSearchBooksUseCase
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.exploreLayoutGrid
import io.legado.app.utils.stackTraceStr
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import splitties.init.appCtx
sealed class BookFilterState(val id: Int) {
data object SHOW_ALL : BookFilterState(0)
data object HIDE_IN_SHELF : BookFilterState(1)
data object HIDE_SAME_NAME_AUTHOR : BookFilterState(2)
data object SHOW_NOT_IN_SHELF_ONLY : BookFilterState(3)
companion object {
fun fromId(id: Int): BookFilterState = when (id) {
1 -> HIDE_IN_SHELF
2 -> HIDE_SAME_NAME_AUTHOR
3 -> SHOW_NOT_IN_SHELF_ONLY
else -> SHOW_ALL
}
}
}
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
data object Empty : UiState<Nothing>()
}
data class ExploreBookItemUi(
val book: SearchBook,
val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
)
class ExploreShowViewModel(
private val repository: ExploreRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
@@ -61,72 +33,128 @@ class ExploreShowViewModel(
) : ViewModel() {
private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
private val _isLoading = MutableStateFlow(false)
private val _isRefreshing = MutableStateFlow(false)
private val _isEnd = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null)
private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
private val _selectedKindTitle = MutableStateFlow<String?>(null)
private var bookSource: BookSource? = null
private var sourceUrl: String? = null
private var exploreUrl: String? = null
private var page = 1
private val _isEndStateFlow = MutableStateFlow(false)
private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
val kinds = _kinds.asStateFlow()
private val _selectedKindTitle = MutableStateFlow<String?>(null)
val selectedKindTitle = _selectedKindTitle.asStateFlow()
private val _layoutState = MutableStateFlow(AppConfig.exploreLayoutState) // 0=列表, 1=网格
val layoutState: StateFlow<Int> = _layoutState.asStateFlow()
private val _gridCount = MutableStateFlow(appCtx.exploreLayoutGrid)
val gridCount = _gridCount.asStateFlow()
val isEnd: StateFlow<Boolean> = _isEndStateFlow.asStateFlow()
val isRefreshing = _isRefreshing.asStateFlow()
private val _uiState = MutableStateFlow(
ExploreShowUiState(
layoutState = AppConfig.exploreLayoutState,
gridCount = appCtx.exploreLayoutGrid,
)
)
val uiState = _uiState.asStateFlow()
fun saveGridCount(value: Int) {
appCtx.exploreLayoutGrid = value
_gridCount.value = value
}
val uiBooks = combine(
_rawBooks,
_bookshelf
) { books, bookshelf ->
books.map { item ->
ExploreBookItemUi(
book = item,
shelfState = resolveBookShelfStateUseCase.execute(
name = item.name,
author = item.author,
url = item.bookUrl,
shelf = bookshelf
)
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val shouldTriggerAutoLoad = combine(
_isLoading,
uiBooks,
_rawBooks,
_isEndStateFlow
) { loading, uiBooks, rawBooks, isEnd ->
!loading && uiBooks.isEmpty() && rawBooks.isNotEmpty() && !isEnd
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isLoading = _isLoading.asStateFlow()
val errorMsg = _errorMsg.asStateFlow()
private val _effects = MutableSharedFlow<ExploreShowEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
viewModelScope.launch {
repository.getBookshelfItems().collect { list ->
val keys = list.map { BookShelfKey(it.name, it.author, it.bookUrl) }.toSet()
_bookshelf.value = keys
observeBookshelf()
combineUiState()
}
fun onIntent(intent: ExploreShowIntent) {
when (intent) {
is ExploreShowIntent.InitData -> initData(intent.sourceUrl, intent.exploreUrl)
ExploreShowIntent.LoadMore -> loadMore()
ExploreShowIntent.Refresh -> loadMore(isRefresh = true)
is ExploreShowIntent.SwitchKind -> switchKind(intent.kind)
ExploreShowIntent.ToggleLayout -> toggleLayout()
is ExploreShowIntent.SaveGridCount -> saveGridCount(intent.count)
is ExploreShowIntent.ShowSheet -> _uiState.update { it.copy(sheet = intent.sheet) }
ExploreShowIntent.DismissSheet -> _uiState.update { it.copy(sheet = ExploreShowSheet.None) }
is ExploreShowIntent.OpenBook -> emitEffect(
ExploreShowEffect.OpenBookInfo(
name = intent.book.name,
author = intent.book.author,
bookUrl = intent.book.bookUrl,
origin = intent.book.origin,
coverPath = intent.book.coverUrl,
sharedCoverKey = intent.sharedCoverKey,
)
)
is ExploreShowIntent.AddToShelf -> viewModelScope.launch {
addToBookshelfUseCase.execute(intent.book)
}
}
}
fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
// 允许 incomingExploreUrl 为空,此时加载书源默认发现页
private fun observeBookshelf() {
viewModelScope.launch {
repository.getBookshelfItems().collect { list ->
_bookshelf.value = list.map {
BookShelfKey(it.name, it.author, it.bookUrl)
}.toSet()
}
}
}
private fun combineUiState() {
viewModelScope.launch {
combine(
_rawBooks,
_bookshelf,
_isLoading,
_isEnd,
_errorMsg,
_kinds,
_selectedKindTitle,
) { values ->
@Suppress("UNCHECKED_CAST")
val rawBooks = values[0] as List<SearchBook>
val bookshelf = values[1] as Set<BookShelfKey>
val isLoading = values[2] as Boolean
val isEnd = values[3] as Boolean
val errorMsg = values[4] as String?
val kinds = values[5] as List<ExploreKind>
val selectedKindTitle = values[6] as String?
val books = rawBooks.map { item ->
ExploreBookItemUi(
book = item,
shelfState = resolveBookShelfStateUseCase.execute(
name = item.name,
author = item.author,
url = item.bookUrl,
shelf = bookshelf,
)
)
}
ExploreShowUiState(
sourceUrl = sourceUrl,
books = books.toImmutableList(),
kinds = kinds.toImmutableList(),
selectedKindTitle = selectedKindTitle,
layoutState = _uiState.value.layoutState,
gridCount = _uiState.value.gridCount,
isLoading = isLoading,
isRefreshing = isLoading && page == 1,
isEnd = isEnd,
errorMsg = errorMsg,
sheet = _uiState.value.sheet,
)
}.collect { newState ->
val oldState = _uiState.value
_uiState.value = newState
if (newState.books.isEmpty() && _rawBooks.value.isNotEmpty() && !newState.isEnd && !newState.isLoading) {
loadMore()
}
}
}
}
private fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
return
}
@@ -135,16 +163,16 @@ class ExploreShowViewModel(
page = 1
bookSource = null
_rawBooks.value = emptyList()
_isEndStateFlow.value = false
_isEnd.value = false
_errorMsg.value = null
_selectedKindTitle.value = null
_kinds.value = emptyList()
viewModelScope.launch {
if (bookSource == null && incomingSourceUrl != null) {
bookSource = repository.getBookSource(incomingSourceUrl)
}
// 如果仍然没有发现 URL,且书源已加载,尝试使用书源的默认发现页
if (exploreUrl == null && bookSource != null) {
loadKinds(incomingSourceUrl!!)
}
@@ -153,33 +181,36 @@ class ExploreShowViewModel(
}
}
fun loadKinds(sourceUrl: String) {
private fun loadKinds(sourceUrl: String) {
viewModelScope.launch {
_kinds.value = repository.getSourceExploreKinds(sourceUrl)
}
}
fun refreshKinds() {
sourceUrl?.let { loadKinds(it) }
}
fun switchExploreUrl(kind: ExploreKind) {
private fun switchKind(kind: ExploreKind) {
_selectedKindTitle.value = kind.title
exploreUrl = kind.url
_isEndStateFlow.value = false
_isEnd.value = false
loadMore(isRefresh = true)
}
fun setLayout() {
val newState = if (_layoutState.value == 0) 1 else 0
_layoutState.value = newState
AppConfig.exploreLayoutState = newState
private fun toggleLayout() {
_uiState.update {
val newState = if (it.layoutState == 0) 1 else 0
AppConfig.exploreLayoutState = newState
it.copy(layoutState = newState)
}
}
fun loadMore(isRefresh: Boolean = false) {
private fun saveGridCount(count: Int) {
appCtx.exploreLayoutGrid = count
_uiState.update { it.copy(gridCount = count) }
}
private fun loadMore(isRefresh: Boolean = false) {
val source = bookSource
val url = exploreUrl ?: source?.exploreUrl
if (source == null || url == null || _isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
if (source == null || url == null || _isLoading.value || (_isEnd.value && !isRefresh)) return
viewModelScope.launch {
_isLoading.value = true
@@ -187,7 +218,7 @@ class ExploreShowViewModel(
if (isRefresh) {
page = 1
_isEndStateFlow.value = false
_isEnd.value = false
_rawBooks.value = emptyList()
}
@@ -195,7 +226,7 @@ class ExploreShowViewModel(
exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page)
}.onSuccess { result ->
if (result.books.isEmpty()) {
_isEndStateFlow.value = true
_isEnd.value = true
} else {
saveSearchBooksUseCase.save(result.books)
@@ -207,34 +238,22 @@ class ExploreShowViewModel(
.distinctBy { it.bookUrl }
if (uniqueNewBooks.isEmpty()) {
_isEndStateFlow.value = true
_isEnd.value = true
} else {
_rawBooks.value = currentList + uniqueNewBooks
page++
_isEndStateFlow.value = false
_isEnd.value = false
}
}
}.onFailure {
_errorMsg.value = it.stackTraceStr
}
.onFailure {
_errorMsg.value = it.stackTraceStr
}
_isLoading.value = false
}
}
fun getCurrentBookShelfState(item: SearchBook): BookShelfState {
return resolveBookShelfStateUseCase.execute(
name = item.name,
author = item.author,
url = item.bookUrl,
shelf = _bookshelf.value
)
}
fun onAddToShelf(book: SearchBook) {
viewModelScope.launch {
addToBookshelfUseCase.execute(book)
}
private fun emitEffect(effect: ExploreShowEffect) {
_effects.tryEmit(effect)
}
}
@@ -34,7 +34,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -272,11 +272,11 @@ fun GroupDeleteAction(
) {
var showDeleteDialog by remember { mutableStateOf(false) }
MediumIconButton(
MediumPlainButton(
onClick = {
showDeleteDialog = true
},
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
AppAlertDialog(
@@ -304,7 +304,7 @@ fun GroupResetCoverAction(
onCoverPathChange: (String?) -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
MediumIconButton(
MediumPlainButton(
onClick = {
if (group != null) {
viewModel.clearCover(group) {
@@ -315,6 +315,6 @@ fun GroupResetCoverAction(
onCoverPathChange(null)
}
},
imageVector = Icons.Default.Restore
icon = Icons.Default.Restore
)
}
@@ -61,8 +61,8 @@ import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SelectionActions
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
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.filePicker.FilePickerSheet
@@ -427,9 +427,9 @@ private fun ImportPathNavigationBar(
}
if (canGoBack) {
SmallTonalIconButton(
SmallTonalButton(
onClick = onNavigateBack,
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "back"
)
}
@@ -511,9 +511,9 @@ private fun ImportBookItem(
if (!item.isDir && !item.isOnBookShelf) {
Spacer(modifier = Modifier.width(8.dp))
SmallIconButton(
SmallPlainButton(
onClick = onAddToBookshelf,
imageVector = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
icon = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
)
}
}
@@ -76,9 +76,9 @@ import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SelectionActions
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.card.TextCard
@@ -167,9 +167,9 @@ fun RemoteBookScreen(
},
endAction = if (showSheet is RemoteBookSheet.Servers) {
{
MediumIconButton(
MediumPlainButton(
onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
}
} else {
@@ -430,16 +430,16 @@ private fun ServerItem(
{
Row {
onEdit?.let {
SmallIconButton(
SmallPlainButton(
onClick = it,
imageVector = Icons.Default.Edit,
icon = Icons.Default.Edit,
contentDescription = "Edit"
)
}
onDelete?.let {
SmallIconButton(
SmallPlainButton(
onClick = it,
imageVector = Icons.Default.Delete,
icon = Icons.Default.Delete,
contentDescription = "Delete"
)
}
@@ -602,9 +602,9 @@ private fun PathNavigationBar(
}
if (canGoBack) {
SmallTonalIconButton(
SmallTonalButton(
onClick = onNavigateBack,
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回上级"
)
}
@@ -703,7 +703,7 @@ private fun RemoteBookItem(
if (!book.isDir) {
Spacer(modifier = Modifier.width(8.dp))
SmallIconButton(
SmallPlainButton(
onClick = {
if (book.isOnBookShelf) {
onUpdateClick(book)
@@ -711,7 +711,7 @@ private fun RemoteBookItem(
onAddClick(book)
}
},
imageVector = if (book.isOnBookShelf)
icon = if (book.isOnBookShelf)
Icons.Outlined.CloudSync
else
Icons.Outlined.AddCircleOutline,
@@ -89,7 +89,7 @@ import io.legado.app.ui.widget.components.AppPullToRefresh
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
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.icon.AppIcon
@@ -1122,9 +1122,9 @@ private fun RelatedBooksBanner(
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
SmallTonalIconButton(
SmallTonalButton(
onClick = onMoreClick,
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
icon = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "more",
)
}
@@ -72,8 +72,8 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.checkBox.AppCheckbox
@@ -145,15 +145,15 @@ fun GroupSelectSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.group_select),
startAction = {
MediumIconButton(
MediumPlainButton(
onClick = { showAddGroup = true },
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
},
endAction = {
MediumIconButton(
MediumPlainButton(
onClick = { onConfirm(selectedGroupId) },
imageVector = Icons.Default.Check
icon = Icons.Default.Check
)
}
) {
@@ -187,9 +187,9 @@ fun GroupSelectSheet(
)
},
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { editingGroup = group },
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
},
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -236,9 +236,9 @@ fun ChangeCoverSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.change_cover_source),
endAction = {
MediumIconButton(
MediumPlainButton(
onClick = { viewModel.startOrStopSearch() },
imageVector = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh
icon = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh
)
}
) {
@@ -370,9 +370,9 @@ fun ChangeSourceSheet(
startAction = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Box {
MediumIconButton(
MediumPlainButton(
onClick = { showOptionsMenu = true },
imageVector = Icons.Default.MoreVert
icon = Icons.Default.MoreVert
)
RoundDropdownMenu(
expanded = showOptionsMenu,
@@ -419,21 +419,21 @@ fun ChangeSourceSheet(
)
}
}
MediumIconButton(
MediumPlainButton(
onClick = { showMigrationOptions = true },
imageVector = Icons.Outlined.Settings
icon = Icons.Outlined.Settings
)
}
},
endAction = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MediumIconButton(
MediumPlainButton(
onClick = { viewModel.startOrStopSearch() },
imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
)
MediumIconButton(
MediumPlainButton(
onClick = { showFilterSheet = true },
imageVector = Icons.Default.FilterList
icon = Icons.Default.FilterList
)
}
}
@@ -484,11 +484,11 @@ fun ChangeSourceSheet(
containerColor = LegadoTheme.colorScheme.onSheetContent,
selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
leadingContent = {
MediumIconButton(
MediumPlainButton(
onClick = {
viewModel.onBookScoreClick(item)
},
imageVector = Icons.Default.PushPin,
icon = Icons.Default.PushPin,
tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
contentDescription = null
)
@@ -59,7 +59,7 @@ import io.legado.app.ui.theme.fadingEdge
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -156,7 +156,7 @@ fun BookInfoEditContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = {
(context as? BookInfoEditActivity)?.showDialogFragment(
ChangeCoverDialog(
@@ -165,15 +165,15 @@ fun BookInfoEditContent(
)
)
},
imageVector = Icons.Default.ImageSearch
icon = Icons.Default.ImageSearch
)
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = { selectCover.launch() },
imageVector = Icons.Default.FolderOpen
icon = Icons.Default.FolderOpen
)
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = { viewModel.resetCover() },
imageVector = Icons.Default.Replay
icon = Icons.Default.Replay
)
}
Spacer(modifier = Modifier.height(4.dp))
@@ -371,19 +371,19 @@ fun KindEditor(
}
}
Spacer(modifier = Modifier.width(8.dp))
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = {
onReset()
},
imageVector = Icons.Default.Replay
icon = Icons.Default.Replay
)
Spacer(modifier = Modifier.width(8.dp))
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = {
editingIndex = -1
editText = ""
},
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
}
@@ -82,8 +82,7 @@ import io.legado.app.ui.widget.components.AppFloatingActionButtonMenu
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.FabMenuItem
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.SmallTonalTextButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -768,32 +767,32 @@ private fun BookshelfManageScreen(
}
)
}
SmallTonalIconButton(
SmallTonalButton(
onClick = {
if (!book.isLocal) {
viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
}
},
imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
contentDescription = "download"
)
SmallTonalIconButton(
SmallTonalButton(
onClick = { exportBook(book) },
imageVector = Icons.Default.Upload,
icon = Icons.Default.Upload,
contentDescription = "upload"
)
SmallTonalIconButton(
SmallTonalButton(
onClick = {
pendingMoveGroupBookUrl = book.bookUrl
groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
showGroupSelectSheet = true
},
imageVector = Icons.Default.Bookmarks,
icon = Icons.Default.Bookmarks,
contentDescription = "group"
)
SmallTonalIconButton(
SmallTonalButton(
onClick = { moreMenuBookUrl = book.bookUrl },
imageVector = Icons.Default.MoreVert,
icon = Icons.Default.MoreVert,
contentDescription = "more"
)
}
@@ -1288,12 +1287,12 @@ private fun BookSourcePickerSheet(
onDismissRequest = onDismissRequest,
title = title,
endAction = {
SmallTonalTextButton(
text = stringResource(android.R.string.ok),
imageVector = Icons.Default.PlayArrow,
SmallTonalButton(
onClick = {
onConfirm(selectedSources.mapNotNull { it.getBookSource() })
}
},
icon = Icons.Default.PlayArrow,
text = stringResource(android.R.string.ok)
)
}
) {
@@ -1402,17 +1401,17 @@ private fun BatchChangePreviewSheet(
onDismissRequest = onDismissRequest,
title = "批量换源预览",
startAction = {
SmallTonalTextButton(
text = "新增全部",
imageVector = Icons.Default.Add,
SmallTonalButton(
onClick = onAddAllToShelf,
icon = Icons.Default.Add,
text = "新增全部"
)
},
endAction = {
SmallTonalTextButton(
text = "迁移全部",
imageVector = Icons.Default.PlayArrow,
SmallTonalButton(
onClick = onMigrateAll,
icon = Icons.Default.PlayArrow,
text = "迁移全部"
)
}
) { items ->
@@ -1498,30 +1497,30 @@ private fun BatchChangePreviewRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
SmallTonalTextButton(
imageVector = Icons.Default.Search,
SmallTonalButton(
onClick = { onManualSearch(item.oldBook) },
icon = Icons.Default.Search
)
SmallTonalTextButton(
text = "不迁移",
imageVector = Icons.Default.Clear,
SmallTonalButton(
onClick = { onSkip(item.oldBook.bookUrl) },
icon = Icons.Default.Clear,
text = "不迁移"
)
SmallTonalTextButton(
text = "迁移",
imageVector = Icons.Default.PlayArrow,
SmallTonalButton(
onClick = { onMigrate(item.oldBook.bookUrl) },
icon = Icons.Default.PlayArrow,
text = "迁移"
)
SmallTonalTextButton(
text = "新增",
imageVector = Icons.Default.Add,
SmallTonalButton(
onClick = { onAddToShelf(item.oldBook.bookUrl) },
icon = Icons.Default.Add,
text = "新增"
)
if (item.candidates.size > 1) {
SmallTonalTextButton(
text = "查看其他源信息",
imageVector = Icons.Default.Info,
SmallTonalButton(
onClick = { onShowOtherSources(item) },
icon = Icons.Default.Info,
text = "查看其他源信息"
)
}
}
@@ -1605,9 +1604,9 @@ private fun OtherSourceOptionsSheet(
isSelected = index == currentItem.selectedCandidateIndex,
onToggleSelection = { onSelect(currentItem.oldBook.bookUrl, index) },
trailingAction = {
SmallTonalIconButton(
SmallTonalButton(
onClick = { onOpenBook(candidate.book) },
imageVector = Icons.Default.Info,
icon = Icons.Default.Info,
contentDescription = null,
)
},
@@ -54,7 +54,7 @@ import io.legado.app.ui.book.readRecord.component.StatsGridCard
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.heatmap.HeatmapMode
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
@@ -187,9 +187,9 @@ fun DateNavigator(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
MediumIconButton(
MediumPlainButton(
onClick = onPrevClick,
imageVector = Icons.AutoMirrored.Filled.ArrowLeft
icon = Icons.AutoMirrored.Filled.ArrowLeft
)
AnimatedContent(
targetState = referenceDate,
@@ -224,9 +224,9 @@ fun DateNavigator(
modifier = Modifier.padding(horizontal = 16.dp)
)
}
MediumIconButton(
MediumPlainButton(
onClick = onNextClick,
imageVector = Icons.AutoMirrored.Filled.ArrowRight
icon = Icons.AutoMirrored.Filled.ArrowRight
)
}
}
@@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.heightIn
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.Check
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -27,8 +25,9 @@ import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -68,9 +67,9 @@ fun ScopeSelectSheet(
title = title,
endAction = onConfirm?.let {
{
MediumIconButton(
MediumPlainButton(
onClick = it,
imageVector = Icons.Default.Check
icon = AppIcons.Settings
)
}
}
@@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() {
},
onOpenSourceManage = {
startActivity<BookSourceActivity>()
}
},
)
}
@@ -5,7 +5,10 @@ import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.MatchMode
import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class SearchResultItemUi(
@@ -17,17 +20,17 @@ data class SearchResultItemUi(
data class SearchUiState(
val query: String = "",
val committedQuery: String = "",
val results: List<SearchResultItemUi> = emptyList(),
val history: List<SearchKeyword> = emptyList(),
val bookshelfHints: List<BookShelfItem> = emptyList(),
val enabledGroups: List<String> = emptyList(),
val enabledSources: List<BookSourcePart> = emptyList(),
val results: ImmutableList<SearchResultItemUi> = persistentListOf(),
val history: ImmutableList<SearchKeyword> = persistentListOf(),
val bookshelfHints: ImmutableList<BookShelfItem> = persistentListOf(),
val enabledGroups: ImmutableList<String> = persistentListOf(),
val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
val scopeDisplay: String = "",
val scopeDisplayNames: List<String> = emptyList(),
val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
val selectedScopeSourceUrls: Set<String> = emptySet(),
val isAllScope: Boolean = true,
val isSourceScope: Boolean = false,
val isPrecisionSearch: Boolean = false,
val matchMode: MatchMode = MatchMode.DEFAULT,
val isSearching: Boolean = false,
val isManualStop: Boolean = false,
val hasMore: Boolean = true,
@@ -35,17 +38,24 @@ data class SearchUiState(
val totalSources: Int = 0,
val selectedSourceTypes: Set<Int> = emptySet(),
val showScopeSheet: Boolean = false,
val showTypeSheet: Boolean = false,
val showSettingsSheet: Boolean = false,
val showClearHistoryDialog: Boolean = false,
val showSuggestions: Boolean = true,
val emptyScopeAction: SearchEmptyScopeAction? = null,
val savedScrollIndex: Int = 0,
val savedScrollOffset: Int = 0,
val expandedSourceUrl: String? = null,
val expandedSourceName: String? = null,
val expandedSourceBooks: ImmutableList<SearchBook> = persistentListOf(),
val expandedSourceLoading: Boolean = false,
val expandedSourceEnd: Boolean = false,
val expandedSourceError: String? = null,
val expandedSourcePage: Int = 1,
)
data class SearchEmptyScopeAction(
val scopeDisplay: String,
val wasPrecisionSearch: Boolean,
val wasMatchMode: MatchMode,
)
sealed interface SearchIntent {
@@ -60,17 +70,22 @@ sealed interface SearchIntent {
data class UseHistoryKeyword(val keyword: String) : SearchIntent
data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent
data object DismissExpandedSource : SearchIntent
data object LoadMoreExpandedSource : SearchIntent
data class OpenExpandedSourceBook(val book: SearchBook, val sharedCoverKey: String?) :
SearchIntent
data class DeleteHistory(val item: SearchKeyword) : SearchIntent
data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
data object ConfirmClearHistory : SearchIntent
data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
data class SetTypeSheetVisible(val visible: Boolean) : SearchIntent
data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
data class ToggleSourceType(val type: Int) : SearchIntent
data object SelectAllScope : SearchIntent
data class ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
data class RemoveScopeItem(val scopeName: String) : SearchIntent
data class TogglePrecision(val enabled: Boolean) : SearchIntent
data class SetMatchMode(val mode: MatchMode) : SearchIntent
data object ConfirmEmptyScopeAction : SearchIntent
data object DismissEmptyScopeAction : SearchIntent
data object OpenSourceManage : SearchIntent
@@ -20,8 +20,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
@@ -54,6 +56,7 @@ import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.MatchMode
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.ui.theme.LegadoTheme
@@ -63,12 +66,12 @@ import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookListItem
import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallTextButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcon
@@ -76,11 +79,11 @@ import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.utils.toastOnUi
@@ -100,6 +103,8 @@ fun SearchScreen(
) {
val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle()
val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
val isSourceGroupedMode = searchLayoutMode == 1
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
@@ -149,13 +154,15 @@ fun SearchScreen(
state.hasMore,
state.isManualStop,
state.showSuggestions,
isSourceGroupedMode,
) {
if (
shouldLoadMore &&
!state.isSearching &&
state.hasMore &&
!state.isManualStop &&
!state.showSuggestions
!state.showSuggestions &&
!isSourceGroupedMode
) {
viewModel.onIntent(SearchIntent.LoadMore)
}
@@ -261,32 +268,15 @@ fun SearchScreen(
)
},
actions = {
TopBarActionButton(
onClick = {
viewModel.onIntent(SearchIntent.OpenSourceManage)
},
imageVector = AppIcons.Settings,
contentDescription = stringResource(R.string.book_source_manage)
)
TopBarAnimatedActionButton(
checked = state.isPrecisionSearch,
onCheckedChange = { checked ->
viewModel.onIntent(SearchIntent.TogglePrecision(checked))
},
iconChecked = AppIcons.PrecisionSearch,
iconUnchecked = AppIcons.UnPrecisionSearch,
activeText = stringResource(R.string.precision_search),
inactiveText = stringResource(R.string.search),
)
TopBarAnimatedActionButton(
checked = state.selectedSourceTypes.isNotEmpty(),
checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(),
onCheckedChange = {
viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true))
viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
},
iconChecked = Icons.Default.Layers,
iconUnchecked = Icons.Default.Layers,
activeText = "搜素类型",
inactiveText = "搜素类型",
iconChecked = AppIcons.Settings,
iconUnchecked = AppIcons.Settings,
activeText = stringResource(R.string.setting),
inactiveText = stringResource(R.string.setting),
)
TopBarAnimatedActionButton(
checked = !state.isAllScope,
@@ -296,7 +286,7 @@ fun SearchScreen(
iconChecked = AppIcons.Filter,
iconUnchecked = AppIcons.Filter,
activeText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen),
inactiveText = stringResource(R.string.screen)
)
},
scrollBehavior = scrollBehavior
@@ -315,12 +305,13 @@ fun SearchScreen(
placeholder = searchLabel,
trailingIcon = {
if (queryInput.isNotEmpty()) {
TopBarActionButton(
SmallPlainButton(
modifier = Modifier.padding(horizontal = 8.dp),
onClick = {
queryInput = ""
viewModel.onIntent(SearchIntent.UpdateQuery(""))
},
imageVector = AppIcons.Close,
icon = AppIcons.Close,
contentDescription = stringResource(R.string.clear)
)
}
@@ -394,52 +385,124 @@ fun SearchScreen(
}
if (state.results.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
itemsIndexed(
items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
)
SearchBookListItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
val sourceGroupedResults = remember(state.results) {
state.results
.groupBy { it.book.origin }
.map { (origin, books) ->
SourceGroup(
origin = origin,
sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() }
?: origin,
items = books
)
}
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
AnimatedContent(
targetState = isSourceGroupedMode,
label = "SearchLayoutTransition",
modifier = Modifier.fillMaxSize(),
) { isSourceGrouped ->
if (isSourceGrouped) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
sourceGroupedResults.forEachIndexed { groupIndex, group ->
item(key = "header_${group.origin}") {
SearchSourceSection(
sourceName = group.sourceName,
items = group.items,
onClickBook = { book, coverKey ->
viewModel.onIntent(
SearchIntent.OpenSearchBook(
book,
coverKey
)
)
},
onLongClickBook = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
onViewAll = {
viewModel.onIntent(
SearchIntent.ExpandSource(
group.origin,
group.sourceName
)
)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sourceSectionIndex = groupIndex,
)
}
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(
items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search:${item.book.origin}:$index"
)
SearchBookListItem(
book = item.book,
shelfState = item.shelfState,
onClick = {
viewModel.onIntent(
SearchIntent.OpenSearchBook(
item.book,
sharedCoverKey
)
)
},
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey
)
}
item {
SearchResultFooter(
isSearching = state.isSearching,
hasMore = state.hasMore,
hasResult = true,
committedQuery = state.committedQuery,
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
)
}
}
}
}
}
@@ -502,7 +565,7 @@ fun SearchScreen(
},
title = stringResource(R.string.draw),
textProvider = {
if (wasPrecisionSearch) {
if (wasMatchMode == MatchMode.EXACT) {
stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
} else {
stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
@@ -525,46 +588,101 @@ fun SearchScreen(
sources = state.enabledSources,
selectedSources = state.selectedScopeSourceUrls,
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope
isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
)
AppModalBottomSheet(
show = state.showTypeSheet,
onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) },
title = "搜素类型",
show = state.showSettingsSheet,
onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
title = stringResource(R.string.setting),
) {
Column {
SelectionItemCard(
title = stringResource(R.string.all),
isSelected = state.selectedSourceTypes.isEmpty(),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
if (state.selectedSourceTypes.isNotEmpty()) {
state.selectedSourceTypes.forEach {
viewModel.onIntent(SearchIntent.ToggleSourceType(it))
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CompactDropdownSettingItem(
title = stringResource(R.string.layout_mode),
selectedValue = searchLayoutMode.toString(),
displayEntries = arrayOf(
stringResource(R.string.search_layout_source_grouped),
stringResource(R.string.search_layout_list)
),
entryValues = arrayOf("1", "0"),
imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted,
onValueChange = { newValue ->
if (newValue.toInt() != searchLayoutMode) {
viewModel.toggleSearchLayout()
}
}
)
Spacer(modifier = Modifier.height(8.dp))
CompactDropdownSettingItem(
title = stringResource(R.string.precision_search),
selectedValue = state.matchMode.value.toString(),
displayEntries = arrayOf(
stringResource(R.string.precision_search),
stringResource(R.string.search)
),
entryValues = arrayOf(
MatchMode.EXACT.value.toString(),
MatchMode.DEFAULT.value.toString()
),
imageVector = if (state.matchMode == MatchMode.EXACT) AppIcons.PrecisionSearch else AppIcons.UnPrecisionSearch,
onValueChange = { newValue ->
val mode = MatchMode.of(newValue.toInt())
if (mode != state.matchMode) {
viewModel.onIntent(SearchIntent.SetMatchMode(mode))
}
}
)
listOf(
0 to stringResource(R.string.noval),
2 to stringResource(R.string.manga),
1 to stringResource(R.string.audio),
).forEach { (type, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(Icons.Default.Layers, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = "搜索类型",
style = LegadoTheme.typography.titleSmall
)
}
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
SelectionItemCard(
title = label,
isSelected = state.selectedSourceTypes.contains(type),
title = stringResource(R.string.all),
isSelected = state.selectedSourceTypes.isEmpty(),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
viewModel.onIntent(SearchIntent.ToggleSourceType(type))
if (state.selectedSourceTypes.isNotEmpty()) {
state.selectedSourceTypes.forEach {
viewModel.onIntent(SearchIntent.ToggleSourceType(it))
}
}
}
)
Spacer(modifier = Modifier.height(4.dp))
listOf(
0 to stringResource(R.string.noval),
2 to stringResource(R.string.manga),
1 to stringResource(R.string.audio),
).forEach { (type, label) ->
SelectionItemCard(
title = label,
isSelected = state.selectedSourceTypes.contains(type),
containerColor = LegadoTheme.colorScheme.onSheetContent,
inSelectionMode = true,
onToggleSelection = {
viewModel.onIntent(SearchIntent.ToggleSourceType(type))
}
)
}
}
Spacer(modifier = Modifier.height(20.dp))
@@ -588,6 +706,24 @@ fun SearchScreen(
viewModel.onAddToShelf(book)
},
)
ExpandedSourceSheet(
show = state.expandedSourceUrl != null,
sourceName = state.expandedSourceName ?: "",
books = state.expandedSourceBooks,
isLoading = state.expandedSourceLoading,
isEnd = state.expandedSourceEnd,
errorMsg = state.expandedSourceError,
onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
onBookClick = { book, coverKey ->
viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
},
onBookLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
)
}
private data class SearchFloatingSummary(
@@ -653,10 +789,10 @@ private fun SearchSuggestionPanel(
}
if (state.history.isNotEmpty()) {
SmallTextButton(
SmallPlainButton(
onClick = onClearHistory,
text = stringResource(R.string.clear_all),
imageVector = Icons.Default.Close
icon = Icons.Default.Close
)
}
}
@@ -685,9 +821,9 @@ private fun SearchSuggestionPanel(
title = history.word,
onToggleSelection = { onUseHistory(history.word) },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onDeleteHistory(history) },
imageVector = Icons.Default.Close,
icon = Icons.Default.Close,
contentDescription = stringResource(R.string.delete)
)
}
@@ -747,3 +883,76 @@ private fun SearchResultFooter(
}
}
}
private data class SourceGroup(
val origin: String,
val sourceName: String,
val items: List<SearchResultItemUi>,
)
@Composable
private fun ExpandedSourceSheet(
show: Boolean,
sourceName: String,
books: List<SearchBook>,
isLoading: Boolean,
isEnd: Boolean,
errorMsg: String?,
onDismiss: () -> Unit,
onLoadMore: () -> Unit,
onBookClick: (SearchBook, String?) -> Unit,
onBookLongClick: ((SearchBook, String?) -> Unit)? = null,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismiss,
title = sourceName,
) {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val total = listState.layoutInfo.totalItemsCount
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
total > 0 && last >= total - 3
}
}
LaunchedEffect(shouldLoadMore, isLoading, isEnd) {
if (shouldLoadMore && !isLoading && !isEnd) {
onLoadMore()
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical(
top = 8.dp,
bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = books,
key = { it.bookUrl },
) { book ->
SearchBookListItem(
book = book,
shelfState = BookShelfState.NOT_IN_SHELF,
onClick = { onBookClick(book, null) },
onLongClick = onBookLongClick,
)
}
item {
LoadMoreFooter(
isLoading = isLoading,
errorMsg = errorMsg,
isEnd = isEnd,
onRetry = onLoadMore,
)
}
}
}
}
@@ -0,0 +1,111 @@
package io.legado.app.ui.book.search
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook
import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizonalPadding
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.fadingEdge
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.text.AppText
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun SearchSourceSection(
sourceName: String,
items: List<SearchResultItemUi>,
onClickBook: (SearchBook, String?) -> Unit,
onLongClickBook: ((SearchBook, String?) -> Unit)? = null,
onViewAll: (() -> Unit)? = null,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sourceSectionIndex: Int = 0,
) {
if (items.isEmpty()) return
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.adaptiveHorizontalPadding()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = sourceName,
style = LegadoTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TextCard(
text = "${items.size}",
modifier = Modifier
.padding(start = 12.dp)
)
Spacer(modifier = Modifier.weight(1f))
if (onViewAll != null) {
SmallTonalButton(
onClick = onViewAll,
icon = Icons.AutoMirrored.Filled.ArrowForward
)
}
}
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = Modifier
.fillMaxWidth()
.fadingEdge(lazyListState, gradientWidth = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = adaptiveHorizonalPadding(),
) {
itemsIndexed(
items = items,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl,
"search_source:$sourceSectionIndex:$index"
)
SearchBookGridItem(
book = item.book,
shelfState = item.shelfState,
onClick = { onClickBook(item.book, sharedCoverKey) },
onLongClick = onLongClickBook,
modifier = Modifier.width(100.dp),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey,
)
}
}
}
}
@@ -2,50 +2,73 @@ package io.legado.app.ui.book.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.domain.usecase.AddToBookshelfUseCase
import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.BookShelfKey
import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.putPrefBoolean
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import splitties.init.appCtx
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel(
private val repository: SearchRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val searchBooksUseCase: SearchBooksUseCase,
private val exploreBooksUseCase: ExploreBooksUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase,
private val localPreferencesRepository: LocalPreferencesRepository,
) : ViewModel() {
val searchLayoutMode = localPreferencesRepository
.getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
fun toggleSearchLayout() {
viewModelScope.launch {
val newMode = if (searchLayoutMode.value == 0) 1 else 0
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.SEARCH_LAYOUT_MODE, newMode
)
}
}
private val matchModeFlow = localPreferencesRepository
.getPreference(LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value)
.distinctUntilChanged()
.map { MatchMode.of(it) }
private val _uiState = MutableStateFlow(
SearchUiState(
isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
scopeDisplay = SearchScope(AppConfig.searchScope).display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
isAllScope = SearchScope(AppConfig.searchScope).isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
)
@@ -72,6 +95,7 @@ class SearchViewModel(
observeBookshelf()
observeQueryHistory()
observeQueryBookshelfHints()
observeMatchMode()
}
fun onAddToShelf(book: SearchBook) {
@@ -153,8 +177,8 @@ class SearchViewModel(
_uiState.update { it.copy(showScopeSheet = intent.visible) }
}
is SearchIntent.SetTypeSheetVisible -> {
_uiState.update { it.copy(showTypeSheet = intent.visible) }
is SearchIntent.SetSettingsSheetVisible -> {
_uiState.update { it.copy(showSettingsSheet = intent.visible) }
}
is SearchIntent.ToggleSourceType -> {
@@ -184,9 +208,12 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope)
}
is SearchIntent.TogglePrecision -> {
appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled)
_uiState.update { it.copy(isPrecisionSearch = intent.enabled) }
is SearchIntent.SetMatchMode -> {
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
)
}
restartCommittedSearchIfNeeded()
}
@@ -197,6 +224,61 @@ class SearchViewModel(
SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
is SearchIntent.ExpandSource -> {
_uiState.update {
it.copy(
expandedSourceUrl = intent.sourceUrl,
expandedSourceName = intent.sourceName,
expandedSourceBooks = persistentListOf(),
expandedSourceLoading = true,
expandedSourceEnd = false,
expandedSourceError = null,
expandedSourcePage = 1,
)
}
loadExpandedSourcePage(intent.sourceUrl, page = 1)
}
SearchIntent.DismissExpandedSource -> {
_uiState.update {
it.copy(
expandedSourceUrl = null,
expandedSourceName = null,
expandedSourceBooks = persistentListOf(),
expandedSourceLoading = false,
expandedSourceEnd = false,
expandedSourceError = null,
expandedSourcePage = 1,
)
}
}
SearchIntent.LoadMoreExpandedSource -> {
val state = _uiState.value
val sourceUrl = state.expandedSourceUrl ?: return
if (state.expandedSourceLoading || state.expandedSourceEnd) return
_uiState.update {
it.copy(
expandedSourceLoading = true,
expandedSourceError = null
)
}
loadExpandedSourcePage(sourceUrl, page = state.expandedSourcePage)
}
is SearchIntent.OpenExpandedSourceBook -> {
emitEffect(
SearchEffect.OpenBookInfo(
name = intent.book.name,
author = intent.book.author,
bookUrl = intent.book.bookUrl,
origin = intent.book.origin,
coverPath = intent.book.coverUrl,
sharedCoverKey = intent.sharedCoverKey,
)
)
}
is SearchIntent.SaveScrollState -> {
_uiState.update {
it.copy(
@@ -233,7 +315,7 @@ class SearchViewModel(
repository.enabledGroups
.catch { emit(emptyList()) }
.collect { groups ->
_uiState.update { it.copy(enabledGroups = groups) }
_uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
}
}
}
@@ -243,7 +325,7 @@ class SearchViewModel(
repository.enabledSources
.catch { emit(emptyList()) }
.collect { sources ->
_uiState.update { it.copy(enabledSources = sources) }
_uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
}
}
}
@@ -255,7 +337,7 @@ class SearchViewModel(
.collect { keys ->
bookshelfKeys.value = keys
_uiState.update { state ->
state.copy(results = state.results.withShelfState(keys))
state.copy(results = state.results.withShelfState(keys).toImmutableList())
}
}
}
@@ -269,7 +351,7 @@ class SearchViewModel(
.flatMapLatest { repository.searchHistory(it) }
.catch { emit(emptyList()) }
.collect { history ->
_uiState.update { it.copy(history = history) }
_uiState.update { it.copy(history = history.toImmutableList()) }
}
}
}
@@ -282,11 +364,19 @@ class SearchViewModel(
.flatMapLatest { repository.searchBookshelf(it) }
.catch { emit(emptyList()) }
.collect { books ->
_uiState.update { it.copy(bookshelfHints = books) }
_uiState.update { it.copy(bookshelfHints = books.toImmutableList()) }
}
}
}
private fun observeMatchMode() {
viewModelScope.launch {
matchModeFlow.collect { mode ->
_uiState.update { it.copy(matchMode = mode) }
}
}
}
private fun updateQuery(query: String, showSuggestions: Boolean) {
val currentState = _uiState.value
val isSameQuery = currentState.query == query
@@ -319,7 +409,7 @@ class SearchViewModel(
_uiState.update {
it.copy(
committedQuery = keyword,
results = emptyList(),
results = persistentListOf(),
isManualStop = false,
hasMore = true,
processedSources = 0,
@@ -362,7 +452,7 @@ class SearchViewModel(
keyword = keyword,
page = page,
scope = BookSearchScope(searchScope.toString()),
precision = _uiState.value.isPrecisionSearch,
matchMode = _uiState.value.matchMode,
concurrency = OtherConfig.threadCount,
types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
),
@@ -395,7 +485,7 @@ class SearchViewModel(
it.copy(
results = buildSearchResultItems(
shelf = bookshelfKeys.value,
),
).toImmutableList(),
processedSources = event.processedSources,
totalSources = event.totalSources,
)
@@ -407,7 +497,7 @@ class SearchViewModel(
val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction(
scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch,
wasMatchMode = state.matchMode,
)
} else {
null
@@ -441,7 +531,7 @@ class SearchViewModel(
it.copy(
query = "",
committedQuery = "",
results = emptyList(),
results = persistentListOf(),
processedSources = 0,
totalSources = 0,
isSearching = false,
@@ -497,9 +587,12 @@ class SearchViewModel(
val action = _uiState.value.emptyScopeAction ?: return
_uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasPrecisionSearch) {
appCtx.putPrefBoolean(PreferKey.precisionSearch, false)
_uiState.update { it.copy(isPrecisionSearch = false) }
if (action.wasMatchMode == MatchMode.EXACT) {
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
)
}
} else {
searchScope.update("")
syncScopeState()
@@ -523,7 +616,7 @@ class SearchViewModel(
_uiState.update {
it.copy(
scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames,
scopeDisplayNames = searchScope.displayNames.toImmutableList(),
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(),
@@ -571,6 +664,37 @@ class SearchViewModel(
}
}
private fun loadExpandedSourcePage(sourceUrl: String, page: Int) {
viewModelScope.launch {
val keyword = _uiState.value.committedQuery
try {
val result = exploreBooksUseCase.execute(
sourceUrl = sourceUrl,
moduleUrl = null,
args = null,
page = page,
key = keyword,
)
val newBooks = result.books
_uiState.update {
it.copy(
expandedSourceBooks = (it.expandedSourceBooks + newBooks).toImmutableList(),
expandedSourceLoading = false,
expandedSourceEnd = newBooks.isEmpty(),
expandedSourcePage = page + 1,
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
expandedSourceLoading = false,
expandedSourceError = e.message ?: "Unknown error",
)
}
}
}
}
private fun emitEffect(effect: SearchEffect) {
_effects.tryEmit(effect)
}
@@ -34,11 +34,9 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import io.legado.app.ui.widget.components.AppFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
@@ -57,20 +55,23 @@ import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchContentHistory
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.button.series.SmallToggleButton
import io.legado.app.ui.widget.components.button.series.ToggleStyle
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -302,14 +303,14 @@ fun SearchHistoryList(
color = LegadoTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center)
)
SmallAnimatedActionButton(
SmallToggleButton(
modifier = Modifier.align(Alignment.CenterEnd),
checked = onlyThisBook,
onCheckedChange = { onToggleScope() },
style = ToggleStyle.Tonal,
iconChecked = Icons.Default.Book,
iconUnchecked = Icons.Default.CollectionsBookmark,
activeText = "仅本书",
inactiveText = "所有记录"
icon = Icons.Default.CollectionsBookmark,
text = "仅本书"
)
}
@@ -338,9 +339,9 @@ fun SearchHistoryList(
Icon(Icons.Default.History, contentDescription = null)
},
trailingContent = {
SmallIconButton(
SmallPlainButton(
onClick = { onDeleteHistory(item) },
imageVector = Icons.Default.Close,
icon = Icons.Default.Close,
contentDescription = "删除"
)
},
@@ -361,7 +362,7 @@ fun SearchHistoryList(
MediumOutlinedButton(
onClick = onClearHistory,
modifier = Modifier.fillMaxWidth(0.6f),
imageVector = Icons.Outlined.DeleteSweep,
icon = Icons.Outlined.DeleteSweep,
text = "清除搜索历史"
)
}
@@ -95,7 +95,8 @@ import io.legado.app.ui.widget.components.FabMenuItem
import io.legado.app.ui.widget.components.SelectionBottomBar
import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
import io.legado.app.ui.widget.components.bookmark.BookmarkItem
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.series.SmallToggleButton
import io.legado.app.ui.widget.components.button.series.ToggleStyle
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillDivider
@@ -476,10 +477,11 @@ fun TocScreen(
if (pagerState.currentPage == 0 && hasVolumes) {
Box {
SmallOutlinedIconToggleButton(
SmallToggleButton(
checked = showVolumeMenu,
onCheckedChange = { showVolumeMenu = it },
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
style = ToggleStyle.Outlined,
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.volume_management)
)
RoundDropdownMenu(
@@ -5,24 +5,15 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -48,7 +39,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.DraggableSelectionHandler
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -59,7 +50,6 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleEditFields
import io.legado.app.ui.widget.components.rules.RuleEditSheet
import io.legado.app.ui.widget.components.rules.RuleListScaffold
import io.legado.app.ui.widget.components.text.AppText
import org.koin.androidx.compose.koinViewModel
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -350,9 +340,9 @@ fun TxtRuleScreen(
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) },
onClickEdit = { editingRule = item.rule; showEditSheet = true },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { showDeleteRuleDialog = item.rule },
imageVector = AppIcons.Delete
icon = AppIcons.Delete
)
}
)
@@ -23,7 +23,7 @@ import io.legado.app.R
import io.legado.app.help.DefaultData
import io.legado.app.model.BookCover
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.utils.toastOnUi
@@ -59,8 +59,8 @@ fun CoverRuleConfigSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.cover_rule),
startAction = {
MediumIconButton(
imageVector = Icons.Default.SettingsBackupRestore,
MediumPlainButton(
icon = Icons.Default.SettingsBackupRestore,
onClick = {
DefaultData.coverRule.let {
enable = it.enable
@@ -72,8 +72,8 @@ fun CoverRuleConfigSheet(
)
},
endAction = {
MediumIconButton(
imageVector = Icons.Default.Save,
MediumPlainButton(
icon = Icons.Default.Save,
onClick = {
if (searchUrl.isBlank() || coverRule.isBlank()) {
appCtx.toastOnUi(R.string.cover_rule_fields_required)
@@ -2,7 +2,6 @@ package io.legado.app.ui.config.otherConfig
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,12 +15,8 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -39,7 +34,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -70,18 +65,18 @@ fun DirectLinkUploadBottomSheet(
show = show,
title = stringResource(R.string.direct_link_upload_config),
startAction = {
MediumIconButton(
MediumPlainButton(
onClick = {
viewModel.testRule { result -> showTestResult = result }
},
imageVector = Icons.Default.Checklist
icon = Icons.Default.Checklist
)
},
endAction = {
Box {
MediumIconButton(
MediumPlainButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert
icon = Icons.Default.MoreVert
)
RoundDropdownMenu(
expanded = showMenu,
@@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcon
@@ -114,13 +114,13 @@ fun BackgroundImageManageSheet(
contentScale = ContentScale.Crop
)
}
SmallTonalIconButton(
SmallTonalButton(
onClick = { viewModel.removeBackground(isDark) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(32.dp),
imageVector = Icons.Default.Close
icon = Icons.Default.Close
)
}
}
@@ -5,21 +5,19 @@ import androidx.compose.foundation.layout.Row
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.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.toMutableStateList
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -30,8 +28,8 @@ import androidx.core.graphics.ColorUtils
import io.legado.app.R
import io.legado.app.help.config.TagColorGenerator
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
@@ -57,7 +55,7 @@ fun LabelColorManageSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.theme_config_manage_label_colors),
startAction = {
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = {
val baseColor = if (themeColor != 0) Color(themeColor) else primaryColor
val generatedColors = TagColorGenerator.generateTagColors(baseColor)
@@ -65,23 +63,25 @@ fun LabelColorManageSheet(
tagColors.addAll(generatedColors)
ThemeConfig.saveCustomTagColors(tagColors)
},
imageVector = Icons.Default.AutoAwesome
icon = Icons.Default.AutoAwesome
)
},
endAction = {
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = {
tagColors.add(TagColorPair(0, 0))
editingIndex = tagColors.size - 1
editingTextColor = 0
showColorPicker = true
},
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
}
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tagColors.size) { index ->
@@ -110,20 +110,20 @@ fun LabelColorManageSheet(
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
SmallIconButton(
SmallPlainButton(
onClick = {
editingIndex = index
editingTextColor = colorPair.textColor
showColorPicker = true
},
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = {
tagColors.removeAt(index)
ThemeConfig.saveCustomTagColors(tagColors)
},
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
}
@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -123,13 +123,13 @@ fun NavIconManageSheet(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
SmallTonalIconButton(
SmallTonalButton(
onClick = { dest.onSetPath("") },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(24.dp),
imageVector = Icons.Default.Close
icon = Icons.Default.Close
)
}
} else {
@@ -88,7 +88,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -222,8 +222,8 @@ fun ThemeConfigScreen(
style = LegadoTheme.typography.labelLargeEmphasized,
modifier = Modifier.weight(1f)
)
SmallIconButton(
imageVector = AppIcons.Close,
SmallPlainButton(
icon = AppIcons.Close,
contentDescription = "关闭",
onClick = {
viewModel.setShowThemeRefactorTip(false)
@@ -759,8 +759,8 @@ fun ThemeConfigScreen(
onDismissRequest = { showFontSheet = false },
title = stringResource(R.string.font_setting),
startAction = {
SmallIconButton(
imageVector = Icons.Default.Delete,
SmallPlainButton(
icon = Icons.Default.Delete,
contentDescription = stringResource(R.string.clear),
onClick = {
ThemeConfig.appFontPath = null
@@ -769,8 +769,8 @@ fun ThemeConfigScreen(
)
},
endAction = {
SmallIconButton(
imageVector = Icons.Default.Add,
SmallPlainButton(
icon = Icons.Default.Add,
contentDescription = stringResource(R.string.select_folder),
onClick = { fontFolderLauncher.launch(null) }
)
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.help.config.ThemeExportData
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
@@ -60,13 +60,13 @@ fun EditThemeSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.theme_manage_edit_theme),
endAction = {
MediumIconButton(
MediumPlainButton(
onClick = {
if (name.isNotBlank()) {
onSave(name, data)
}
},
imageVector = Icons.Default.Done,
icon = Icons.Default.Done,
contentDescription = "Save"
)
}
@@ -47,7 +47,7 @@ import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.text.AppText
@@ -393,17 +393,17 @@ private fun SavedThemeItem(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
SmallIconButton(
SmallPlainButton(
onClick = onEdit,
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = onExport,
imageVector = Icons.Default.Share
icon = Icons.Default.Share
)
SmallIconButton(
SmallPlainButton(
onClick = onDelete,
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
}
@@ -1,168 +0,0 @@
package io.legado.app.ui.dict.rule
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.NoteAdd
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.DictRule
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DictRuleEditSheet(
rule: DictRule?,
onDismissRequest: () -> Unit,
onSave: (DictRule) -> Unit,
onCopy: (DictRule) -> Unit,
onPaste: () -> DictRule?
) {
val scope = rememberCoroutineScope()
var name by remember(rule) { mutableStateOf(rule?.name ?: "") }
var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") }
var showRule by remember(rule) { mutableStateOf(rule?.showRule ?: "") }
var showMenu by remember { mutableStateOf(false) }
AppModalBottomSheet(
show = true,
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
CenterAlignedTopAppBar(
title = { AppText(stringResource(R.string.dict_rule)) },
navigationIcon = {
IconButton(onClick = onDismissRequest) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.cancel)
)
}
},
actions = {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { AppText(stringResource(R.string.copy_rule)) },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
onClick = {
onCopy(
DictRule(
name,
urlRule,
showRule,
enabled = rule?.enabled ?: true
)
)
showMenu = false
}
)
DropdownMenuItem(
text = { AppText(stringResource(R.string.paste_rule)) },
leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
onClick = {
scope.launch {
onPaste()?.let {
name = it.name
urlRule = it.urlRule
showRule = it.showRule
}
}
showMenu = false
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = name,
onValueChange = { name = it },
label = { AppText(stringResource(R.string.name)) },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = urlRule,
onValueChange = { urlRule = it },
label = { AppText(stringResource(R.string.url_rule)) }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = showRule,
onValueChange = { showRule = it },
label = { AppText(stringResource(R.string.show_rule)) },
minLines = 3
)
}
}
AppFloatingActionButton(
onClick = {
onSave(
rule?.copy(name = name, urlRule = urlRule, showRule = showRule)
?: DictRule(name = name, urlRule = urlRule, showRule = showRule)
)
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
icon = Icons.Default.Save
)
}
}
}
@@ -10,13 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -41,7 +38,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.DraggableSelectionHandler
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -77,7 +74,6 @@ fun DictRuleScreen(
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
var showUrlInput by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
var showExportSheet by remember { mutableStateOf(false) }
@@ -90,7 +86,6 @@ fun DictRuleScreen(
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
val importState by viewModel.importState.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
@@ -276,7 +271,7 @@ fun DictRuleScreen(
}),
ActionItem(
text = stringResource(R.string.export),
onClick = { showFilePickerSheet = true })
onClick = { showExportSheet = true })
),
onDeleteSelected = { ids ->
@Suppress("UNCHECKED_CAST")
@@ -320,9 +315,9 @@ fun DictRuleScreen(
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) },
onClickEdit = { editingRule = item.rule; showEditSheet = true },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { showDeleteRuleDialog = item.rule },
imageVector = AppIcons.Delete
icon = AppIcons.Delete
)
}
)
@@ -111,7 +111,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
context: Context,
exploreName: String? = null,
sourceUrl: String,
exploreUrl: String? = null
exploreUrl: String? = null,
): Intent = MainIntent.createExploreShowIntent(context, exploreName, sourceUrl, exploreUrl)
}
@@ -134,7 +134,7 @@ object MainIntent {
context: Context,
exploreName: String? = null,
sourceUrl: String,
exploreUrl: String? = null
exploreUrl: String? = null,
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_EXPLORE_SHOW)
@@ -19,7 +19,9 @@ import io.legado.app.ui.about.AboutEffect
import io.legado.app.ui.about.AboutScreen
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
import io.legado.app.ui.book.explore.ExploreShowIntent
import io.legado.app.ui.book.explore.ExploreShowScreen
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.import.local.ImportBookScreen
import io.legado.app.ui.book.import.remote.RemoteBookScreen
import io.legado.app.ui.book.info.BookInfoRouteScreen
@@ -39,8 +41,8 @@ import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
import io.legado.app.ui.config.otherConfig.OtherConfigScreen
import io.legado.app.ui.config.readConfig.ReadConfigScreen
import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
import io.legado.app.ui.config.translation.TranslationConfigScreen
import io.legado.app.ui.config.themeManage.ThemeManageScreen
import io.legado.app.ui.config.translation.TranslationConfigScreen
import io.legado.app.ui.rss.article.MainRouteRssSort
import io.legado.app.ui.rss.article.RssSortRouteScreen
import io.legado.app.ui.rss.favorites.RssFavoritesScreen
@@ -436,10 +438,17 @@ fun MainActivity.mainEntryProvider(
}
entry<MainRouteExploreShow> { route ->
val exploreViewModel = koinViewModel<ExploreShowViewModel>()
LaunchedEffect(route.sourceUrl, route.exploreUrl, exploreViewModel) {
exploreViewModel.onIntent(
ExploreShowIntent.InitData(route.sourceUrl, route.exploreUrl)
)
}
ExploreShowScreen(
viewModel = exploreViewModel,
title = route.title ?: "探索",
sourceUrl = route.sourceUrl,
exploreUrl = route.exploreUrl,
onBack = { onNavigateBack() },
onBookClick = { book, sharedCoverKey ->
onNavigateToRoute(
@@ -268,7 +268,7 @@ object MainNavigator {
MainRouteExploreShow(
title = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_NAME),
sourceUrl = sourceUrl,
exploreUrl = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_URL)
exploreUrl = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_URL),
)
} ?: MainRouteHome
@@ -114,7 +114,9 @@ import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.button.series.SmallToggleButton
import io.legado.app.ui.widget.components.button.series.ToggleStyle
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
@@ -557,7 +559,7 @@ fun BookshelfScreen(
val showExpandButton by BookshelfConfig.shouldShowExpandButtonState
if (showExpandButton) {
Box(modifier = Modifier) {
SmallOutlinedIconToggleButton(
SmallToggleButton(
checked = showGroupMenu,
onCheckedChange = {
if (it) {
@@ -566,7 +568,8 @@ fun BookshelfScreen(
viewModel.dismissOverlay()
}
},
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
style = ToggleStyle.Outlined,
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
@@ -837,7 +840,7 @@ fun BookshelfScreen(
if (this != null) Modifier.skipToLookaheadSize() else Modifier
}
),
beyondViewportPageCount = 1,
beyondViewportPageCount = 0,
key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex)
@@ -1058,9 +1061,10 @@ private fun BookshelfTopBar(
},
trailingIcon = {
if (uiState.searchKey.isNotEmpty()) {
TopBarActionButton(
SmallPlainButton(
modifier = Modifier.padding(start = 12.dp),
onClick = onClearSearch,
imageVector = AppIcons.Close,
icon = AppIcons.Close,
contentDescription = stringResource(R.string.clear)
)
}
@@ -41,7 +41,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@@ -196,34 +195,31 @@ class BookshelfViewModel(
}.distinctUntilChanged().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class)
private val allGroupBooksFlow: StateFlow<Map<Long, List<BookUiItem>>> = combine(
groupsFlow, sortConfigFlow
) { groups, sortConfig ->
groups to sortConfig
}.flatMapLatest { (groups, sortConfig) ->
if (groups.isEmpty()) {
flowOf(emptyMap())
} else {
val flows = groups.map { group ->
bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
group.groupId to bookshelfRepository.sortBooks(
books,
group,
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }
private val allGroupBooksImmutableFlow: StateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
combine(groupsFlow, sortConfigFlow) { groups, sortConfig ->
groups to sortConfig
}.flatMapLatest { (groups, sortConfig) ->
if (groups.isEmpty()) {
flowOf(persistentMapOf())
} else {
val flows = groups.map { group ->
bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
group.groupId to bookshelfRepository.sortBooks(
books,
group,
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }.toImmutableList()
}
}
combine(flows) { results ->
results.fold(persistentMapOf<Long, ImmutableList<BookUiItem>>()) { acc, (id, list) ->
acc.put(id, list)
}
}
}
combine(flows) { it.toMap() }
}
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private val allGroupBooksImmutableFlow: StateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
allGroupBooksFlow.map { map ->
map.mapValues { it.value.toImmutableList() }.toImmutableMap()
}.flowOn(Dispatchers.Default)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentMapOf())
private val visibleBooksFlow: Flow<List<BookUiItem>> = combine(
@@ -301,30 +297,21 @@ class BookshelfViewModel(
}
}.distinctUntilChanged().flowOn(Dispatchers.Default)
private val coreInternalStateFlow = combine(
private val internalStateFlow = combine(
groupIdFlow,
searchKeyFlow,
searchModeFlow,
loadingTextFlow,
updatingBooksFlow
) { groupId, searchKey, isSearchMode, loadingText, updatingBooks ->
combine(updatingBooksFlow, upBooksCountFlow, sortConfigFlow) { a, b, c ->
Triple(a, b, c)
}
) { groupId, searchKey, isSearchMode, loadingText, (updatingBooks, upBooksCount, sortConfig) ->
InternalState(
groupId = groupId,
searchKey = searchKey,
isSearchMode = isSearchMode,
loadingText = loadingText,
updatingBooks = updatingBooks,
upBooksCount = 0,
sortConfig = readSortConfig()
)
}
private val internalStateFlow = combine(
coreInternalStateFlow,
upBooksCountFlow,
sortConfigFlow
) { core, upBooksCount, sortConfig ->
core.copy(
upBooksCount = upBooksCount,
sortConfig = sortConfig
)
@@ -351,35 +338,29 @@ class BookshelfViewModel(
val pendingSavedBooks: List<BookUiItem>?
)
private val editStateFlow = combine(
private val interactionStateFlow = combine(
activeOverlayFlow,
isEditModeFlow,
selectedVisibleBookUrlsFlow,
isInFolderRootFlow
) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot ->
EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot)
}
private data class EditState(
val activeOverlay: BookshelfOverlay?,
val isEditMode: Boolean,
val selectedBookUrls: Set<String>,
val isInFolderRoot: Boolean
)
private val interactionStateFlow = combine(
editStateFlow,
isRefreshingFlow,
bookGroupStyleFlow,
draggingBooksFlow,
pendingSavedBooksFlow
) { editState, isRefreshing, bookGroupStyle, draggingBooks, pendingSavedBooks ->
isInFolderRootFlow,
isRefreshingFlow
) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot, isRefreshing ->
BookshelfInteractionState(
activeOverlay = editState.activeOverlay,
isEditMode = editState.isEditMode,
selectedBookUrls = editState.selectedBookUrls,
isInFolderRoot = editState.isInFolderRoot,
activeOverlay = activeOverlay,
isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls,
isInFolderRoot = isInFolderRoot,
isRefreshing = isRefreshing,
bookGroupStyle = 0,
draggingBooks = null,
pendingSavedBooks = null
)
}.combine(
combine(bookGroupStyleFlow, draggingBooksFlow, pendingSavedBooksFlow) { a, b, c ->
Triple(a, b, c)
}
) { interaction, (bookGroupStyle, draggingBooks, pendingSavedBooks) ->
interaction.copy(
bookGroupStyle = bookGroupStyle,
draggingBooks = draggingBooks,
pendingSavedBooks = pendingSavedBooks
@@ -390,11 +371,6 @@ class BookshelfViewModel(
GroupPreviewState(persistentMapOf(), persistentMapOf(), 0)
)
private val allGroupBooksStateFlow =
MutableStateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>>(
persistentMapOf()
)
private val dataStateFlow = combine(
booksFlow,
groupsFlow,
@@ -403,7 +379,7 @@ class BookshelfViewModel(
internalStateFlow
) { books, groups, allGroups, previews, internal ->
BookshelfDataCore(books, groups, allGroups, previews, internal)
}.combine(allGroupBooksStateFlow) { core, allGroupBooks ->
}.combine(allGroupBooksImmutableFlow) { core, allGroupBooks ->
BookshelfDataState(
books = core.books,
groups = core.groups.map { it.toBookGroupUi() },
@@ -529,9 +505,6 @@ class BookshelfViewModel(
viewModelScope.launch {
groupPreviewsFlow.collect { groupPreviewsStateFlow.value = it }
}
viewModelScope.launch {
allGroupBooksImmutableFlow.collect { allGroupBooksStateFlow.value = it }
}
viewModelScope.launch {
combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks ->
books to canReorderBooks
@@ -26,12 +26,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.book.group.GroupEditContent
import io.legado.app.ui.book.group.GroupDeleteAction
import io.legado.app.ui.book.group.GroupEditContent
import io.legado.app.ui.book.group.GroupResetCoverAction
import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.utils.move
@@ -95,13 +95,13 @@ fun GroupManageSheet(
},
endAction = {
if (!isEditing) {
SmallIconButton(
SmallPlainButton(
onClick = {
editingGroup = null
coverPath = null
isEditing = true
},
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
} else {
GroupResetCoverAction(
@@ -92,6 +92,9 @@ fun ExploreScreen(
val context = LocalContext.current
val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listItems by remember(uiState.items, uiState.expandedId, uiState.exploreKinds) {
derivedStateOf { viewModel.buildExploreListItems(uiState) }
}
var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
uiState.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl }
@@ -118,10 +121,10 @@ fun ExploreScreen(
}
}
val stickyHeaderSource by remember(uiState.listItems, uiState.items) {
val stickyHeaderSource by remember(listItems, uiState.items) {
derivedStateOf {
val firstIndex = listState.firstVisibleItemIndex
val item = uiState.listItems.getOrNull(firstIndex)
val item = listItems.getOrNull(firstIndex)
if (item is ExploreListItem.KindRow) {
uiState.items.find { it.bookSourceUrl == item.sourceUrl }
} else {
@@ -178,7 +181,7 @@ fun ExploreScreen(
)
) {
items(
items = uiState.listItems,
items = listItems,
key = { it.key }
) { listItem ->
when (listItem) {
@@ -268,7 +271,7 @@ fun ExploreScreen(
verticalPadding = 8.dp,
onClick = {
scope.launch {
val index = uiState.listItems.indexOfFirst {
val index = listItems.indexOfFirst {
it is ExploreListItem.Header && it.source.bookSourceUrl == item.bookSourceUrl
}
if (index >= 0) listState.animateScrollToItem(index)
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -42,7 +41,6 @@ class ExploreViewModel(
private val _uiState = MutableStateFlow(ExploreUiState())
val uiState: StateFlow<ExploreUiState> = _uiState
.map { state -> state.copy(listItems = buildExploreListItems(state)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExploreUiState())
private val _effects = MutableSharedFlow<ExploreEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
@@ -200,11 +198,10 @@ class ExploreViewModel(
val exploreKinds: ImmutableList<ExploreKind> = persistentListOf(),
val kindDisplayNames: ImmutableMap<String, String> = persistentMapOf(),
val kindValues: ImmutableMap<String, String> = persistentMapOf(),
val loadingKinds: Boolean = false,
val listItems: ImmutableList<ExploreListItem> = persistentListOf()
val loadingKinds: Boolean = false
) : ListUiState<BookSourcePart>
private fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
if (state.items.isEmpty()) return persistentListOf()
val expandedId = state.expandedId
val kindRows = if (expandedId != null) {
@@ -37,7 +37,7 @@ import io.legado.app.ui.main.homepage.manage.SetListPage
import io.legado.app.ui.main.homepage.manage.SourceBrowseDetailPage
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -127,38 +127,38 @@ fun <T> HomepageModuleManageSheet(
},
startAction = {
if (showCustomSetAddModules) {
SmallIconButton(
SmallPlainButton(
onClick = { showCustomSetAddModules = false },
imageVector = Icons.AutoMirrored.Filled.ArrowBack
icon = Icons.AutoMirrored.Filled.ArrowBack
)
} else if (browsingSourceUrl != null || showSourceBrowser) {
SmallIconButton(
SmallPlainButton(
onClick = {
if (browsingDetail) browsingDetail = false
else if (showSourceBrowser) showSourceBrowser = false
else browsingSourceUrl = null
},
imageVector = Icons.AutoMirrored.Filled.ArrowBack
icon = Icons.AutoMirrored.Filled.ArrowBack
)
} else if (selectingSetUrl != null) {
SmallIconButton(
SmallPlainButton(
onClick = { selectingSetUrl = null },
imageVector = Icons.AutoMirrored.Filled.ArrowBack
icon = Icons.AutoMirrored.Filled.ArrowBack
)
}
},
endAction = {
if (browsingDetail && browseTab == 2 && browseModuleType == "buttonGroup" && selectedKindTitles.isNotEmpty()) {
SmallIconButton(
SmallPlainButton(
onClick = { showAddButtonGroupDialog = true },
imageVector = Icons.Default.Check
icon = Icons.Default.Check
)
} else if ((showSourceBrowser || browsingSourceUrl != null) && !browsingDetail) {
var expanded by remember { mutableStateOf(false) }
Box {
SmallIconButton(
SmallPlainButton(
onClick = { expanded = true },
imageVector = Icons.Default.FilterList
icon = Icons.Default.FilterList
)
RoundDropdownMenu(
expanded = expanded,
@@ -72,7 +72,7 @@ import io.legado.app.ui.widget.components.LoadMoreFooter
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookGridItem
import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
@@ -82,6 +82,7 @@ import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.utils.sendToClip
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -132,7 +133,7 @@ fun HomepageScreen(
}
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
viewModel.effects.collectLatest { effect ->
when (effect) {
is HomepageEffect.NavigateToBookInfo ->
onBookClick(
@@ -717,9 +718,9 @@ private fun ModuleHeader(
modifier = Modifier.weight(1f),
)
if (onNavigate != null) {
SmallTonalIconButton(
SmallTonalButton(
onClick = onNavigate,
imageVector = Icons.AutoMirrored.Filled.ArrowForward
icon = Icons.AutoMirrored.Filled.ArrowForward
)
}
}
@@ -338,6 +338,12 @@ class HomepageViewModel(
}
}
override fun onCleared() {
super.onCleared()
loadJobs.values.forEach { it.cancel() }
loadJobs.clear()
}
private suspend fun syncModulesFromSource(source: BookSource) {
val json = source.homepageModules ?: return
ensureSetForSource(source.bookSourceUrl, source.bookSourceName)
@@ -26,7 +26,7 @@ import io.legado.app.ui.main.homepage.HomepageModuleManageUi
import io.legado.app.ui.main.homepage.HomepageViewModel
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.divider.PillDivider
@@ -129,13 +129,13 @@ fun SetDetailPage(
}
},
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onEditModule(module) },
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = { onRequestDeleteModule(module.id) },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -160,13 +160,13 @@ fun SetDetailPage(
containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
onEnabledChange = { onToggleModule(module.id, it) },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onEditModule(module) },
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = { onRequestDeleteModule(module.id) },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -22,7 +22,7 @@ import io.legado.app.R
import io.legado.app.ui.main.homepage.HomepageSourceManageUi
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.utils.move
@@ -85,13 +85,13 @@ fun SetListPage(
}
},
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onRenameSet(set.sourceUrl) },
imageVector = Icons.Default.DriveFileRenameOutline
icon = Icons.Default.DriveFileRenameOutline
)
SmallIconButton(
SmallPlainButton(
onClick = { onDeleteSet(set.sourceUrl) },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -32,7 +32,7 @@ import io.legado.app.ui.main.homepage.HomepageModuleManageUi
import io.legado.app.ui.main.homepage.HomepageViewModel
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -170,13 +170,13 @@ fun SourceBrowseDetailPage(
}
},
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onEditModule(module) },
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = { onRequestDeleteModule(module.id) },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -212,13 +212,13 @@ fun SourceBrowseDetailPage(
containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
onEnabledChange = { onToggleModule(module.id, it) },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { onEditModule(module) },
imageVector = Icons.Default.Edit
icon = Icons.Default.Edit
)
SmallIconButton(
SmallPlainButton(
onClick = { onRequestDeleteModule(module.id) },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -334,9 +334,9 @@ fun SourceBrowseDetailPage(
onToggleSelection = { showKindSelect = true },
trailingAction = {
if (isButtonGroup && selectedKindTitles.isNotEmpty()) {
SmallIconButton(
SmallPlainButton(
onClick = { onShowAddButtonGroupDialog() },
imageVector = Icons.Default.Check
icon = Icons.Default.Check
)
}
}
@@ -35,8 +35,6 @@ import androidx.compose.material.icons.filled.Source
import androidx.compose.material.icons.filled.Web
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -54,11 +52,12 @@ import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.button.SmallTextButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import org.koin.androidx.compose.koinViewModel
@@ -81,7 +80,7 @@ fun MyScreen(
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.my),
actions = {
IconButton(
TopBarActionButton(
onClick = {
onNavigate(
PrefClickEvent.ShowMd(
@@ -89,10 +88,10 @@ fun MyScreen(
path = "appHelp"
)
)
}
) {Icon(
Icons.AutoMirrored.Filled.HelpOutline, null)
}
},
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
contentDescription = null
)
},
scrollBehavior = scrollBehavior
)
@@ -251,22 +250,22 @@ fun WebServiceSettingBlock(
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.End
) {
SmallTextButton(
text = stringResource(R.string.copy_url),
imageVector = Icons.Default.ContentCopy,
SmallPlainButton(
onClick = {
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
}
},
icon = Icons.Default.ContentCopy,
text = stringResource(R.string.copy_url)
)
Spacer(modifier = Modifier.width(12.dp))
SmallTextButton(
text = stringResource(R.string.open_in_browser),
imageVector = Icons.Default.OpenInBrowser,
SmallPlainButton(
onClick = {
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
}
},
icon = Icons.Default.OpenInBrowser,
text = stringResource(R.string.open_in_browser)
)
}
}
@@ -5,8 +5,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,71 +17,54 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.ui.res.stringResource
import io.legado.app.R
import io.legado.app.ui.widget.components.AppFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.legado.app.R
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.ToggleChip
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import org.koin.androidx.compose.koinViewModel
@Composable
@@ -369,9 +350,9 @@ fun GroupSelector(
}
}
}
MediumIconButton(
MediumPlainButton(
onClick = onManageClick,
imageVector = Icons.Default.Settings
icon = Icons.Default.Settings
)
}
}
@@ -1,7 +1,6 @@
package io.legado.app.ui.rss.article
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,13 +10,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.Check
@@ -25,7 +22,6 @@ import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.Dataset
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Style
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -39,6 +35,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -51,10 +48,9 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.button.series.SmallToggleButton
import io.legado.app.ui.widget.components.button.series.ToggleStyle
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
@@ -65,6 +61,8 @@ import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -242,10 +240,11 @@ fun RssSortScreen(
if (BookshelfConfig.shouldShowExpandButton) {
Box {
SmallOutlinedIconToggleButton(
SmallToggleButton(
checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it },
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
style = ToggleStyle.Outlined,
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
@@ -321,9 +320,9 @@ private fun RssReadRecordSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.read_record),
endAction = {
SmallIconButton(
SmallPlainButton(
onClick = onClear,
imageVector = Icons.Default.CleaningServices,
icon = Icons.Default.CleaningServices,
contentDescription = stringResource(R.string.clear)
)
}
@@ -33,7 +33,7 @@ import io.legado.app.data.entities.RssStar
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.dialog.TextListInputDialog
import io.legado.app.ui.widget.components.divider.PillDivider
@@ -229,9 +229,9 @@ fun RssFavoritesScreen(
val openAction = {
onOpenRead(rssStar.title, rssStar.origin, rssStar.link, null)
}
SmallIconButton(
SmallPlainButton(
onClick = openAction,
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
icon = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Open"
)
},
@@ -58,7 +58,7 @@ import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -422,9 +422,9 @@ private fun FavoriteEditSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.favorite),
endAction = {
SmallIconButton(
SmallPlainButton(
onClick = onDelete,
imageVector = Icons.Default.CleaningServices,
icon = Icons.Default.CleaningServices,
contentDescription = stringResource(R.string.delete)
)
}
@@ -5,7 +5,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
@@ -13,13 +12,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -45,7 +41,7 @@ import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.DraggableSelectionHandler
import io.legado.app.ui.widget.components.GroupManageBottomSheet
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.dialog.TextListInputDialog
import io.legado.app.ui.widget.components.divider.PillDivider
@@ -364,9 +360,9 @@ fun RssSourceScreen(
onEnabledChange = { enabled -> viewModel.update(item.source.copy(enabled = enabled)) },
onClickEdit = { onEditSource(item.source) },
trailingAction = {
SmallIconButton(
SmallPlainButton(
onClick = { showDeleteRuleDialog = item.source },
imageVector = Icons.Default.Delete
icon = Icons.Default.Delete
)
}
)
@@ -17,7 +17,6 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
@@ -46,6 +45,7 @@ import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
@@ -165,9 +165,11 @@ fun RuleSubScreen(
}
},
trailingAction = {
IconButton(onClick = { showEditDialog = ruleSub }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
SmallPlainButton(
onClick = { showEditDialog = ruleSub },
icon = Icons.Default.Edit,
contentDescription = "Edit"
)
},
dropdownContent = { dismiss ->
RoundDropdownMenuItem(
@@ -35,6 +35,14 @@ fun Modifier.adaptiveVerticalPadding(): Modifier {
return this.padding(horizontal = horizontal)
}
@Composable
fun adaptiveHorizonalPadding(): PaddingValues {
val horizontal = if (ThemeResolver.isMiuixEngine(composeEngine)) 12.dp else 16.dp
return PaddingValues(
horizontal = horizontal
)
}
@Composable
fun adaptiveContentPaddingOnlyVertical(
top: Dp,
@@ -0,0 +1,41 @@
package io.legado.app.ui.widget.components
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider
@Composable
fun AppSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
onValueChangeFinished: (() -> Unit)? = null,
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixSlider(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
valueRange = valueRange,
steps = steps,
onValueChangeFinished = onValueChangeFinished
)
} else {
Slider(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
valueRange = valueRange,
steps = steps,
onValueChangeFinished = onValueChangeFinished
)
}
}
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -24,8 +23,9 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.button.SmallTonalTextButton
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
import io.legado.app.ui.widget.components.text.AnimatedTextLine
@Composable
@@ -84,10 +84,10 @@ fun EmptyMessage(
if (buttonText != null && onButtonClick != null) {
Spacer(modifier = Modifier.height(8.dp))
SmallTonalTextButton(
SmallTonalButton(
onClick = onButtonClick,
text = buttonText,
imageVector = buttonImageVector
icon = buttonImageVector
)
}
}
@@ -26,7 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.widget.components.button.SmallTextButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.SettingItem
@@ -118,13 +118,13 @@ private fun GroupItem(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
SmallTextButton(
text = stringResource(id = R.string.ok),
imageVector = Icons.Default.Check,
SmallPlainButton(
onClick = {
onUpdateGroup(group, state.text.toString())
expanded = false
}
},
icon = Icons.Default.Check,
text = stringResource(id = R.string.ok)
)
}
}
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.GSON
@@ -37,17 +37,17 @@ fun JsonRawEditor(
style = LegadoTheme.typography.labelMediumEmphasized
)
Row {
SmallIconButton(
SmallPlainButton(
onClick = {
runCatching {
val jsonElement = JsonParser.parseString(value)
onValueChange(GSON.toJson(jsonElement))
}
},
imageVector = Icons.Default.AutoFixHigh,
icon = Icons.Default.AutoFixHigh,
contentDescription = "格式化"
)
SmallIconButton(
SmallPlainButton(
onClick = {
runCatching {
val jsonElement = JsonParser.parseString(value)
@@ -55,7 +55,7 @@ fun JsonRawEditor(
onValueChange(compactGson.toJson(jsonElement))
}
},
imageVector = Icons.Default.Compress,
icon = Icons.Default.Compress,
contentDescription = "压缩"
)
}
@@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.SmallOutlinedIconButton
import io.legado.app.ui.widget.components.button.series.SmallOutlinedButton
import io.legado.app.ui.widget.components.card.TextCard
@Composable
@@ -26,12 +26,12 @@ fun ValueStepper(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SmallOutlinedIconButton(
SmallOutlinedButton(
onClick = {
val newValue = (value.toInt() - 1).toFloat().coerceIn(valueRange)
onValueChange(newValue)
},
imageVector = Icons.Default.Remove
icon = Icons.Default.Remove
)
TextCard(
cornerRadius = 8.dp,
@@ -41,12 +41,12 @@ fun ValueStepper(
backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurface
)
SmallOutlinedIconButton(
SmallOutlinedButton(
onClick = {
val newValue = (value.toInt() + 1).toFloat().coerceIn(valueRange)
onValueChange(newValue)
},
imageVector = Icons.Default.Add
icon = Icons.Default.Add
)
}
}
@@ -1,43 +0,0 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.material3.TextButton
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import io.legado.app.ui.widget.components.text.AppText
@Composable
fun AnimatedTextButton(
isLoading: Boolean,
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier
) {
TextButton(
onClick = onClick,
enabled = !isLoading,
modifier = modifier.animateContentSize()
) {
AnimatedContent(
targetState = isLoading,
contentAlignment = Alignment.Center,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "ButtonLoading"
) { loading ->
if (loading) {
AppContainedLoadingIndicator()
} else {
AppText(text)
}
}
}
}
@@ -1,217 +0,0 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@Composable
fun MediumIconButton(
onClick: () -> Unit,
imageVector: ImageVector,
tint: Color = LegadoTheme.colorScheme.onSurface,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = tint
)
}
} else {
IconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = tint
)
}
}
}
@Composable
fun MediumOutlinedIconButton(
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription
)
}
} else {
OutlinedIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
border = ButtonDefaults.outlinedButtonBorder()
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = LegadoTheme.colorScheme.onSurface
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MediumAnimatedActionButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
iconChecked: ImageVector,
iconUnchecked: ImageVector,
activeText: String,
inactiveText: String,
modifier: Modifier = Modifier
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
animationSpec = tween(150),
label = "MiuixActionButtonContainer"
)
val contentColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
animationSpec = tween(150),
label = "MiuixActionButtonContent"
)
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked,
iconUnchecked = iconUnchecked,
activeText = activeText,
inactiveText = inactiveText,
modifier = modifier,
iconSize = 24.dp,
textStyle = LegadoTheme.typography.labelMedium,
textStartPadding = 8.dp,
contentColor = contentColor,
button = { buttonModifier, onToggle, content ->
MiuixIconButton(
onClick = { onToggle(!checked) },
modifier = buttonModifier,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp),
content = content
)
}
},
icon = { imageVector, iconModifier, tint ->
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier,
tint = tint ?: Color.Unspecified
)
},
text = { label, textModifier, style, color ->
MiuixText(
text = label,
color = color ?: Color.Unspecified,
style = style,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked,
iconUnchecked = iconUnchecked,
activeText = activeText,
inactiveText = inactiveText,
modifier = modifier,
iconSize = 24.dp,
textStyle = LegadoTheme.typography.labelMedium,
textStartPadding = 8.dp,
button = { buttonModifier, onToggle, content ->
TonalToggleButton(
checked = checked,
onCheckedChange = onToggle,
modifier = buttonModifier,
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = content
)
}
},
icon = { imageVector, iconModifier, _ ->
AnimatedIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier
)
},
text = { label, textModifier, style, color ->
Text(
text = label,
modifier = textModifier,
style = style,
color = color ?: Color.Unspecified,
maxLines = 1,
softWrap = false
)
}
)
}
}
@@ -1,66 +0,0 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@Composable
fun MediumOutlinedButton(
onClick: () -> Unit,
imageVector: ImageVector,
text: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription
)
MiuixText(text = text)
}
}
} else {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
border = ButtonDefaults.outlinedButtonBorder()
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription
)
Text(
text = text,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
@@ -1,356 +0,0 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButtonShapes
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.OutlinedIconToggleButton
import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
private val SmallMiuixButtonSize = 32.dp
private val SmallMiuixIconSize = 18.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize(
IconButtonDefaults.IconButtonWidthOption.Uniform
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private val smallIconSize: androidx.compose.ui.unit.Dp
get() = IconButtonDefaults.extraSmallIconSize
@Composable
private fun SmallNoMinTouchTarget(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
content()
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallIconButton(
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(16.dp),
)
}
} else {
SmallNoMinTouchTarget {
IconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shape = IconButtonDefaults.extraSmallRoundShape,
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallOutlinedIconButton(
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
} else {
SmallNoMinTouchTarget {
OutlinedIconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = IconButtonDefaults.shapes(),
border = ButtonDefaults.outlinedButtonBorder()
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize)
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallTonalIconButton(
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixIconButton(
onClick = onClick,
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
} else {
SmallNoMinTouchTarget {
FilledTonalIconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = IconButtonDefaults.shapes(),
colors = IconButtonDefaults.filledTonalIconButtonColors()
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize)
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallOutlinedIconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
animationSpec = tween(150),
label = "MiuixToggleContainerColor"
)
val iconTint by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
animationSpec = tween(150),
label = "MiuixToggleIconTint"
)
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = containerColor
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
} else {
val defaultShape = IconButtonDefaults.extraSmallRoundShape
val pressedShape = IconButtonDefaults.extraSmallPressedShape
val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape
val toggleShapes = remember(defaultShape, checkedShape) {
IconToggleButtonShapes(
shape = defaultShape,
pressedShape = pressedShape,
checkedShape = checkedShape
)
}
SmallNoMinTouchTarget {
OutlinedIconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = toggleShapes
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallAnimatedActionButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
iconChecked: ImageVector,
iconUnchecked: ImageVector,
activeText: String,
inactiveText: String,
modifier: Modifier = Modifier
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainer,
animationSpec = tween(150),
label = "MiuixActionButtonContainer"
)
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked,
iconUnchecked = iconUnchecked,
activeText = activeText,
inactiveText = inactiveText,
modifier = modifier,
iconSize = 18.dp,
textStyle = LegadoTheme.typography.labelSmall,
textStartPadding = 6.dp,
button = { buttonModifier, onToggle, content ->
MiuixIconButton(
onClick = { onToggle(!checked) },
modifier = buttonModifier,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
content = content
)
}
},
icon = { imageVector, iconModifier, tint ->
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier,
tint = tint ?: Color.Unspecified
)
},
text = { label, textModifier, style, color ->
MiuixText(
text = label,
style = style,
color = color ?: Color.Unspecified,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
SmallNoMinTouchTarget {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked,
iconUnchecked = iconUnchecked,
activeText = activeText,
inactiveText = inactiveText,
modifier = modifier.height(36.dp),
iconSize = 16.dp,
textStyle = LegadoTheme.typography.labelSmall,
textStartPadding = 6.dp,
button = { buttonModifier, onToggle, content ->
TonalToggleButton(
checked = checked,
onCheckedChange = onToggle,
modifier = buttonModifier,
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = content
)
}
},
icon = { imageVector, iconModifier, _ ->
AnimatedIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier
)
},
text = { label, textModifier, style, color ->
Text(
text = label,
style = style,
color = color ?: Color.Unspecified,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
}
}
}
@@ -1,142 +0,0 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
@Composable
fun SmallTextButton(
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.surfaceVariant,
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 {
TextButton(
onClick = onClick,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.small
) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
@Composable
fun SmallTonalTextButton(
text: String? = null,
imageVector: ImageVector? = null,
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
) {
if (imageVector != null) {
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
if (text != null) {
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
} else {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
if (imageVector != null) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
Spacer(Modifier.width(4.dp))
if (text != null) {
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
}
@@ -1,4 +1,4 @@
package io.legado.app.ui.widget.components.button
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.RowScope
@@ -1,4 +1,4 @@
package io.legado.app.ui.widget.components.button
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
@@ -0,0 +1,173 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MediumAnimatedButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
iconChecked: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainer,
animationSpec = tween(150),
label = "MiuixAnimatedContainerColor"
)
val contentColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
animationSpec = tween(150),
label = "MiuixAnimatedContentColor"
)
if (text != null) {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked ?: icon!!,
iconUnchecked = icon ?: iconChecked!!,
activeText = text,
inactiveText = text,
modifier = modifier,
iconSize = 24.dp,
textStyle = LegadoTheme.typography.labelMedium,
textStartPadding = 8.dp,
contentColor = contentColor,
button = { buttonModifier, onToggle, content ->
MiuixIconButton(
onClick = { onToggle(!checked) },
modifier = buttonModifier,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp),
content = content
)
}
},
icon = { imageVector, iconModifier, tint ->
MiuixIcon(
tint = tint ?: Color.Unspecified,
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier
)
},
text = { label, textModifier, textStyle, color ->
MiuixText(
text = label,
color = color ?: Color.Unspecified,
style = textStyle,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
backgroundColor = containerColor
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
tint = contentColor
)
}
}
} else {
if (text != null) {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked ?: icon!!,
iconUnchecked = icon ?: iconChecked!!,
activeText = text,
inactiveText = text,
modifier = modifier.height(36.dp),
iconSize = 20.dp,
textStyle = LegadoTheme.typography.labelMedium,
textStartPadding = 8.dp,
button = { buttonModifier, onToggle, content ->
TonalToggleButton(
modifier = buttonModifier,
contentPadding = PaddingValues(horizontal = 8.dp),
checked = checked,
onCheckedChange = onToggle
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = content
)
}
},
icon = { imageVector, iconModifier, _ ->
AnimatedIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier
)
},
text = { label, textModifier, textStyle, color ->
Text(
text = label,
style = textStyle,
color = color ?: Color.Unspecified,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
FilledTonalIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
)
}
}
}
}
@@ -0,0 +1,78 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
@Composable
fun MediumOutlinedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainerHigh,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
MediumButtonContent(icon, text, contentDescription)
}
}
} else {
if (icon != null && text == null) {
OutlinedIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
border = ButtonDefaults.outlinedButtonBorder()
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = LegadoTheme.colorScheme.onSurface
)
}
} else {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
border = ButtonDefaults.outlinedButtonBorder()
) {
MediumButtonContent(icon, text, contentDescription)
}
}
}
}
@@ -0,0 +1,121 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@Composable
internal fun MediumButtonContent(
icon: ImageVector?,
text: String?,
contentDescription: String?
) {
val isMiuix = ThemeResolver.isMiuixEngine(composeEngine)
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
if (isMiuix) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription
)
} else {
Icon(
imageVector = icon,
contentDescription = contentDescription
)
}
}
if (text != null) {
if (isMiuix) {
MiuixText(text = text)
} else {
AppText(text = text)
}
}
}
}
@Composable
fun MediumPlainButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
tint: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription,
tint = tint
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceVariant,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
MediumButtonContent(icon, text, contentDescription)
}
}
} else {
if (icon != null && text == null) {
IconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = tint
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceVariant,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
MediumButtonContent(icon, text, contentDescription)
}
}
}
}
@@ -0,0 +1,152 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MediumToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
style: ToggleStyle = ToggleStyle.Outlined,
icon: ImageVector? = null,
iconChecked: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
animationSpec = tween(150),
label = "MiuixToggleContainerColor"
)
val contentColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
animationSpec = tween(150),
label = "MiuixToggleContentColor"
)
if (text != null) {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp)
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = null,
tint = contentColor
)
MiuixText(
text = text,
color = contentColor,
style = LegadoTheme.typography.labelMedium,
modifier = Modifier.padding(start = 8.dp),
maxLines = 1,
softWrap = false
)
}
}
} else {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
backgroundColor = containerColor
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
tint = contentColor
)
}
}
} else {
if (text != null) {
ToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = null
)
Text(
text = text,
style = LegadoTheme.typography.labelMedium,
modifier = Modifier.padding(start = 8.dp),
maxLines = 1,
softWrap = false
)
}
}
} else {
when (style) {
ToggleStyle.Outlined -> {
FilledTonalButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
)
}
}
ToggleStyle.Tonal -> {
FilledTonalIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
)
}
}
}
}
}
}
@@ -0,0 +1,74 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
@Composable
fun MediumTonalButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
MediumButtonContent(icon, text, contentDescription)
}
}
} else {
if (icon != null && text == null) {
FilledTonalIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
Icon(
imageVector = icon,
contentDescription = contentDescription
)
}
} else {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
MediumButtonContent(icon, text, contentDescription)
}
}
}
}
@@ -0,0 +1,176 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallAnimatedButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
iconChecked: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
animationSpec = tween(150),
label = "MiuixAnimatedContainerColor"
)
val iconTint by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
animationSpec = tween(150),
label = "MiuixAnimatedIconTint"
)
if (text != null) {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked ?: icon!!,
iconUnchecked = icon ?: iconChecked!!,
activeText = text,
inactiveText = text,
modifier = modifier,
iconSize = 18.dp,
textStyle = LegadoTheme.typography.labelSmall,
textStartPadding = 6.dp,
contentColor = iconTint,
button = { buttonModifier, onToggle, content ->
MiuixIconButton(
onClick = { onToggle(!checked) },
modifier = buttonModifier,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
content = content
)
}
},
icon = { imageVector, iconModifier, tint ->
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier,
tint = tint ?: Color.Unspecified
)
},
text = { label, textModifier, textStyle, color ->
MiuixText(
text = label,
style = textStyle,
color = color ?: Color.Unspecified,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = containerColor
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
}
} else {
SmallNoMinTouchTarget {
if (text != null) {
AnimatedActionButtonCore(
checked = checked,
onCheckedChange = onCheckedChange,
iconChecked = iconChecked ?: icon!!,
iconUnchecked = icon ?: iconChecked!!,
activeText = text,
inactiveText = text,
modifier = modifier.height(36.dp),
iconSize = 16.dp,
textStyle = LegadoTheme.typography.labelSmall,
textStartPadding = 6.dp,
button = { buttonModifier, onToggle, content ->
TonalToggleButton(
checked = checked,
onCheckedChange = onToggle,
modifier = buttonModifier,
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = content
)
}
},
icon = { imageVector, iconModifier, _ ->
AnimatedIcon(
imageVector = imageVector,
contentDescription = null,
modifier = iconModifier
)
},
text = { label, textModifier, textStyle, color ->
androidx.compose.material3.Text(
text = label,
style = textStyle,
color = color ?: Color.Unspecified,
modifier = textModifier,
maxLines = 1,
softWrap = false
)
}
)
} else {
FilledTonalIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
}
}
}
@@ -0,0 +1,93 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallOutlinedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
SmallButtonContent(icon, text, contentDescription)
}
}
} else {
SmallNoMinTouchTarget {
when {
icon != null && text == null -> {
OutlinedIconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = IconButtonDefaults.shapes(),
border = ButtonDefaults.outlinedButtonBorder()
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize)
)
}
}
else -> {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
border = ButtonDefaults.outlinedButtonBorder()
) {
SmallButtonContent(icon, text, contentDescription)
}
}
}
}
}
}
@@ -0,0 +1,161 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
internal val SmallMiuixButtonSize = 32.dp
internal val SmallMiuixIconSize = 18.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
internal fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize(
IconButtonDefaults.IconButtonWidthOption.Uniform
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
internal val smallIconSize: Dp
get() = IconButtonDefaults.extraSmallIconSize
@Composable
internal fun SmallNoMinTouchTarget(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
content()
}
}
@Composable
internal fun SmallButtonContent(
icon: ImageVector?,
text: String?,
contentDescription: String?
) {
val isMiuix = ThemeResolver.isMiuixEngine(composeEngine)
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
if (isMiuix) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(16.dp)
)
}
}
if (text != null) {
if (isMiuix) {
MiuixText(
text = text,
style = LegadoTheme.typography.labelMedium
)
} else {
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
}
@Composable
fun SmallPlainButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(SmallMiuixIconSize),
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceVariant,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
SmallButtonContent(icon, text, contentDescription)
}
}
} else {
SmallNoMinTouchTarget {
when {
icon != null && text == null -> {
IconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shape = IconButtonDefaults.extraSmallRoundShape,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
else -> {
TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.small
) {
SmallButtonContent(icon, text, contentDescription)
}
}
}
}
}
}
@@ -0,0 +1,178 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButtonShapes
import androidx.compose.material3.OutlinedIconToggleButton
import androidx.compose.material3.Text
import androidx.compose.material3.TonalToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
enum class ToggleStyle { Outlined, Tonal }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
style: ToggleStyle = ToggleStyle.Outlined,
icon: ImageVector? = null,
iconChecked: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
animationSpec = tween(150),
label = "MiuixToggleContainerColor"
)
val iconTint by animateColorAsState(
targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
animationSpec = tween(150),
label = "MiuixToggleIconTint"
)
if (text != null) {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier,
enabled = enabled,
backgroundColor = containerColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = iconTint
)
MiuixText(
text = text,
style = LegadoTheme.typography.labelSmall,
color = iconTint,
modifier = Modifier.padding(start = 6.dp),
maxLines = 1,
softWrap = false
)
}
}
} else {
MiuixIconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = containerColor
) {
MiuixIcon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
}
} else {
SmallNoMinTouchTarget {
if (text != null) {
TonalToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Text(
text = text,
style = LegadoTheme.typography.labelSmall,
modifier = Modifier.padding(start = 6.dp),
maxLines = 1,
softWrap = false
)
}
}
} else {
when (style) {
ToggleStyle.Outlined -> {
val defaultShape = IconButtonDefaults.extraSmallRoundShape
val pressedShape = IconButtonDefaults.extraSmallPressedShape
val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape
val toggleShapes = remember(defaultShape, checkedShape) {
IconToggleButtonShapes(
shape = defaultShape,
pressedShape = pressedShape,
checkedShape = checkedShape
)
}
OutlinedIconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = toggleShapes
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
ToggleStyle.Tonal -> {
FilledTonalButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
) {
Icon(
imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
}
}
}
}
}
}
@@ -0,0 +1,91 @@
package io.legado.app.ui.widget.components.button.series
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SmallTonalButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
text: String? = null,
contentDescription: String? = null
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (icon != null && text == null) {
MiuixIconButton(
onClick = onClick,
modifier = modifier.size(SmallMiuixButtonSize),
enabled = enabled,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer
) {
MiuixIcon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(SmallMiuixIconSize)
)
}
} else {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
SmallButtonContent(icon, text, contentDescription)
}
}
} else {
SmallNoMinTouchTarget {
when {
icon != null && text == null -> {
FilledTonalIconButton(
onClick = onClick,
modifier = modifier.size(smallContainerSize()),
enabled = enabled,
shapes = IconButtonDefaults.shapes(),
colors = IconButtonDefaults.filledTonalIconButtonColors()
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize)
)
}
}
else -> {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
SmallButtonContent(icon, text, contentDescription)
}
}
}
}
}
}
@@ -16,13 +16,8 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,7 +37,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.AdaptiveSwitch
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.checkBox.AppCheckbox
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -50,7 +45,6 @@ import io.legado.app.ui.widget.components.text.AppText
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.ReorderableLazyListState
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun SelectionItemCard(
@@ -218,9 +212,9 @@ fun SelectionItemCardContent(
}
if (onClickEdit != null) {
SmallIconButton(
SmallPlainButton(
onClick = onClickEdit,
imageVector = AppIcons.Edit,
icon = AppIcons.Edit,
contentDescription = "Edit"
)
}
@@ -231,9 +225,9 @@ fun SelectionItemCardContent(
if (dropdownContent != null) {
Box {
SmallIconButton(
SmallPlainButton(
onClick = { showMenu = true },
imageVector = AppIcons.MoreVert,
icon = AppIcons.MoreVert,
contentDescription = "More"
)
RoundDropdownMenu(
@@ -36,8 +36,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.MediumAnimatedActionButton
import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.series.MediumToggleButton
import io.legado.app.ui.widget.components.text.AppText
import java.time.LocalDate
@@ -54,15 +54,14 @@ fun HeatmapCalendarStartAction(
currentMode: HeatmapMode,
onModeChanged: (HeatmapMode) -> Unit,
) {
MediumAnimatedActionButton(
MediumToggleButton(
checked = currentMode == HeatmapMode.TIME,
onCheckedChange = {
onModeChanged(if (it) HeatmapMode.TIME else HeatmapMode.COUNT)
},
icon = Icons.Default.FormatListNumbered,
iconChecked = Icons.Default.AccessTime,
iconUnchecked = Icons.Default.FormatListNumbered,
activeText = "按时长",
inactiveText = "按次数"
text = "按时长"
)
}
@@ -73,9 +72,9 @@ fun HeatmapCalendarStartAction(
fun HeatmapCalendarEndAction(
onClearDate: () -> Unit
) {
MediumOutlinedIconButton(
MediumOutlinedButton(
onClick = onClearDate,
imageVector = Icons.Outlined.Delete,
icon = Icons.Outlined.Delete,
)
}
@@ -44,7 +44,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.series.SmallPlainButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -183,9 +183,9 @@ fun <T> BatchImportDialog(
title = sheetTitle,
startAction = if (isEditing) {
{
SmallIconButton(
SmallPlainButton(
onClick = { editingIndex = null },
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
@@ -196,9 +196,9 @@ fun <T> BatchImportDialog(
{
Row {
topBarActions()
SmallIconButton(
SmallPlainButton(
onClick = { onToggleAll(!allSelected) },
imageVector = Icons.Default.SelectAll,
icon = Icons.Default.SelectAll,
contentDescription = if (allSelected) "全不选" else "全选"
)
}
@@ -371,9 +371,9 @@ fun ImportItemRow(
modifier = Modifier.padding(end = 4.dp)
)
SmallIconButton(
SmallPlainButton(
onClick = onInfoClick,
imageVector = Icons.Default.Info,
icon = Icons.Default.Info,
contentDescription = "详情"
)
}
@@ -23,7 +23,7 @@ import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.LogUtils
@@ -43,12 +43,12 @@ fun AppLogSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.log),
endAction = {
MediumIconButton(
MediumPlainButton(
onClick = {
AppLog.clear()
logs = emptyList()
},
imageVector = Icons.Default.DeleteSweep
icon = Icons.Default.DeleteSweep
)
}
) {
@@ -10,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -65,7 +63,7 @@ fun RowScope.OptionCard(
.weight(1f)
.height(100.dp),
cornerRadius = 12.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
containerColor = LegadoTheme.colorScheme.onSheetContent,
elevation = 2.dp
) {
Column(
@@ -13,15 +13,8 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -30,19 +23,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch
/**
@@ -84,17 +74,17 @@ fun <T> RuleEditSheet(
AppModalBottomSheet(
title = title,
startAction = {
MediumIconButton(
MediumPlainButton(
onClick = onDismissRequest,
imageVector = Icons.Default.Close,
icon = Icons.Default.Close,
contentDescription = "Close",
)
},
endAction = {
Box{
MediumIconButton(
MediumPlainButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
icon = Icons.Default.MoreVert,
contentDescription = "More"
)
RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
@@ -1,9 +1,13 @@
package io.legado.app.ui.widget.components.topbar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -16,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LocalHazeState
@@ -81,7 +86,9 @@ fun GlassMediumFlexibleTopAppBar(
}
val finalModifier = if (hazeState != null) {
modifier.background(color = animatedColor).responsiveHazeEffect(state = hazeState)
modifier
.background(color = animatedColor)
.responsiveHazeEffect(state = hazeState)
} else {
modifier.background(color = animatedColor)
}
@@ -116,7 +123,7 @@ fun GlassMediumFlexibleTopAppBar(
AdaptiveAnimatedText(
text = title,
useCharMode = useCharMode,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
},
@@ -126,7 +133,13 @@ fun GlassMediumFlexibleTopAppBar(
}
},
navigationIcon = navigationIcon,
actions = actions,
actions = {
Box(modifier = Modifier.padding(end = 12.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { actions() }
}
},
scrollBehavior = (scrollBehavior as? M3GlassScrollBehavior)?.m3Behavior,
colors = transparentColors
)
@@ -153,7 +166,15 @@ fun GlassMediumFlexibleTopAppBar(
}
},
navigationIcon = navigationIcon,
actions = actions,
actions = {
Box(modifier = Modifier.padding(end = 12.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
actions()
}
}
},
scrollBehavior = (scrollBehavior as? M3GlassScrollBehavior)?.m3Behavior,
colors = transparentColors
)
@@ -30,13 +30,13 @@ import io.legado.app.R
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.button.AnimatedActionButtonCore
import io.legado.app.ui.widget.components.button.AnimatedIcon
import io.legado.app.ui.widget.components.button.series.AnimatedActionButtonCore
import io.legado.app.ui.widget.components.button.series.AnimatedIcon
import io.legado.app.ui.widget.components.icon.AppIcons
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.Text as MiuixText
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
import top.yukonga.miuix.kmp.basic.Text as MiuixText
@Composable
private fun TopBarButton(
@@ -112,7 +112,7 @@ fun TopBarActionButton(
onClick = onClick,
imageVector = imageVector,
contentDescription = contentDescription,
modifier = modifier.padding(end = 12.dp)
modifier = modifier
)
} else {
IconButton(
@@ -140,16 +140,10 @@ fun TopBarAnimatedActionButton(
modifier: Modifier = Modifier
) {
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
val containerColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
animationSpec = tween(150),
label = "MiuixActionButtonContainer"
)
val contentColor by animateColorAsState(
targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
targetValue = if (checked) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.onSurface,
animationSpec = tween(150),
label = "MiuixActionButtonContainer"
label = "MiuixActionButtonContent"
)
AnimatedActionButtonCore(
@@ -167,8 +161,7 @@ fun TopBarAnimatedActionButton(
button = { buttonModifier, onToggle, content ->
MiuixIconButton(
onClick = { onToggle(!checked) },
modifier = buttonModifier,
backgroundColor = containerColor
modifier = buttonModifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1,24 +1,41 @@
package io.legado.app.web.socket
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.domain.model.BookSearchScope
import io.legado.app.domain.model.MatchMode
import io.legado.app.domain.usecase.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.SearchBooksUseCase
import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.*
import kotlinx.coroutines.*
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.isJson
import io.legado.app.utils.printOnDebug
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import splitties.init.appCtx
class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) : CoroutineScope by session {
private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
private val localPreferencesRepository: LocalPreferencesRepository by lazy {
GlobalContext.get().get()
}
private val searchControl = BookSearchControl()
private val sentBookUrls = linkedSetOf<String>()
private var searchJob: Job? = null
@@ -66,7 +83,14 @@ class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) :
keyword = key,
page = 1,
scope = BookSearchScope(AppConfig.searchScope),
precision = appCtx.getPrefBoolean(PreferKey.precisionSearch),
matchMode = MatchMode.of(
localPreferencesRepository
.getPreference(
LocalPreferencesKeys.MATCH_MODE,
MatchMode.DEFAULT.value
)
.first()
),
concurrency = OtherConfig.threadCount,
),
searchControl
+4 -2
View File
@@ -24,7 +24,7 @@
<string name="dialog_setting">去设置</string>
<string name="tip_cannot_jump_setting_page">无法跳转至设置界面</string>
<string name="manual_input">手动输入</string>
<string name="manual_input">输入URL</string>
<string name="preferred_contrast">偏好对比度</string>
<string name="contrast_source_system_summary">来源:系统对比度</string>
<string name="contrast_source_manual_summary">来源:手动输入</string>
@@ -899,7 +899,7 @@
<string name="body_title">正文标题</string>
<string name="show_hide">显示/隐藏</string>
<string name="header_footer">页眉与页脚</string>
<string name="rule_subscription">规则订阅</string>
<string name="rule_subscription">规则</string>
<string name="rule_sub_empty_msg">添加大佬们提供的规则导入地址\n添加后点击可导入规则</string>
<string name="get_book_progress">拉取云端进度</string>
<string name="cover_book_progress">覆盖云端进度</string>
@@ -1648,6 +1648,8 @@
<string name="search_source_progress"> · 进度 %1$d/%2$d</string>
<string name="search_empty_scope_disable_precision">%1$s分组搜索结果为空,是否关闭精准搜索?</string>
<string name="search_empty_scope_switch_all">%1$s分组搜索结果为空,是否切换到全部分组?</string>
<string name="search_layout_list">列表布局</string>
<string name="search_layout_source_grouped">来源分组</string>
<string name="download_cache_config">下载与缓存</string>
<string name="http_cache">HTTP缓存</string>
<string name="cover_cache">封面缓存</string>
+2
View File
@@ -1684,6 +1684,8 @@
<string name="search_source_progress"> · Progress %1$d/%2$d</string>
<string name="search_empty_scope_disable_precision">%1$s group search returned no results. Disable precise search?</string>
<string name="search_empty_scope_switch_all">%1$s group search returned no results. Switch to all groups?</string>
<string name="search_layout_list">List layout</string>
<string name="search_layout_source_grouped">Source grouped</string>
<string name="download_cache_config">Download &amp; Cache</string>
<string name="http_cache">HTTP Cache</string>
<string name="cover_cache">Cover Cache</string>
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
+168
View File
@@ -0,0 +1,168 @@
import { defineConfig } from 'vitepress'
export default defineConfig({
title: '阅读',
description: 'Legado-with-MD3 使用文档',
base: '/legado-with-MD3/',
head: [
['link', { rel: 'icon', href: '/legado-with-MD3/favicon.ico' }]
],
themeConfig: {
logo: '/logo.png',
siteTitle: 'Legado Docs',
nav: [
{ text: '首页', link: '/' },
{ text: '快速开始', link: '/guide/' },
{ text: '开发文档', link: '/dev/' },
],
sidebar: {
'/guide/': [
{
text: '使用指南',
items: [
{ text: '帮助文档', link: '/guide/' },
{ text: '阅读界面', link: '/guide/reading' },
{ text: '导入源管理', link: '/guide/book-source' },
{ text: '订阅源管理', link: '/guide/rss-source' },
{ text: '替换规则', link: '/guide/replace-rule' },
],
},
{
text: '教程',
items: [
{ text: 'WebDAV 备份教程', link: '/tutorial/webdav-backup' },
{ text: 'WebDAV 书籍同步', link: '/tutorial/webdav-book' },
],
},
],
'/tutorial/': [
{
text: '使用指南',
items: [
{ text: '帮助文档', link: '/guide/' },
{ text: '阅读界面', link: '/guide/reading' },
{ text: '导入源管理', link: '/guide/book-source' },
{ text: '订阅源管理', link: '/guide/rss-source' },
{ text: '替换规则', link: '/guide/replace-rule' },
],
},
{
text: '教程',
items: [
{ text: 'WebDAV 备份教程', link: '/tutorial/webdav-backup' },
{ text: 'WebDAV 书籍同步', link: '/tutorial/webdav-book' },
],
},
],
'/dev/': [
{
text: '入门',
items: [
{ text: '规则语法详解', link: '/dev/syntax' },
{ text: 'URL 参数详解', link: '/dev/url-options' },
{ text: '源字段速查', link: '/dev/source-fields' },
{ text: '源示例', link: '/dev/examples' },
],
},
{
text: '参考',
items: [
{ text: '源规则帮助', link: '/dev/rule' },
{ text: 'JS 变量和函数', link: '/dev/js' },
{ text: 'XPath 路径表达式', link: '/dev/xpath' },
{ text: '正则表达式', link: '/dev/regex' },
],
},
{
text: '配置规范',
items: [
{ text: '请求头配置', link: '/dev/request-headers' },
{ text: '认证与登录', link: '/dev/authentication' },
{ text: '发现 URL 配置', link: '/dev/discovery-url' },
{ text: '首页模块配置', link: '/spec/homepage-modules' },
{ text: '关联书籍配置', link: '/spec/related-books' },
],
},
{
text: '扩展功能',
items: [
{ text: '源调试', link: '/dev/debug' },
{ text: '字典规则', link: '/dev/dict-rule' },
{ text: '在线朗读规则', link: '/dev/tts-rule' },
{ text: 'TXT 目录正则', link: '/dev/txt-toc' },
{ text: 'MIME 类型参考', link: '/spec/mime-types' },
],
},
],
'/spec/': [
{
text: '入门',
items: [
{ text: '规则语法详解', link: '/dev/syntax' },
{ text: 'URL 参数详解', link: '/dev/url-options' },
{ text: '源字段速查', link: '/dev/source-fields' },
{ text: '源示例', link: '/dev/examples' },
],
},
{
text: '参考',
items: [
{ text: '源规则帮助', link: '/dev/rule' },
{ text: 'JS 变量和函数', link: '/dev/js' },
{ text: 'XPath 路径表达式', link: '/dev/xpath' },
{ text: '正则表达式', link: '/dev/regex' },
],
},
{
text: '配置规范',
items: [
{ text: '请求头配置', link: '/dev/request-headers' },
{ text: '认证与登录', link: '/dev/authentication' },
{ text: '发现 URL 配置', link: '/dev/discovery-url' },
{ text: '首页模块配置', link: '/spec/homepage-modules' },
{ text: '关联书籍配置', link: '/spec/related-books' },
],
},
{
text: '扩展功能',
items: [
{ text: '源调试', link: '/dev/debug' },
{ text: '字典规则', link: '/dev/dict-rule' },
{ text: '在线朗读规则', link: '/dev/tts-rule' },
{ text: 'TXT 目录正则', link: '/dev/txt-toc' },
{ text: 'MIME 类型参考', link: '/spec/mime-types' },
],
},
],
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/HapeLee/legado-with-MD3' },
],
footer: {
message: '基于 Apache-2.0 许可发布',
copyright: 'Copyright © 2026 Legado',
},
search: {
provider: 'local',
},
outline: {
level: [2, 3],
label: '页面导航',
},
docFooter: {
prev: '上一页',
next: '下一页',
},
lastUpdated: {
text: '最后更新于',
},
editLink: {
pattern: 'https://github.com/HapeLee/legado-with-MD3/edit/main/docs/:path',
text: '在 GitHub 上编辑此页面',
},
},
lastUpdated: true,
markdown: {
lineNumbers: true,
},
})
+129
View File
@@ -0,0 +1,129 @@
# 认证与登录 (Authentication)
[[toc]]
Legado 支持通过 CookieJar、登录 UI 表单和登录 URL 脚本三种方式实现网站认证。
## 1. CookieJar
启用后自动保存每次响应头中的 `Set-Cookie` 值,适用于需要 session 的网站(如验证码图片)。
在源编辑器中勾选「启用 CookieJar」即可,无需额外配置。
## 2. 登录 UI (Login UI)
通过 JSON 数组定义登录表单,替代内置 WebView 登录方式。
### 字段定义
| 字段 | 类型 | 必须 | 说明 |
|:---------|:---------|:---|:-----------------------|
| `name` | `String` | 是 | 字段名称,显示为输入框标签 |
| `type` | `String` | 是 | 字段类型 |
| `action` | `String` | 否 | 仅 `button` 类型:点击时执行的动作 |
| `style` | `Object` | 否 | Flexbox 布局配置 |
### type 取值
| 值 | 说明 |
|:-----------|:---------------------------------------|
| `text` | 文本输入框 |
| `password` | 密码输入框 |
| `button` | 可点击按钮,`action` 为 URL 时打开浏览器,为函数名时调用 JS |
### JSON 示例
```json
[
{ "name": "telephone", "type": "text" },
{ "name": "password", "type": "password" },
{
"name": "注册",
"type": "button",
"action": "http://www.example.com/register"
},
{
"name": "获取验证码",
"type": "button",
"action": "getVerificationCode()",
"style": {
"layout_flexGrow": 0,
"layout_flexShrink": 1,
"layout_alignSelf": "auto",
"layout_flexBasisPercent": -1,
"layout_wrapBefore": false
}
}
]
```
### style 布局属性
| 属性 | 类型 | 说明 |
|:--------------------------|:----------|:---------------|
| `layout_flexGrow` | `Int` | 弹性增长比例 |
| `layout_flexShrink` | `Int` | 弹性收缩比例 |
| `layout_alignSelf` | `String` | 交叉轴对齐方式 |
| `layout_flexBasisPercent` | `Int` | 基础尺寸百分比,-1 为自动 |
| `layout_wrapBefore` | `Boolean` | 是否强制换行 |
::: tip 版本变更
从版本 20221113 起,按钮支持调用「登录 URL」规则中的函数,必须实现 `login` 函数。
:::
## 3. 登录 URL (Login URL)
可填写登录链接或实现登录逻辑的 JavaScript。配合登录 UI 使用时,需要实现 `login` 函数。
### JS 示例
```js
function login() {
java.log("模拟登录请求");
java.log(source.getLoginInfoMap());
}
function getVerificationCode() {
java.log("登录UI按钮:获取到手机号码" + result.get("telephone"))
}
```
### 获取登录信息
在登录按钮函数和 `login` 函数中,可通过以下方式获取用户输入:
```js
// 登录按钮函数中
result.get("telephone")
// login 函数中
source.getLoginInfo()
source.getLoginInfoMap().get("telephone")
```
### source 登录相关方法
| 方法 | 返回值 | 说明 |
|:-------------------------------|:----------|:----------|
| `login()` | — | 执行登录 |
| `getHeaderMap(hasLoginHeader)` | `Map` | 获取请求头 |
| `getLoginHeader()` | `String?` | 获取登录头字符串 |
| `getLoginHeaderMap()` | `Map?` | 获取登录头 Map |
| `putLoginHeader(header)` | — | 保存登录头 |
| `removeLoginHeader()` | — | 清除登录头 |
| `setVariable(variable)` | — | 设置源变量 |
| `getVariable()` | `String?` | 获取源变量 |
### AnalyzeUrl 函数
以下函数仅在「登录检查 JS」规则中有效:
| 方法 | 返回值 | 说明 |
|:---------------------------------------------------|:--------------|:--------------------|
| `initUrl()` | — | 重新解析 URL |
| `getHeaderMap().putAll(source.getHeaderMap(true))` | — | 重新设置登录头 |
| `getStrResponse(jsStr, sourceRegex)` | `StrResponse` | 返回文本类型的访问结果 |
| `getResponse()` | `Response` | 返回 Response 类型的访问结果 |
## 4. 登录检查 JS (Login Check)
在源的「登录检查 JS」字段中填写 JavaScript,用于判断当前登录状态。返回 `true` 表示已登录,返回其他值会触发重新登录流程。

Some files were not shown because too many files have changed in this diff Show More