[优化] 优化界面

This commit is contained in:
HapeLee
2026-04-18 02:25:18 +08:00
parent 347995a676
commit c6ed112aa3
60 changed files with 1908 additions and 1832 deletions
@@ -13,6 +13,8 @@ import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchRepositoryImpl
import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.ExploreKindUiUseCase
@@ -30,6 +32,7 @@ import io.legado.app.ui.book.import.local.ImportBookViewModel
import io.legado.app.ui.book.import.remote.RemoteBookViewModel
import io.legado.app.ui.book.info.BookInfoViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.search.SearchViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
@@ -69,6 +72,7 @@ val appModule = module {
single<UploadRepository> { DirectLinkUploadRepository() }
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<ImageLoader> {
ImageLoader.Builder(get())
@@ -111,6 +115,7 @@ val appModule = module {
viewModelOf(::ChangeBookSourceViewModel)
viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel)
viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
@@ -50,10 +50,9 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
import io.legado.app.ui.widget.components.bookmark.BookmarkItem
@@ -212,7 +211,7 @@ fun AllBookmarkScreen(
) { state ->
when (state) {
"LOADING" -> {
EmptyMessageView(
EmptyMessage(
message = "加载中...",
isLoading = true,
modifier = Modifier
@@ -225,7 +224,7 @@ fun AllBookmarkScreen(
}
"EMPTY" -> {
EmptyMessageView(
EmptyMessage(
message = "没有书签!",
modifier = Modifier
.fillMaxSize()
@@ -92,6 +92,8 @@ import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
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.book.SearchBookGridItem
import io.legado.app.ui.widget.components.book.SearchBookListItem
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
@@ -521,111 +523,12 @@ fun ExploreBookItem(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val shelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
val badge: (@Composable RowScope.() -> Unit)?
= when (shelfState) {
BookShelfState.IN_SHELF -> {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "已在书架",
modifier = Modifier.size(12.dp)
)
}
}
BookShelfState.SAME_NAME_AUTHOR -> {
{
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "同名书籍",
modifier = Modifier.size(12.dp)
)
}
}
BookShelfState.NOT_IN_SHELF -> null
}
Row(
SearchBookListItem(
book = book,
shelfState = shelfState,
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Cover(
path = book.coverUrl,
modifier = Modifier.width(72.dp),
badgeContent = badge)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)) {
AppText(
text = book.name,
style = LegadoTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row {
AppText(
text = book.author,
style = LegadoTheme.typography.bodySmall,
maxLines = 1
)
val latestChapter = book.latestChapterTitle
if (!latestChapter.isNullOrEmpty()) {
AppText(
text = "",
style = LegadoTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1
)
AppText(
text = "最新: $latestChapter",
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(4.dp))
val intro = book.intro?.replace("\\s+".toRegex(), "") ?: ""
if (intro.isNotEmpty()) {
AppText(
text = intro,
style = LegadoTheme.typography.labelSmall,
color = Color.Gray,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis
)
}
val kinds = book.getKindList()
if (kinds.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
kinds.forEach { kind ->
TagChip(text = kind)
Spacer(modifier = Modifier.width(6.dp))
}
}
}
}
}
)
}
@Composable
@@ -635,71 +538,12 @@ fun ExploreBookGridItem(
shelfState: Flow<BookShelfState>,
modifier: Modifier = Modifier
) {
val shelfState by shelfState.collectAsState(initial = BookShelfState.NOT_IN_SHELF)
val badgeText: String? = when (shelfState) {
BookShelfState.IN_SHELF -> "已在书架"
BookShelfState.SAME_NAME_AUTHOR -> "同名书籍"
BookShelfState.NOT_IN_SHELF -> null
}
val content: (@Composable RowScope.() -> Unit)? = if (!badgeText.isNullOrBlank()) {
{
AppText(
text = badgeText,
style = LegadoTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
fontSize = 9.sp
)
)
}
} else {
null
}
Column(
SearchBookGridItem(
book = book,
shelfState = shelfState,
onClick = onClick,
modifier = modifier
.width(IntrinsicSize.Min)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(4.dp)
) {
Cover(
path = book.coverUrl,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(12 / 17f),
badgeContent = content
)
Spacer(modifier = Modifier.height(4.dp))
AppText(
text = book.name,
style = LegadoTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// 简单的标签组件
@Composable
fun TagChip(text: String) {
Surface(
color = LegadoTheme.colorScheme.cardContainer,
shape = RoundedCornerShape(4.dp)
) {
AppText(
text = text,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onCardContainer
)
}
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -8,18 +8,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -34,12 +32,16 @@ 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.lib.dialogs.alert
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.card.GlassCard
import io.legado.app.ui.widget.components.cover.BookCover
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.SelectImageContract
@@ -58,10 +60,35 @@ fun GroupEditSheet(
onDismissRequest: () -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest) {
var coverPath by remember(group) { mutableStateOf(group?.cover) }
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
startAction = if (group != null && (group.groupId > 0 || group.groupId == Long.MIN_VALUE)) {
{
GroupDeleteAction(
group = group,
onDismissRequest = onDismissRequest,
viewModel = viewModel
)
}
} else {
null
},
endAction = {
GroupResetCoverAction(
group = group,
onCoverPathChange = { coverPath = it },
viewModel = viewModel
)
}
) {
GroupEditContent(
group = group,
onDismissRequest = onDismissRequest,
coverPath = coverPath,
onCoverPathChange = { coverPath = it },
viewModel = viewModel
)
}
@@ -71,11 +98,12 @@ fun GroupEditSheet(
fun GroupEditContent(
group: BookGroup? = null,
onDismissRequest: () -> Unit,
coverPath: String?,
onCoverPathChange: (String?) -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
val context = LocalContext.current
var groupName by remember(group) { mutableStateOf(group?.groupName ?: "") }
var coverPath by remember(group) { mutableStateOf(group?.cover) }
var enableRefresh by remember(group) { mutableStateOf(group?.enableRefresh ?: true) }
var selectedSortIndex by remember(group) { mutableIntStateOf(group?.bookSort ?: -1) }
@@ -96,7 +124,7 @@ fun GroupEditContent(
FileOutputStream(file).use { output ->
context.contentResolver.openInputStream(uri)?.use { it.copyTo(output) }
}
coverPath = file.absolutePath
onCoverPathChange(file.absolutePath)
}
} catch (e: Exception) {
appCtx.toastOnUi(e.localizedMessage)
@@ -107,6 +135,7 @@ fun GroupEditContent(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -129,23 +158,29 @@ fun GroupEditContent(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
AppTextField(
value = groupName,
onValueChange = { groupName = it },
label = { AppText(stringResource(R.string.group_name)) },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.group_name),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
CompactDropdownSettingItem(
title = stringResource(R.string.sort),
selectedValue = selectedSortIndex.toString(),
displayEntries = sortOptions,
entryValues = sortEntryValues,
onValueChange = {
selectedSortIndex = it.toInt()
}
)
GlassCard(
containerColor = LegadoTheme.colorScheme.onSheetContent,
) {
CompactDropdownSettingItem(
title = stringResource(R.string.sort),
selectedValue = selectedSortIndex.toString(),
color = LegadoTheme.colorScheme.onSheetContent,
displayEntries = sortOptions,
entryValues = sortEntryValues,
onValueChange = {
selectedSortIndex = it.toInt()
}
)
}
}
}
@@ -159,79 +194,91 @@ fun GroupEditContent(
Spacer(modifier = Modifier.height(16.dp))
Row(
ConfirmDismissButtonsRow(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (group != null && (group.groupId > 0 || group.groupId == Long.MIN_VALUE)) {
OutlinedIconButton(onClick = {
context.alert(R.string.delete, R.string.sure_del) {
yesButton {
viewModel.delGroup(group) {
onDismissRequest()
}
}
noButton()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
OutlinedIconButton(onClick = {
if (group != null) {
viewModel.clearCover(group) {
coverPath = null
appCtx.toastOnUi("封面已重置")
onDismiss = onDismissRequest,
onConfirm = {
if (groupName.isEmpty()) {
appCtx.toastOnUi("分组名称不能为空")
} else {
if (group != null && group.groupId != 0b1L) {
viewModel.upGroup(
group.copy(
groupName = groupName,
cover = coverPath,
bookSort = selectedSortIndex,
enableRefresh = enableRefresh
)
) {
onDismissRequest()
}
} else {
coverPath = null
}
}) {
Icon(Icons.Default.Restore, contentDescription = stringResource(R.string.reset))
}
}
Spacer(modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onDismissRequest) {
AppText(stringResource(R.string.cancel))
}
Button(onClick = {
if (groupName.isEmpty()) {
appCtx.toastOnUi("分组名称不能为空")
} else {
if (group != null) {
viewModel.upGroup(
group.copy(
groupName = groupName,
cover = coverPath,
bookSort = selectedSortIndex,
enableRefresh = enableRefresh
)
) {
onDismissRequest()
}
} else {
viewModel.addGroup(
groupName,
selectedSortIndex,
enableRefresh,
coverPath
) {
onDismissRequest()
}
viewModel.addGroup(
groupName,
selectedSortIndex,
enableRefresh,
coverPath
) {
onDismissRequest()
}
}
}) {
AppText(stringResource(R.string.ok))
}
}
}
},
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.ok)
)
}
}
@Composable
fun GroupDeleteAction(
group: BookGroup,
onDismissRequest: () -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
var showDeleteDialog by remember { mutableStateOf(false) }
MediumIconButton(
onClick = {
showDeleteDialog = true
},
imageVector = Icons.Default.Delete
)
AppAlertDialog(
show = showDeleteDialog,
onDismissRequest = { showDeleteDialog = false },
title = stringResource(R.string.delete),
text = stringResource(R.string.sure_del),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDeleteDialog = false
viewModel.delGroup(group) {
onDismissRequest()
}
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDeleteDialog = false }
)
}
@Composable
fun GroupResetCoverAction(
group: BookGroup? = null,
onCoverPathChange: (String?) -> Unit,
viewModel: GroupViewModel = koinViewModel()
) {
MediumIconButton(
onClick = {
if (group != null) {
viewModel.clearCover(group) {
onCoverPathChange(null)
appCtx.toastOnUi("封面已重置")
}
} else {
onCoverPathChange(null)
}
},
imageVector = Icons.Default.Restore
)
}
@@ -1,6 +1,5 @@
package io.legado.app.ui.book.import.local
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -29,7 +28,6 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.CircularProgressIndicator
@@ -53,23 +51,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.ui.config.importBookConfig.ImportBookConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.EmptyMessageView
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
@@ -83,15 +73,9 @@ import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.ConvertUtils
import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.FileDoc
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.isUri
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.takePersistablePermissionSafely
import io.legado.app.utils.toastOnUi
import org.koin.androidx.compose.koinViewModel
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -222,7 +206,7 @@ private fun ImportBookContent(
}
state.items.isEmpty() -> {
EmptyMessageView(
EmptyMessage(
modifier = Modifier.fillMaxSize(),
message = stringResource(R.string.empty_msg_import_book)
)
@@ -39,16 +39,11 @@ import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.CloudSync
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
@@ -80,11 +75,14 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AppRadioButton
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.EmptyMessageView
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.TopBarActionButton
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
@@ -93,7 +91,6 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.ConvertUtils
import io.legado.app.utils.FileDoc
import io.legado.app.utils.isUri
import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.toastOnUi
@@ -173,9 +170,10 @@ fun RemoteBookScreen(
},
endAction = if (showSheet is RemoteBookSheet.Servers) {
{
IconButton(onClick = { showSheet = RemoteBookSheet.ServerConfig(null) }) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add))
}
MediumIconButton(
onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
imageVector = Icons.Default.Add
)
}
} else {
null
@@ -265,9 +263,10 @@ fun RemoteBookScreen(
onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) },
searchPlaceholder = "搜索",
topBarActions = {
IconButton(onClick = { showSheet = RemoteBookSheet.Servers }) {
Icon(Icons.Default.Storage, contentDescription = "配置服务器")
}
TopBarActionButton(
onClick = { showSheet = RemoteBookSheet.Servers },
imageVector = Icons.Default.Storage
)
},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
@@ -335,7 +334,7 @@ fun RemoteBookScreen(
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
EmptyMessageView(
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
@@ -453,7 +452,7 @@ private fun ServerItem(
} else {
null
},
containerColor = LegadoTheme.colorScheme.surface,
containerColor = LegadoTheme.colorScheme.onSheetContent,
selectedContainerColor = LegadoTheme.colorScheme.secondaryContainer,
modifier = modifier
.fillMaxWidth()
@@ -483,6 +482,7 @@ private fun ServerConfigSheetContent(
AppTextField(
value = name,
onValueChange = { name = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "名称",
modifier = Modifier.fillMaxWidth(),
singleLine = true
@@ -491,6 +491,7 @@ private fun ServerConfigSheetContent(
AppTextField(
value = url,
onValueChange = { url = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "URL",
modifier = Modifier.fillMaxWidth(),
singleLine = true
@@ -499,6 +500,7 @@ private fun ServerConfigSheetContent(
AppTextField(
value = username,
onValueChange = { username = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "用户名",
modifier = Modifier.fillMaxWidth(),
singleLine = true
@@ -507,6 +509,7 @@ private fun ServerConfigSheetContent(
AppTextField(
value = password,
onValueChange = { password = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = "密码",
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -523,33 +526,23 @@ private fun ServerConfigSheetContent(
Spacer(modifier = Modifier.height(24.dp))
Row(
ConfirmDismissButtonsRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
AppText(stringResource(android.R.string.cancel))
}
Button(
onClick = {
val newServer = server?.copy(name = name) ?: Server(name = name)
val newConfig = JSONObject().apply {
put("url", url)
put("username", username)
put("password", password)
}
newServer.config = newConfig.toString()
onSave(newServer)
},
modifier = Modifier.weight(1f),
enabled = name.isNotBlank() && url.isNotBlank()
) {
AppText("保存")
}
}
onDismiss = onCancel,
onConfirm = {
val newServer = server?.copy(name = name) ?: Server(name = name)
val newConfig = JSONObject().apply {
put("url", url)
put("username", username)
put("password", password)
}
newServer.config = newConfig.toString()
onSave(newServer)
},
dismissText = stringResource(android.R.string.cancel),
confirmText = "保存",
confirmEnabled = name.isNotBlank() && url.isNotBlank()
)
}
}
@@ -3,7 +3,6 @@ package io.legado.app.ui.book.info
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -43,6 +42,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Image
@@ -53,13 +53,14 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
@@ -78,6 +79,7 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
@@ -89,6 +91,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import coil.compose.AsyncImage
import io.legado.app.R
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
@@ -105,11 +108,14 @@ import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LocalHazeState
import io.legado.app.ui.theme.ProvideThemeOverride
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.ThemeOverrideState
import io.legado.app.ui.theme.rememberImageSeedColor
import io.legado.app.ui.theme.rememberThemeOverride
import io.legado.app.ui.theme.responsiveHazeEffect
import io.legado.app.ui.theme.responsiveHazeEffectFixedStyle
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
@@ -123,10 +129,12 @@ import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
import io.legado.app.ui.widget.components.text.AnimatedTextLine
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
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.MiuixGlassScrollBehavior
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.startActivity
@@ -134,6 +142,7 @@ import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import org.koin.androidx.compose.koinViewModel
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@@ -162,32 +171,12 @@ private fun BookInfoScreenContent(
onBack: () -> Unit,
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val miuixScrollBehavior = if (isMiuix) {
GlassTopAppBarDefaults.defaultScrollBehavior() as? MiuixGlassScrollBehavior
val scrollBehavior = if (isMiuix) {
MiuixGlassScrollBehavior(MiuixScrollBehavior())
} else {
null
M3GlassScrollBehavior(TopAppBarDefaults.exitUntilCollapsedScrollBehavior())
}
val miuixCollapsedFraction = miuixScrollBehavior?.collapsedFraction ?: 0f
val listState = rememberLazyListState()
val colorThresholdPx = with(LocalDensity.current) { 6.dp.roundToPx() }
val showAppBarColor by remember(listState, isMiuix, colorThresholdPx, miuixCollapsedFraction) {
derivedStateOf {
if (isMiuix) {
miuixCollapsedFraction > 0.01f
} else {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > colorThresholdPx
}
}
}
val appBarColor by animateColorAsState(
targetValue = if (showAppBarColor) {
LegadoTheme.colorScheme.surfaceContainer
} else {
LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0f)
},
label = "book-info-top-bar-color",
)
val pullState = rememberPullToRefreshState()
var showMenu by rememberSaveable { mutableStateOf(false) }
@@ -196,63 +185,16 @@ private fun BookInfoScreenContent(
AppScaffold(
modifier = Modifier
.fillMaxSize()
.then(
if (miuixScrollBehavior != null) {
Modifier.nestedScroll(miuixScrollBehavior.nestedScrollConnection)
} else {
Modifier
}
),
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
if (isMiuix) {
MiuixTopAppBar(
title = "",
color = appBarColor,
navigationIcon = {
TopBarNavigationButton(
onClick = { onIntent(BookInfoIntent.BackPressed) }
)
},
actions = {
BookInfoTopBarActions(
state = state,
showMenu = showMenu,
onShowMenuChange = { showMenu = it },
onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
)
},
scrollBehavior = miuixScrollBehavior?.miuixBehavior
)
} else {
TopAppBar(
title = {
Text(
text = "",
maxLines = 1,
)
},
navigationIcon = {
TopBarNavigationButton(
onClick = { onIntent(BookInfoIntent.BackPressed) }
)
},
actions = {
BookInfoTopBarActions(
state = state,
showMenu = showMenu,
onShowMenuChange = { showMenu = it },
onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = appBarColor,
scrolledContainerColor = appBarColor,
navigationIconContentColor = LegadoTheme.colorScheme.onSurface,
titleContentColor = LegadoTheme.colorScheme.onSurface,
actionIconContentColor = LegadoTheme.colorScheme.onSurface,
),
)
}
BookInfoTransparentTopAppBar(
state = state,
showMenu = showMenu,
onShowMenuChange = { showMenu = it },
onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) },
onBackPressed = { onIntent(BookInfoIntent.BackPressed) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
@@ -322,6 +264,7 @@ private fun BookInfoScreenContent(
.padding(bottom = 24.dp)
) {
BookInfoActions(
book = book,
inBookshelf = state.inBookshelf,
onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
onTocClick = { onIntent(BookInfoIntent.TocClick) },
@@ -416,6 +359,70 @@ private fun BookInfoColorTheme(
ProvideThemeOverride(theme = theme, content = content)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BookInfoTransparentTopAppBar(
state: BookInfoUiState,
showMenu: Boolean,
onShowMenuChange: (Boolean) -> Unit,
onMenuAction: (BookInfoMenuAction) -> Unit,
onBackPressed: () -> Unit,
scrollBehavior: GlassTopAppBarScrollBehavior,
) {
val hazeState = LocalHazeState.current
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val collapsedColor = if (isMiuix) {
GlassTopAppBarDefaults.getMiuixAppBarColor()
} else {
GlassTopAppBarDefaults.scrolledContainerColor()
}
val isAtTop = scrollBehavior.collapsedFraction <= 0.001f
val resolvedColor = if (isAtTop) Color.Transparent else collapsedColor
val topBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = resolvedColor,
scrolledContainerColor = resolvedColor,
)
if (isMiuix) {
MiuixTopAppBar(
modifier = hazeState?.let { Modifier.responsiveHazeEffectFixedStyle(it) } ?: Modifier,
title = "",
subtitle = "",
navigationIcon = {
TopBarNavigationButton(onClick = onBackPressed)
},
actions = {
BookInfoTopBarActions(
state = state,
showMenu = showMenu,
onShowMenuChange = onShowMenuChange,
onMenuAction = onMenuAction,
)
},
color = resolvedColor,
scrollBehavior = (scrollBehavior as? MiuixGlassScrollBehavior)?.miuixBehavior,
)
} else {
MediumFlexibleTopAppBar(
modifier = hazeState?.let { Modifier.responsiveHazeEffectFixedStyle(it) } ?: Modifier,
title = { Text(text = "", maxLines = 1) },
navigationIcon = {
TopBarNavigationButton(onClick = onBackPressed)
},
actions = {
BookInfoTopBarActions(
state = state,
showMenu = showMenu,
onShowMenuChange = onShowMenuChange,
onMenuAction = onMenuAction,
)
},
scrollBehavior = (scrollBehavior as? M3GlassScrollBehavior)?.m3Behavior,
colors = topBarColors,
)
}
}
@Composable
private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
val imageLoader = koinInject<ImageLoader>()
@@ -470,6 +477,19 @@ private fun BookInfoTopBarActions(
@Composable
private fun BookInfoBackdrop(book: Book) {
val cover = book.getDisplayCover()
val sourceOrigin = book.origin
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val context = LocalContext.current
val imageLoader = koinInject<ImageLoader>()
val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) {
buildCoverImageRequest(
context = context,
data = cover,
sourceOrigin = sourceOrigin,
loadOnlyWifi = loadOnlyWifi,
crossfade = false,
)
}
val seedOverlay = lerp(
LegadoTheme.colorScheme.secondaryContainer,
LegadoTheme.seedColor,
@@ -478,7 +498,8 @@ private fun BookInfoBackdrop(book: Book) {
Box(modifier = Modifier.fillMaxSize()) {
if (!cover.isNullOrBlank()) {
AsyncImage(
model = cover,
model = backdropRequest,
imageLoader = imageLoader,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
@@ -567,22 +588,24 @@ private fun BookInfoOverflowMenu(
text = stringResource(R.string.to_top),
onClick = { onMenuAction(BookInfoMenuAction.Top) }
)
RoundDropdownMenuItem(
text = stringResource(R.string.allow_update),
onClick = { onMenuAction(BookInfoMenuAction.ToggleCanUpdate) },
trailingIcon = { if (book?.canUpdate == true) Icon(Icons.Default.Star, null) }
)
if (book?.isLocal == true && book.type and io.legado.app.constant.BookType.text > 0) {
if (book?.isLocal == false ){
RoundDropdownMenuItem(
text = stringResource(R.string.allow_update),
onClick = { onMenuAction(BookInfoMenuAction.ToggleCanUpdate) },
isSelected = book.canUpdate
)
}
if (book?.isLocal == true && book.type and BookType.text > 0) {
RoundDropdownMenuItem(
text = stringResource(R.string.split_long_chapter),
onClick = { onMenuAction(BookInfoMenuAction.ToggleSplitLongChapter) },
trailingIcon = { if (book.getSplitLongChapter()) Icon(Icons.Default.Star, null) }
isSelected = book.getSplitLongChapter()
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.delete_alert),
onClick = { onMenuAction(BookInfoMenuAction.ToggleDeleteAlert) },
trailingIcon = { if (io.legado.app.help.config.LocalConfig.bookInfoDeleteAlert) Icon(Icons.Default.Star, null) }
isSelected = io.legado.app.help.config.LocalConfig.bookInfoDeleteAlert
)
RoundDropdownMenuItem(
text = stringResource(R.string.clear_cache),
@@ -606,11 +629,6 @@ private fun BookInfoHeader(
onBookNameClick: (Boolean) -> Unit,
onOriginClick: () -> Unit,
) {
val labelBackground = lerp(
LegadoTheme.colorScheme.surface,
LegadoTheme.seedColor,
0.1f
).copy(alpha = 0.82f)
Column(
modifier = Modifier
.fillMaxWidth()
@@ -688,6 +706,16 @@ private fun BookInfoHeader(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
groupNames?.takeIf { it.isNotBlank() }?.let {
item(key = "group-$it") {
TextCard(
text = stringResource(R.string.group_s, it),
textStyle = LegadoTheme.typography.labelLargeEmphasized,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurface,
)
}
}
itemsIndexed(
items = kindLabels,
key = { index, label -> "kind-$index-$label" }
@@ -695,20 +723,10 @@ private fun BookInfoHeader(
TextCard(
text = label,
textStyle = LegadoTheme.typography.labelLargeEmphasized,
backgroundColor = labelBackground,
backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurface,
)
}
groupNames?.takeIf { it.isNotBlank() }?.let {
item(key = "group-$it") {
TextCard(
text = stringResource(R.string.group_s, it),
textStyle = LegadoTheme.typography.labelLargeEmphasized,
backgroundColor = labelBackground,
contentColor = LegadoTheme.colorScheme.onSurface,
)
}
}
}
}
}
@@ -717,6 +735,7 @@ private fun BookInfoHeader(
@Composable
private fun BookInfoActions(
book: Book,
inBookshelf: Boolean,
onShelfClick: () -> Unit,
onTocClick: () -> Unit,
@@ -747,7 +766,7 @@ private fun BookInfoActions(
modifier = Modifier
.fillMaxWidth()
.background(LegadoTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BookInfoActionCard(
@@ -772,7 +791,7 @@ private fun BookInfoActions(
)
BookInfoActionCard(
modifier = Modifier.weight(1f),
icon = Icons.Default.Bookmark,
icon = if (book.group > 0) Icons.Default.Bookmark else Icons.Outlined.Bookmark,
label = stringResource(R.string.change_group),
onClick = onGroupClick
)
@@ -788,14 +807,14 @@ private fun BookInfoActions(
@Composable
private fun BookInfoActionCard(
modifier: Modifier = Modifier,
icon: androidx.compose.ui.graphics.vector.ImageVector,
icon: ImageVector,
label: String,
onClick: () -> Unit
) {
GlassCard(
modifier = modifier,
onClick = onClick,
containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f),
containerColor = LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.2f),
contentColor = LegadoTheme.colorScheme.onSurface,
) {
Column(
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
@@ -65,6 +66,7 @@ import io.legado.app.ui.book.group.GroupEditSheet
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
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
@@ -134,53 +136,58 @@ fun GroupSelectSheet(
title = stringResource(R.string.group_select),
endAction = { IconButton(onClick = { editingGroup = BookGroup() }) { Icon(Icons.Default.Add, null) } }
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(groups, key = { it.groupId }) { group ->
val isSelected = selectedGroupId and group.groupId > 0
SelectionItemCard(
title = group.groupName,
isSelected = isSelected,
onToggleSelection = {
selectedGroupId = if (isSelected) {
selectedGroupId - group.groupId
} else {
selectedGroupId + group.groupId
}
},
leadingContent = {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedGroupId = if (it) {
selectedGroupId + group.groupId
} else {
selectedGroupId - group.groupId
}
Column(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.heightIn(max = 600.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(groups, key = { it.groupId }) { group ->
val isSelected = selectedGroupId and group.groupId > 0
SelectionItemCard(
title = group.groupName,
isSelected = isSelected,
onToggleSelection = {
selectedGroupId = if (isSelected) {
selectedGroupId - group.groupId
} else {
selectedGroupId + group.groupId
}
)
},
trailingAction = {
SmallIconButton(
onClick = { editingGroup = group },
imageVector = Icons.Default.Edit
)
},
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
)
},
leadingContent = {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedGroupId = if (it) {
selectedGroupId + group.groupId
} else {
selectedGroupId - group.groupId
}
}
)
},
trailingAction = {
SmallIconButton(
onClick = { editingGroup = group },
imageVector = Icons.Default.Edit
)
},
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
)
}
}
Spacer(modifier = Modifier.height(12.dp))
ConfirmDismissButtonsRow(
onDismiss = onDismissRequest,
onConfirm = { onConfirm(selectedGroupId) },
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.ok),
)
Spacer(modifier = Modifier.height(12.dp))
}
Spacer(modifier = Modifier.height(12.dp))
ConfirmDismissButtonsRow(
onDismiss = onDismissRequest,
onConfirm = { onConfirm(selectedGroupId) },
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.ok),
)
Spacer(modifier = Modifier.height(12.dp))
}
GroupEditSheet(show = editingGroup != null, group = editingGroup, onDismissRequest = { editingGroup = null })
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChangeCoverSheet(
@@ -376,7 +383,7 @@ fun ChangeSourceSheet(
)
Spacer(modifier = Modifier.height(12.dp))
if (isSearching) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(8.dp))
AppText(
text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}",
@@ -69,12 +69,11 @@ import cn.hutool.core.date.DateUtil
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.CollapsibleHeader
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.AppIconButton
@@ -248,7 +247,7 @@ fun ReadRecordScreen(
) { targetState ->
when (targetState) {
"LOADING" -> {
EmptyMessageView(
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.padding(
@@ -261,7 +260,7 @@ fun ReadRecordScreen(
}
"EMPTY" -> {
EmptyMessageView(
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.padding(
@@ -1,674 +1,73 @@
package io.legado.app.ui.book.search
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View.VISIBLE
import android.view.inputmethod.EditorInfo
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.material.search.SearchBar
import com.google.android.material.search.SearchView
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.SearchKeyword
import io.legado.app.databinding.ActivityBookSearchBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.BookShelfState
import io.legado.app.ui.about.AppLogDialog
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.utils.applyNavigationBarMargin
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import io.legado.app.utils.transaction
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import kotlin.math.abs
import org.koin.androidx.viewmodel.ext.android.viewModel
class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel>(),
BookAdapter.CallBack,
HistoryKeyAdapter.CallBack,
SearchScopeDialog.Callback,
SearchAdapter.CallBack {
class SearchActivity : BaseComposeActivity() {
override val binding by viewBinding(ActivityBookSearchBinding::inflate)
override val viewModel by viewModels<SearchViewModel>()
private val adapter by lazy { SearchAdapter(this, this) }
private val bookAdapter by lazy {
BookAdapter(this, this).apply {
setHasStableIds(true)
}
}
private val historyKeyAdapter by lazy {
HistoryKeyAdapter(this, this).apply {
setHasStableIds(true)
}
}
private val searchBar: SearchBar by lazy { binding.searchBar }
private val searchView: SearchView by lazy { binding.searchView }
private var menu: Menu? = null
private var groups: List<String>? = null
private var historyFlowJob: Job? = null
private var booksFlowJob: Job? = null
private var precisionSearchMenuItem: MenuItem? = null
private var isManualStopSearch = false
private val viewModel by viewModel<SearchViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.searchBar)
initRecyclerView()
initMaterialSearch()
initOtherView()
initData()
updateSelectedGroup()
receiptIntent(intent)
dispatchInit(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
receiptIntent(intent)
setIntent(intent)
dispatchInit(intent)
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_search, menu)
this.menu = menu
precisionSearchMenuItem = menu.findItem(R.id.menu_precision_search)
precisionSearchMenuItem?.isChecked = getPrefBoolean(PreferKey.precisionSearch)
return super.onCompatCreateOptionsMenu(menu)
override fun onResume() {
super.onResume()
viewModel.onIntent(SearchIntent.ResumeEngine)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
buildSearchScopeMenu(menu)
updateSearchScopeChip()
return super.onMenuOpened(featureId, menu)
override fun onPause() {
viewModel.onIntent(SearchIntent.PauseEngine)
super.onPause()
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_precision_search -> {
val newValue = !getPrefBoolean(PreferKey.precisionSearch)
putPrefBoolean(PreferKey.precisionSearch, newValue)
precisionSearchMenuItem?.isChecked = newValue
binding.chipPrecisionSearch.isChecked = newValue
binding.searchView.text.toString().trim().let {
binding.searchView.setText(it)
@Composable
override fun Content() {
SearchScreen(
viewModel = viewModel,
onBack = { finish() },
onOpenBookInfo = { name, author, bookUrl ->
startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
},
onOpenSourceManage = {
startActivity<BookSourceActivity>()
}
R.id.menu_search_scope -> alertSearchScope()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
R.id.menu_log -> showDialogFragment(AppLogDialog())
R.id.menu_1 -> {
viewModel.searchScope.update("")
updateSelectedGroup()
}
else -> {
if (item.groupId == R.id.menu_group_1) {
viewModel.searchScope.remove(item.title.toString())
} else if (item.groupId == R.id.menu_group_2) {
viewModel.searchScope.update(item.title.toString())
}
updateSelectedGroup()
}
}
return super.onCompatOptionsItemSelected(item)
)
}
private fun initMaterialSearch() {
binding.chipPrecisionSearch.isChecked = getPrefBoolean(PreferKey.precisionSearch)
binding.chipPrecisionSearch.setOnCheckedChangeListener { chip, isChecked ->
putPrefBoolean(PreferKey.precisionSearch, isChecked)
precisionSearchMenuItem?.isChecked = isChecked
binding.searchView.text.toString().trim().let {
binding.searchView.setText(it)
}
}
binding.chipSearchScope.setOnClickListener {
alertSearchScope()
}
binding.chipGroup.setOnClickListener { view ->
binding.chipGroup.isChecked = true
val popup = PopupMenu(this, view)
val menu = popup.menu
buildSearchScopeMenu(menu)
updateSearchScopeChip()
var fromGroup1 = false
popup.setOnMenuItemClickListener { item ->
when (item.groupId) {
R.id.menu_group_1 -> {
viewModel.searchScope.remove(item.title.toString())
fromGroup1 = true
}
R.id.menu_group_2 -> {
if (item.itemId == R.id.menu_1) {
viewModel.searchScope.update("")
fromGroup1 = true
} else {
viewModel.searchScope.update(item.title.toString())
fromGroup1 = false
}
}
else -> {
viewModel.searchScope.update(item.title.toString())
fromGroup1 = false
}
}
updateSelectedGroup()
true
}
popup.setOnDismissListener {
binding.chipGroup.isChecked = !fromGroup1
updateSearchScopeChip()
}
popup.show()
}
searchBar.setOnMenuItemClickListener { item ->
onCompatOptionsItemSelected(item)
}
searchBar.setNavigationOnClickListener {
finish()
}
searchView.setupWithSearchBar(searchBar)
binding.searchView.show()
binding.searchView.editText.requestFocus()
searchView.editText.hint = getString(R.string.search_book_key)
searchView.editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
doSearch()
true
} else {
false
}
}
searchView.editText.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN) {
doSearch()
true
} else {
false
}
}
searchView.editText.doAfterTextChanged {
viewModel.stop()
binding.fbStartStop.invisible()
upHistory(it?.toString()?.trim().orEmpty())
}
searchView.addTransitionListener { _, _, newState ->
if (newState == SearchView.TransitionState.HIDDEN) {
searchBar.setText(searchView.text)
}
}
searchView.addTransitionListener { _, oldState, newState ->
if (oldState == SearchView.TransitionState.HIDDEN && newState == SearchView.TransitionState.SHOWN) {
searchView.editText.requestFocus()
}
}
visibleInputHelp()
}
private fun doSearch() {
val key = searchView.text.toString().trim()
if (key.isNotEmpty()) {
isManualStopSearch = false
viewModel.saveSearchKey(key)
viewModel.searchKey = ""
viewModel.search(key)
visibleInputHelp()
searchView.hide()
}
}
private fun updateSelectedGroup() {
val displayNames = viewModel.searchScope.displayNames
val groupName = displayNames.firstOrNull().orEmpty()
binding.chipGroup.text = groupName.ifEmpty { getString(R.string.search_select_group) }
binding.chipGroup.isChecked = groupName.isNotEmpty()
}
private fun buildSearchScopeMenu(menu: Menu) {
menu.transaction {
menu.removeGroup(R.id.menu_group_1)
menu.removeGroup(R.id.menu_group_2)
var hasChecked = false
val searchScopeNames = viewModel.searchScope.displayNames
if (viewModel.searchScope.isSource()) {
menu.add(R.id.menu_group_1, Menu.NONE, Menu.NONE, searchScopeNames.first()).apply {
isChecked = true
hasChecked = true
}
}
val allSourceMenu =
menu.add(R.id.menu_group_2, R.id.menu_1, Menu.NONE, getString(R.string.all_source))
.apply {
if (searchScopeNames.isEmpty()) {
isChecked = true
hasChecked = true
}
}
groups?.forEach { groupName ->
if (searchScopeNames.contains(groupName)) {
menu.add(R.id.menu_group_1, Menu.NONE, Menu.NONE, groupName).apply {
isChecked = true
hasChecked = true
}
} else {
menu.add(R.id.menu_group_2, Menu.NONE, Menu.NONE, groupName)
}
}
if (!hasChecked) {
viewModel.searchScope.update("")
allSourceMenu.isChecked = true
}
menu.setGroupCheckable(R.id.menu_group_1, true, false)
menu.setGroupCheckable(R.id.menu_group_2, true, true)
}
}
private fun initRecyclerView() {
binding.rvBookshelfSearch.layoutManager = FlexboxLayoutManager(this)
binding.rvBookshelfSearch.adapter = bookAdapter
binding.rvBookshelfSearch.itemAnimator = DefaultItemAnimator()
binding.rvHistoryKey.layoutManager = FlexboxLayoutManager(this)
binding.rvHistoryKey.adapter = historyKeyAdapter
binding.rvHistoryKey.itemAnimator = DefaultItemAnimator()
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.itemAnimator = DefaultItemAnimator()
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
if (toPosition == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
})
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (!recyclerView.canScrollVertically(1)) {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition()
if (lastPosition == RecyclerView.NO_POSITION) {
return
}
val lastView = layoutManager.findViewByPosition(lastPosition)
if (lastView == null) {
scrollToBottom()
return
}
val bottom =
abs(lastView.bottom - recyclerView.height) - recyclerView.paddingBottom
if (bottom <= 1) {
scrollToBottom()
}
}
}
})
}
private fun initOtherView() {
binding.llStop.applyNavigationBarMargin()
binding.fbStartStop.setOnClickListener {
if (viewModel.isSearchLiveData.value == true) {
isManualStopSearch = true
viewModel.stop()
} else {
viewModel.search("")
}
}
binding.tvClearHistory.setOnClickListener { alertClearHistory() }
}
private fun initData() {
viewModel.searchScope.stateLiveData.observe(this) {
if (!binding.llInputHelp.isVisible) {
searchView.text.toString().trim().let {
searchView.setText(it)
}
}
}
viewModel.isSearchLiveData.observe(this) {
if (it) {
startSearch()
} else {
searchFinally()
}
}
viewModel.searchBookLiveData.observe(this) {
adapter.setItems(it)
if (it.isNullOrEmpty()) {
binding.tvEmptyMsg.visible()
} else {
binding.tvEmptyMsg.gone()
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().collect {
groups = it
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.resume()
try {
awaitCancellation()
} finally {
viewModel.pause()
}
}
}
}
/**
* 处理传入数据
*/
private fun receiptIntent(intent: Intent? = null) {
val searchScope = intent?.getStringExtra("searchScope")
searchScope?.let {
viewModel.searchScope.update(searchScope, false)
updateSearchScopeChip()
}
val key = intent?.getStringExtra("key")
if (key.isNullOrBlank()) {
searchView.editText.requestFocus()
} else {
searchView.setText(key)
searchView.postDelayed({doSearch()},500)
}
}
/**
* 滚动到底部事件
*/
private fun scrollToBottom() {
if (isManualStopSearch) {
return
}
if (viewModel.isSearchLiveData.value == false
&& viewModel.searchKey.isNotEmpty()
&& viewModel.hasMore
) {
viewModel.search("")
}
}
/**
* 打开关闭输入帮助
*/
private fun visibleInputHelp() {
upHistory(searchView.text.toString())
binding.llInputHelp.visibility = VISIBLE
}
/**
* 更新搜索历史
*/
private fun upHistory(key: String? = null) {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
if (key.isNullOrBlank()) {
binding.tvBookShow.gone()
binding.rvBookshelfSearch.gone()
} else {
appDb.bookDao.flowSearch(key).conflate().collect {
if (it.isEmpty()) {
binding.tvBookShow.gone()
binding.rvBookshelfSearch.gone()
} else {
binding.tvBookShow.visible()
binding.rvBookshelfSearch.visible()
}
bookAdapter.setItems(it)
}
}
}
historyFlowJob?.cancel()
historyFlowJob = lifecycleScope.launch {
when {
key.isNullOrBlank() -> appDb.searchKeywordDao.flowByTime()
else -> appDb.searchKeywordDao.flowSearch(key)
}.catch {
AppLog.put("搜索界面获取搜索历史数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
historyKeyAdapter.setItems(it)
if (it.isEmpty()) {
binding.tvClearHistory.invisible()
} else {
binding.tvClearHistory.visible()
}
}
}
}
/**
* 开始搜索
*/
private fun startSearch() {
binding.refreshProgressBar.visible()
binding.cdProgress.visible()
//binding.refreshProgressBar.isAutoLoading = true
binding.fbStartStop.setImageResource(R.drawable.ic_stop_black_24dp)
binding.fbStartStop.visible()
}
/**
* 搜索结束
*/
private fun searchFinally() {
TransitionManager.beginDelayedTransition(binding.root)
binding.refreshProgressBar.gone()
binding.cdProgress.gone()
if (!isManualStopSearch && viewModel.hasMore) {
binding.fbStartStop.setImageResource(R.drawable.ic_play)
} else {
binding.fbStartStop.invisible()
}
}
@SuppressLint("SetTextI18n")
override fun observeLiveBus() {
viewModel.upAdapterLiveData.observe(this) {
adapter.notifyItemRangeChanged(0, adapter.itemCount, bundleOf(it to null))
}
viewModel.searchProgressLiveData.observe(this) { (processed, total) ->
val progress = (processed * 100 / total).coerceAtMost(100)
binding.refreshProgressBar.setProgress(progress, true)
binding.tvProgress.text = "$processed / $total"
}
viewModel.searchFinishLiveData.observe(this) { isEmpty ->
if (!isEmpty || viewModel.searchScope.isAll()) return@observe
alert("搜索结果为空") {
val precisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch)
val displayScope = viewModel.searchScope.display
if (precisionSearch) {
setMessage("${displayScope}分组搜索结果为空,是否关闭精准搜索?")
yesButton {
appCtx.putPrefBoolean(PreferKey.precisionSearch, false)
precisionSearchMenuItem?.isChecked = false
viewModel.searchKey = ""
viewModel.search(searchView.text.toString())
doSearch()
}
} else {
setMessage("${displayScope}分组搜索结果为空,是否切换到全部分组?")
yesButton {
viewModel.searchScope.update("")
doSearch()
}
}
noButton()
}
}
}
/**
* 显示书籍详情
*/
override fun showBookInfo(name: String, author: String, bookUrl: String) {
startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
}
/**
* 是否已经加入书架
*/
override fun getBookShelfState(name: String, author: String, url: String?): BookShelfState {
return viewModel.isInBookShelf(name, author, url)
}
/**
* 显示书籍详情
*/
override fun showBookInfo(book: Book) {
showBookInfo(book.name, book.author, book.bookUrl)
}
/**
* 点击历史关键字
*/
override fun searchHistory(key: String) {
lifecycleScope.launch {
when {
searchView.text.toString() == key -> {
searchView.setText(key)
}
withContext(IO) { appDb.bookDao.findByName(key).isEmpty() } -> {
searchView.setText(key)
}
else -> {
searchView.setText(key)
}
}
}
}
/**
* 删除搜索记录
*/
override fun deleteHistory(searchKeyword: SearchKeyword) {
viewModel.deleteHistory(searchKeyword)
}
override fun onSearchScopeOk(searchScope: SearchScope) {
viewModel.searchScope.update(searchScope.toString())
updateSearchScopeChip()
}
private fun updateSearchScopeChip() {
binding.chipSearchScope.isCheckable = true
val scopeNames = viewModel.searchScope.displayNames
if (scopeNames.isEmpty() || viewModel.searchScope.isAll()) {
binding.chipSearchScope.apply {
isChecked = false
text = getString(R.string.all_source)
}
} else {
binding.chipSearchScope.apply {
isChecked = true
text = scopeNames.joinToString(", ")
}
}
binding.chipSearchScope.isCheckable = false
}
private fun alertSearchScope() {
showDialogFragment<SearchScopeDialog>()
}
private fun alertClearHistory() {
alert(R.string.draw) {
setMessage(R.string.sure_clear_search_history)
yesButton {
viewModel.clearHistory()
}
noButton()
}
}
override fun finish() {
if (searchView.hasFocus()) {
searchView.clearFocus()
}
super.finish()
private fun dispatchInit(intent: Intent?) {
viewModel.onIntent(
SearchIntent.Initialize(
key = intent?.getStringExtra("key"),
scopeRaw = intent?.getStringExtra("searchScope"),
)
)
}
companion object {
fun start(context: Context, key: String?, searchScope: String? = null) {
context.startActivity<SearchActivity> {
putExtra("key", key)
putExtra("searchScope", searchScope)
}
}
}
}
}
@@ -18,11 +18,11 @@ data class SearchScope(private var scope: String) {
constructor(groups: List<String>) : this(groups.joinToString(","))
constructor(source: BookSource) : this(
"${source.bookSourceName.replace(":", "")}::${source.bookSourceUrl}"
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
)
constructor(source: BookSourcePart) : this(
"${source.bookSourceName.replace(":", "")}::${source.bookSourceUrl}"
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
)
override fun toString(): String {
@@ -44,19 +44,35 @@ data class SearchScope(private var scope: String) {
}
fun update(source: BookSource) {
scope = "${source.bookSourceName}::${source.bookSourceUrl}"
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope)
save()
}
fun update(source: BookSourcePart) {
scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
stateLiveData.postValue(scope)
save()
}
fun updateSources(sources: List<BookSourcePart>) {
scope = encodeSourceScope(sources)
stateLiveData.postValue(scope)
save()
}
fun isSource(): Boolean {
return scope.contains("::")
val items = scopeItems()
if (items.isEmpty()) return false
return parseSourceItems(items).size == items.size
}
val display: String
get() {
if (scope.contains("::")) {
return scope.substringBefore("::")
if (isSource()) {
val sourceNames = parseSourceItems().map { it.name }
if (sourceNames.isEmpty()) return appCtx.getString(R.string.all_source)
return sourceNames.joinToString(",")
}
if (scope.isEmpty()) {
return appCtx.getString(R.string.all_source)
@@ -70,22 +86,30 @@ data class SearchScope(private var scope: String) {
val displayNames: List<String>
get() {
val list = arrayListOf<String>()
if (scope.contains("::")) {
list.add(scope.substringBefore("::"))
if (isSource()) {
parseSourceItems().forEach {
list.add(it.name)
}
} else {
scope.splitNotBlank(",").forEach {
scopeItems().forEach {
list.add(it)
}
}
return list
}
val sourceUrls: List<String>
get() = parseSourceItems().map { it.url }
fun remove(scope: String) {
if (isSource()) {
this.scope = ""
val sourceItems = parseSourceItems().filterNot {
it.name == scope || it.url == scope
}
this.scope = sourceItems.joinToString(",") { "${it.name}::${it.url}" }
} else {
val stringBuilder = StringBuilder()
this.scope.split(",").forEach {
scopeItems().forEach {
if (it != scope) {
if (stringBuilder.isNotEmpty()) {
stringBuilder.append(",")
@@ -106,14 +130,14 @@ data class SearchScope(private var scope: String) {
if (scope.isEmpty()) {
list.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
if (scope.contains("::")) {
scope.substringAfter("::").let {
appDb.bookSourceDao.getBookSourcePart(it)?.let { source ->
if (isSource()) {
parseSourceItems().forEach { sourceItem ->
appDb.bookSourceDao.getBookSourcePart(sourceItem.url)?.let { source ->
list.add(source)
}
}
} else {
val oldScope = scope.splitNotBlank(",")
val oldScope = scopeItems()
val newScope = oldScope.filter {
val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it)
list.addAll(bookSources)
@@ -150,4 +174,43 @@ data class SearchScope(private var scope: String) {
}
}
private fun scopeItems(): List<String> = scope.splitNotBlank(",").toList()
private fun parseSourceItems(
items: List<String> = scopeItems()
): List<ScopeSourceItem> {
return items.mapNotNull { item ->
val splitIndex = item.indexOf("::")
if (splitIndex <= 0 || splitIndex >= item.lastIndex) {
null
} else {
ScopeSourceItem(
name = item.substring(0, splitIndex),
url = item.substring(splitIndex + 2)
)
}
}
}
private data class ScopeSourceItem(
val name: String,
val url: String,
)
companion object {
private fun sanitizeSourceName(name: String): String {
return name.replace(":", "").replace(",", "")
}
private fun encodeSourceToken(name: String, url: String): String {
return "${sanitizeSourceName(name)}::${url}"
}
private fun encodeSourceScope(sources: List<BookSourcePart>): String {
return sources.joinToString(",") { source ->
encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
}
}
}
}
@@ -1,166 +1,423 @@
package io.legado.app.ui.book.search
import android.app.Application
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
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.entities.SearchKeyword
import io.legado.app.help.book.isNotShelf
import io.legado.app.data.repository.SearchRepository
import io.legado.app.data.repository.SearchSessionEvent
import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookShelfState
import io.legado.app.model.webBook.SearchModel
import io.legado.app.utils.ConflateLiveData
import io.legado.app.utils.toastOnUi
import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.putPrefBoolean
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.mapLatest
import java.util.concurrent.ConcurrentHashMap
data class BookKey(val name: String, val author: String, val url: String?)
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import splitties.init.appCtx
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel(application: Application) : BaseViewModel(application) {
val handler = Handler(Looper.getMainLooper())
val bookshelf: MutableSet<BookKey> = ConcurrentHashMap.newKeySet()
val upAdapterLiveData = MutableLiveData<String>()
var searchBookLiveData = ConflateLiveData<List<SearchBook>>(1000)
val searchScope: SearchScope = SearchScope(AppConfig.searchScope)
var searchFinishLiveData = MutableLiveData<Boolean>()
var isSearchLiveData = MutableLiveData<Boolean>()
class SearchViewModel(
private val repository: SearchRepository,
) : ViewModel() {
val searchProgressLiveData = MutableLiveData<Pair<Int, Int>>()
private val _uiState = MutableStateFlow(
SearchUiState(
isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
scopeDisplay = SearchScope(AppConfig.searchScope).display,
scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames,
isAllScope = SearchScope(AppConfig.searchScope).isAll(),
isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
)
)
val uiState = _uiState.asStateFlow()
var searchKey: String = ""
var hasMore = true
private var searchID = 0L
private val searchModel = SearchModel(viewModelScope, object : SearchModel.CallBack {
private val _effects = MutableSharedFlow<SearchEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
override fun getSearchScope(): SearchScope {
return searchScope
}
private val queryFlow = MutableStateFlow("")
private val bookshelfKeys = MutableStateFlow<Set<BookKey>>(emptySet())
private val searchScope = SearchScope(AppConfig.searchScope)
private val searchSession = repository.createSearchSession { searchScope }
override fun onSearchStart() {
isSearchLiveData.postValue(true)
}
override fun onSearchSuccess(
searchBooks: List<SearchBook>, processedSources: Int, totalSources: Int
) {
searchBookLiveData.postValue(searchBooks)
searchProgressLiveData.postValue(Pair(processedSources, totalSources))
}
override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) {
this@SearchViewModel.hasMore = hasMore
isSearchLiveData.postValue(false)
searchFinishLiveData.postValue(isEmpty)
}
override fun onSearchCancel(exception: Throwable?) {
isSearchLiveData.postValue(false)
exception?.let {
context.toastOnUi(it.localizedMessage)
}
}
})
private var currentSearchId = 0L
init {
execute {
appDb.bookDao.flowAll().mapLatest { books ->
books.filterNot { it.isNotShelf }
}.catch {
AppLog.put("搜索界面获取书籍列表失败\n${it.localizedMessage}", it)
}.collect { books ->
bookshelf.clear()
bookshelf.addAll(books.map { BookKey(it.name, it.author, it.bookUrl) })
upAdapterLiveData.postValue("isInBookshelf")
syncScopeState()
observeEnabledGroups()
observeEnabledSources()
observeBookshelf()
observeQueryHistory()
observeQueryBookshelfHints()
observeSearchSession()
}
fun onIntent(intent: SearchIntent) {
when (intent) {
is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw)
is SearchIntent.UpdateQuery -> updateQuery(intent.query, showSuggestions = true)
SearchIntent.SubmitSearch -> submitSearch()
SearchIntent.LoadMore -> loadMore()
SearchIntent.StopSearch -> stopSearch()
SearchIntent.PauseEngine -> searchSession.pause()
SearchIntent.ResumeEngine -> searchSession.resume()
is SearchIntent.UseHistoryKeyword -> {
updateQuery(intent.keyword, showSuggestions = false)
submitSearch(intent.keyword)
}
}.onError {
AppLog.put("加载书架数据失败", it)
is SearchIntent.OpenSearchBook -> {
emitEffect(
SearchEffect.OpenBookInfo(
name = intent.book.name,
author = intent.book.author,
bookUrl = intent.book.bookUrl,
)
)
}
is SearchIntent.OpenBookshelfBook -> {
emitEffect(
SearchEffect.OpenBookInfo(
name = intent.book.name,
author = intent.book.author,
bookUrl = intent.book.bookUrl,
)
)
}
is SearchIntent.DeleteHistory -> viewModelScope.launch {
repository.deleteSearchKeyword(intent.item)
}
is SearchIntent.SetClearHistoryDialogVisible -> {
_uiState.update { it.copy(showClearHistoryDialog = intent.visible) }
}
SearchIntent.ConfirmClearHistory -> {
_uiState.update { it.copy(showClearHistoryDialog = false) }
viewModelScope.launch {
repository.clearSearchKeywords()
}
}
is SearchIntent.SetScopeSheetVisible -> {
_uiState.update { it.copy(showScopeSheet = intent.visible) }
}
SearchIntent.SelectAllScope -> {
searchScope.update("")
syncScopeState()
}
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
is SearchIntent.RemoveScopeItem -> {
searchScope.remove(intent.scopeName)
syncScopeState()
}
is SearchIntent.TogglePrecision -> {
appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled)
_uiState.update { it.copy(isPrecisionSearch = intent.enabled) }
restartCommittedSearchIfNeeded()
}
SearchIntent.ConfirmEmptyScopeAction -> handleEmptyScopeActionConfirmed()
SearchIntent.DismissEmptyScopeAction -> {
_uiState.update { it.copy(emptyScopeAction = null) }
}
SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
}
}
fun isInBookShelf(name: String, author: String, url: String?): BookShelfState {
val exactMatch = bookshelf.any { it.name == name && it.author == author && it.url == url }
fun getBookShelfStateFlow(item: SearchBook): Flow<BookShelfState> {
return bookshelfKeys
.map { keys -> resolveBookShelfState(item, keys) }
.distinctUntilChanged()
}
override fun onCleared() {
super.onCleared()
searchSession.close()
}
private fun initialize(key: String?, scopeRaw: String?) {
scopeRaw?.let {
searchScope.update(it, postValue = false)
}
syncScopeState()
val initKey = key?.trim().orEmpty()
if (initKey.isNotEmpty()) {
updateQuery(initKey, showSuggestions = false)
submitSearch(initKey)
} else if (key != null) {
updateQuery(initKey, showSuggestions = true)
}
}
private fun observeEnabledGroups() {
viewModelScope.launch {
repository.enabledGroups
.catch { emit(emptyList()) }
.collect { groups ->
_uiState.update { it.copy(enabledGroups = groups) }
}
}
}
private fun observeEnabledSources() {
viewModelScope.launch {
repository.enabledSources
.catch { emit(emptyList()) }
.collect { sources ->
_uiState.update { it.copy(enabledSources = sources) }
}
}
}
private fun observeBookshelf() {
viewModelScope.launch {
repository.bookshelfKeys
.catch { emit(emptySet()) }
.collect { keys ->
bookshelfKeys.value = keys
}
}
}
private fun observeQueryHistory() {
viewModelScope.launch {
queryFlow
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { repository.searchHistory(it) }
.catch { emit(emptyList()) }
.collect { history ->
_uiState.update { it.copy(history = history) }
}
}
}
private fun observeQueryBookshelfHints() {
viewModelScope.launch {
queryFlow
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { repository.searchBookshelf(it) }
.catch { emit(emptyList()) }
.collect { books ->
_uiState.update { it.copy(bookshelfHints = books) }
}
}
}
private fun observeSearchSession() {
viewModelScope.launch {
searchSession.events.collect { event ->
when (event) {
SearchSessionEvent.Started -> {
_uiState.update { it.copy(isSearching = true) }
}
is SearchSessionEvent.Progress -> {
_uiState.update {
it.copy(
results = event.books,
processedSources = event.processedSources,
totalSources = event.totalSources,
)
}
}
is SearchSessionEvent.Finished -> {
_uiState.update { state ->
val emptyAction = if (event.isEmpty && !searchScope.isAll()) {
SearchEmptyScopeAction(
scopeDisplay = searchScope.display,
wasPrecisionSearch = state.isPrecisionSearch,
)
} else {
null
}
state.copy(
isSearching = false,
hasMore = event.hasMore,
emptyScopeAction = emptyAction,
)
}
}
is SearchSessionEvent.Canceled -> {
_uiState.update { it.copy(isSearching = false) }
event.throwable?.localizedMessage
?.takeIf { it.isNotBlank() }
?.let { emitEffect(SearchEffect.ShowMessage(it)) }
}
}
}
}
}
private fun updateQuery(query: String, showSuggestions: Boolean) {
if (showSuggestions && _uiState.value.isSearching) {
searchSession.stop()
}
queryFlow.value = query
_uiState.update {
it.copy(
query = query,
showSuggestions = showSuggestions,
isManualStop = false,
emptyScopeAction = null,
)
}
}
private fun submitSearch(keyOverride: String? = null) {
val keyword = keyOverride?.trim() ?: queryFlow.value.trim()
if (keyword.isBlank()) return
updateQuery(keyword, showSuggestions = false)
currentSearchId = System.currentTimeMillis()
_uiState.update {
it.copy(
committedQuery = keyword,
results = emptyList(),
isManualStop = false,
hasMore = true,
processedSources = 0,
totalSources = 0,
emptyScopeAction = null,
)
}
viewModelScope.launch {
repository.saveSearchKeyword(keyword)
}
searchSession.search(currentSearchId, keyword)
}
private fun loadMore() {
val state = _uiState.value
if (state.isSearching) return
if (state.committedQuery.isBlank()) return
if (!state.hasMore) return
_uiState.update {
it.copy(
isManualStop = false,
showSuggestions = false,
)
}
searchSession.search(currentSearchId, state.committedQuery)
}
private fun stopSearch() {
_uiState.update { it.copy(isManualStop = true) }
searchSession.stop()
}
private fun toggleScopeGroup(groupName: String) {
if (searchScope.isSource()) {
searchScope.update("")
}
val selected = searchScope.displayNames.toMutableSet()
if (selected.contains(groupName)) {
selected.remove(groupName)
} else {
selected.add(groupName)
}
searchScope.update(selected.toList())
syncScopeState()
}
private fun toggleScopeSource(source: BookSourcePart) {
val selectedUrls = if (searchScope.isSource()) {
searchScope.sourceUrls.toMutableSet()
} else {
mutableSetOf()
}
if (selectedUrls.contains(source.bookSourceUrl)) {
selectedUrls.remove(source.bookSourceUrl)
} else {
selectedUrls.add(source.bookSourceUrl)
}
if (selectedUrls.isEmpty()) {
searchScope.update("")
} else {
val selectedSources = _uiState.value.enabledSources.filter {
selectedUrls.contains(it.bookSourceUrl)
}
searchScope.updateSources(selectedSources)
}
syncScopeState()
}
private fun handleEmptyScopeActionConfirmed() {
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) }
} else {
searchScope.update("")
syncScopeState()
}
restartCommittedSearchIfNeeded()
}
private fun restartCommittedSearchIfNeeded() {
val committed = _uiState.value.committedQuery
if (committed.isNotBlank()) {
submitSearch(committed)
}
}
private fun syncScopeState() {
_uiState.update {
it.copy(
scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames,
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(),
)
}
}
private fun resolveBookShelfState(item: SearchBook, shelf: Set<BookKey>): BookShelfState {
val exactMatch = shelf.any {
it.name == item.name && it.author == item.author && it.url == item.bookUrl
}
if (exactMatch) return BookShelfState.IN_SHELF
val sameNameAuthor = bookshelf.any { it.name == name && it.author == author && it.url != url }
val sameNameAuthor = shelf.any {
it.name == item.name && it.author == item.author && it.url != item.bookUrl
}
if (sameNameAuthor) return BookShelfState.SAME_NAME_AUTHOR
return BookShelfState.NOT_IN_SHELF
}
/**
* 开始搜索
*/
fun search(key: String) {
execute {
if ((searchKey == key) || key.isNotEmpty()) {
searchModel.cancelSearch()
searchID = System.currentTimeMillis()
searchBookLiveData.postValue(emptyList())
searchKey = key
hasMore = true
}
if (searchKey.isEmpty()) {
return@execute
}
searchModel.search(searchID, searchKey)
}
private fun emitEffect(effect: SearchEffect) {
_effects.tryEmit(effect)
}
/**
* 停止搜索
*/
fun stop() {
searchModel.cancelSearch()
}
fun pause() {
searchModel.pause()
}
fun resume() {
searchModel.resume()
}
/**
* 保存搜索关键字
*/
fun saveSearchKey(key: String) {
execute {
appDb.searchKeywordDao.get(key)?.let {
it.usage += 1
it.lastUseTime = System.currentTimeMillis()
appDb.searchKeywordDao.update(it)
} ?: appDb.searchKeywordDao.insert(SearchKeyword(key, 1))
}
}
/**
* 清楚搜索关键字
*/
fun clearHistory() {
execute {
appDb.searchKeywordDao.deleteAll()
}
}
fun deleteHistory(searchKeyword: SearchKeyword) {
execute {
appDb.searchKeywordDao.delete(searchKeyword)
}
}
override fun onCleared() {
super.onCleared()
searchModel.close()
}
}
@@ -15,8 +15,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -42,8 +40,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -62,10 +58,9 @@ 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.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.button.MediumOutlinedButton
import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
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.button.TopBarAnimatedActionButton
@@ -225,7 +220,7 @@ fun SearchContentScreen(
) { state ->
when (state) {
is SearchContentState.Error -> {
EmptyMessageView(
EmptyMessage(
message = state.throwable.localizedMessage ?: "发生未知错误",
modifier = Modifier
.fillMaxSize()
@@ -244,7 +239,7 @@ fun SearchContentScreen(
)
}
SearchContentState.EmptyResult -> {
EmptyMessageView(
EmptyMessage(
message = "没有找到相关内容!",
modifier = Modifier
.fillMaxSize()
@@ -319,7 +314,7 @@ fun SearchHistoryList(
}
if (history.isEmpty()) {
EmptyMessageView(
EmptyMessage(
message = "暂无搜索历史",
modifier = Modifier
.fillMaxSize()
@@ -99,7 +99,7 @@ import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.CollapsibleHeader
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
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
@@ -864,7 +864,7 @@ fun BookmarkListContent(
),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
EmptyMessage(
message = "暂无书签"
)
}
@@ -51,6 +51,7 @@ 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.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
@@ -204,17 +205,17 @@ fun TxtRuleScreen(
}
AppAlertDialog(
data = showDeleteRuleDialog, // 传入 nullable �rule 对象
data = showDeleteRuleDialog,
onDismissRequest = { showDeleteRuleDialog = null },
title = stringResource(R.string.delete),
text = stringResource(R.string.sure_del),
confirmText = stringResource(R.string.ok),
onConfirm = { rule ->
viewModel.delete(rule)
showDeleteRuleDialog = null
},
dismissText = stringResource(R.string.cancel),
onDismiss = { showDeleteRuleDialog = null },
content = { Text(text = stringResource(R.string.del_msg)) }
onDismiss = { showDeleteRuleDialog = null }
)
RuleEditSheet(
@@ -228,7 +229,7 @@ fun TxtRuleScreen(
editingRule = null
},
onSave = { updatedRule ->
//TODO:我很想把他改为自增主键,但为了兼容性日åŽå†è¯?
//TODO:我很想把他改为自增主键,但为了兼容性日后再?
if (editingRule == null) {
viewModel.insert(updatedRule)
} else {
@@ -346,7 +347,7 @@ fun TxtRuleScreen(
trailingAction = {
SmallIconButton(
onClick = { showDeleteRuleDialog = item.rule },
imageVector = Icons.Default.Delete
imageVector = AppIcons.Delete
)
}
)
@@ -18,6 +18,8 @@ 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.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -36,116 +38,102 @@ fun CheckSourceBottomSheet(
AppModalBottomSheet(
show = show,
onDismissRequest = onDismiss
onDismissRequest = onDismiss,
title = stringResource(R.string.check_source_config)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
AppText(
text = stringResource(R.string.check_source_config),
style = LegadoTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 16.dp)
)
SliderSettingItem(
title = stringResource(R.string.check_source_timeout),
color = MaterialTheme.colorScheme.surface,
value = viewModel.checkSourceTimeout.toFloat(),
defaultValue = 180f,
onValueChange = { viewModel.checkSourceTimeout = it.toLong() },
valueRange = 0f..300f,
)
GlassCard {
SliderSettingItem(
title = stringResource(R.string.check_source_timeout),
value = viewModel.checkSourceTimeout.toFloat(),
defaultValue = 180f,
onValueChange = { viewModel.checkSourceTimeout = it.toLong() },
valueRange = 0f..300f,
)
}
Spacer(modifier = Modifier.padding(8.dp))
CheckboxGroupContainer(columns = 2) {
item {
CheckboxItem(
title = stringResource(R.string.search),
checked = viewModel.checkSearch,
onCheckedChange = {
viewModel.checkSearch = it
if (!it && !viewModel.checkDiscovery) {
viewModel.checkDiscovery = true
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CheckboxItem(
title = stringResource(R.string.search),
checked = viewModel.checkSearch,
onCheckedChange = {
viewModel.checkSearch = it
if (!it && !viewModel.checkDiscovery) {
viewModel.checkDiscovery = true
}
)
}
}
)
item {
CheckboxItem(
title = stringResource(R.string.discovery),
checked = viewModel.checkDiscovery,
onCheckedChange = {
viewModel.checkDiscovery = it
if (!it && !viewModel.checkSearch) {
viewModel.checkSearch = true
}
CheckboxItem(
title = stringResource(R.string.discovery),
checked = viewModel.checkDiscovery,
onCheckedChange = {
viewModel.checkDiscovery = it
if (!it && !viewModel.checkSearch) {
viewModel.checkSearch = true
}
)
}
}
)
CheckboxItem(
title = stringResource(R.string.source_tab_info),
checked = viewModel.checkInfo,
onCheckedChange = {
viewModel.checkInfo = it
if (!it) {
viewModel.checkCategory = false
viewModel.checkContent = false
}
}
)
CheckboxItem(
title = stringResource(R.string.chapter_list),
checked = viewModel.checkCategory,
enabled = viewModel.checkInfo,
onCheckedChange = {
viewModel.checkCategory = it
if (!it) viewModel.checkContent = false
}
)
CheckboxItem(
title = stringResource(R.string.source_tab_content),
checked = viewModel.checkContent,
enabled = viewModel.checkCategory,
onCheckedChange = { viewModel.checkContent = it }
)
}
CheckboxGroupContainer(columns = 3) {
item {
CheckboxItem(
title = stringResource(R.string.source_tab_info),
checked = viewModel.checkInfo,
onCheckedChange = {
viewModel.checkInfo = it
if (!it) {
viewModel.checkCategory = false
viewModel.checkContent = false
}
}
)
}
item {
CheckboxItem(
title = stringResource(R.string.chapter_list),
checked = viewModel.checkCategory,
enabled = viewModel.checkInfo,
onCheckedChange = {
viewModel.checkCategory = it
if (!it) viewModel.checkContent = false
}
)
}
item {
CheckboxItem(
title = stringResource(R.string.source_tab_content),
checked = viewModel.checkContent,
enabled = viewModel.checkCategory,
onCheckedChange = { viewModel.checkContent = it }
)
}
}
Row(
ConfirmDismissButtonsRow(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
AppText(stringResource(R.string.cancel))
}
Button(onClick = {
onDismiss = onDismiss,
onConfirm = {
if (viewModel.saveCheckSourceConfig()) {
onDismiss()
} else {
Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show()
}
}) {
AppText(stringResource(R.string.ok))
}
}
},
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.ok)
)
}
}
}
@@ -1,5 +1,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
@@ -10,6 +11,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Download
@@ -33,8 +35,10 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.help.DirectLinkUpload
import io.legado.app.lib.dialogs.selector
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.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -65,57 +69,68 @@ fun DirectLinkUploadBottomSheet(
AppModalBottomSheet(
show = show,
title = stringResource(R.string.direct_link_upload_config),
endAction = {
startAction = {
MediumIconButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert
onClick = {
viewModel.testRule { result -> showTestResult = result }
},
imageVector = Icons.Default.Checklist
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
RoundDropdownMenuItem(
text = "导入默认",
leadingIcon = { Icon(Icons.Default.Download, null) },
onClick = {
showMenu = false
context.selector(DirectLinkUpload.defaultRules) { _, rule, _ ->
viewModel.upView(rule)
}
}
},
endAction = {
Box {
MediumIconButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert
)
RoundDropdownMenuItem(
text = stringResource(R.string.copy_rule),
leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
onClick = {
showMenu = false
val rule = DirectLinkUpload.Rule(
viewModel.uploadUrl,
viewModel.downloadUrlRule,
viewModel.summary,
viewModel.compress
)
context.sendToClip(GSON.toJson(rule))
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.paste_rule),
leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
onClick = {
showMenu = false
runCatching {
context.getClipText()?.let {
val rule =
GSON.fromJsonObject<DirectLinkUpload.Rule>(it)
.getOrThrow()
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
RoundDropdownMenuItem(
text = "导入默认",
leadingIcon = { Icon(Icons.Default.Download, null) },
onClick = {
showMenu = false
context.selector(DirectLinkUpload.defaultRules) { _, rule, _ ->
viewModel.upView(rule)
}
}.onFailure {
context.toastOnUi("剪贴板为空或格式不对")
}
}
)
)
RoundDropdownMenuItem(
text = stringResource(R.string.copy_rule),
leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
onClick = {
showMenu = false
val rule = DirectLinkUpload.Rule(
viewModel.uploadUrl,
viewModel.downloadUrlRule,
viewModel.summary,
viewModel.compress
)
context.sendToClip(GSON.toJson(rule))
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.paste_rule),
leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
onClick = {
showMenu = false
runCatching {
context.getClipText()?.let {
val rule =
GSON.fromJsonObject<DirectLinkUpload.Rule>(it)
.getOrThrow()
viewModel.upView(rule)
}
}.onFailure {
context.toastOnUi("剪贴板为空或格式不对")
}
}
)
}
}
},
onDismissRequest = onDismiss
) {
@@ -128,6 +143,7 @@ fun DirectLinkUploadBottomSheet(
AppTextField(
value = viewModel.uploadUrl,
onValueChange = { viewModel.uploadUrl = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.upload_url),
modifier = Modifier.fillMaxWidth(),
)
@@ -137,6 +153,7 @@ fun DirectLinkUploadBottomSheet(
AppTextField(
value = viewModel.downloadUrlRule,
onValueChange = { viewModel.downloadUrlRule = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.download_url_rule),
modifier = Modifier.fillMaxWidth(),
)
@@ -146,6 +163,7 @@ fun DirectLinkUploadBottomSheet(
AppTextField(
value = viewModel.summary,
onValueChange = { viewModel.summary = it },
backgroundColor = LegadoTheme.colorScheme.onSheetContent,
label = stringResource(R.string.summary),
modifier = Modifier.fillMaxWidth(),
)
@@ -154,33 +172,26 @@ fun DirectLinkUploadBottomSheet(
CheckboxItem(
title = stringResource(R.string.is_compress),
color = MaterialTheme.colorScheme.surface,
color = LegadoTheme.colorScheme.onSheetContent,
checked = viewModel.compress,
onCheckedChange = { viewModel.compress = it }
)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = {
viewModel.testRule { result -> showTestResult = result }
}) {
AppText("测试")
}
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onDismiss) {
AppText(stringResource(R.string.cancel))
}
Button(onClick = {
ConfirmDismissButtonsRow(
modifier = Modifier.fillMaxWidth(),
onDismiss = onDismiss,
onConfirm = {
if (viewModel.saveDirectLinkRule()) {
onDismiss()
} else {
context.toastOnUi("请填写完整信息")
}
}) {
AppText(stringResource(R.string.ok))
}
}
},
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.ok)
)
}
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight
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.AppTextField
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
@@ -35,24 +37,22 @@ fun PageKeySheet(
var prevKeys by remember { mutableStateOf(ReadConfig.prevKeys) }
var nextKeys by remember { mutableStateOf(ReadConfig.nextKeys) }
AppModalBottomSheet(show = show, onDismissRequest = onDismissRequest) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.custom_page_key)
) {
Column(
modifier = Modifier.Companion
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AppText(
text = stringResource(R.string.custom_page_key),
style = LegadoTheme.typography.titleLarge,
fontWeight = FontWeight.Companion.Bold
)
OutlinedTextField(
AppTextField(
value = prevKeys,
onValueChange = { prevKeys = it },
label = { AppText(stringResource(R.string.prev_page_key)) },
modifier = Modifier.Companion
label = stringResource(R.string.prev_page_key),
modifier = Modifier
.fillMaxWidth()
.onPreviewKeyEvent { event ->
if (event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
@@ -71,10 +71,10 @@ fun PageKeySheet(
singleLine = true
)
OutlinedTextField(
AppTextField(
value = nextKeys,
onValueChange = { nextKeys = it },
label = { AppText(stringResource(R.string.next_page_key)) },
label = stringResource(R.string.next_page_key),
modifier = Modifier.Companion
.fillMaxWidth()
.onPreviewKeyEvent { event ->
@@ -96,35 +96,23 @@ fun PageKeySheet(
AppText(
text = stringResource(R.string.page_key_set_help),
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
style = LegadoTheme.typography.bodyMedium
)
Row(
modifier = Modifier.Companion.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = {
prevKeys = ""
nextKeys = ""
},
modifier = Modifier.Companion.weight(1f)
) {
AppText(stringResource(R.string.reset))
}
Button(
onClick = {
ReadConfig.prevKeys = prevKeys
ReadConfig.nextKeys = nextKeys
onDismissRequest()
},
modifier = Modifier.Companion.weight(1f)
) {
AppText(stringResource(R.string.ok))
}
}
ConfirmDismissButtonsRow(
modifier = Modifier.fillMaxWidth(),
onDismiss = {
prevKeys = ""
nextKeys = ""
},
onConfirm = {
ReadConfig.prevKeys = prevKeys
ReadConfig.nextKeys = nextKeys
onDismissRequest()
},
dismissText = stringResource(R.string.reset),
confirmText = stringResource(R.string.ok)
)
}
}
}
@@ -31,7 +31,9 @@ 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.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch
@@ -68,35 +70,31 @@ fun BackgroundImageManageSheet(
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest
onDismissRequest = onDismissRequest,
title = stringResource(R.string.background_image),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
AppText(
text = stringResource(R.string.background_image),
style = LegadoTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
if (currentPath.isNullOrBlank()) {
Surface(
onClick = { showFilePicker = true },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
color = LegadoTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.padding(16.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
AppIcon(
imageVector = Icons.Default.Add,
contentDescription = "Add",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
tint = LegadoTheme.colorScheme.primary
)
}
}
@@ -119,24 +117,14 @@ fun BackgroundImageManageSheet(
contentScale = ContentScale.Crop
)
}
IconButton(
SmallTonalIconButton(
onClick = { viewModel.removeBackground(isDarkTheme) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(32.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(20.dp)
)
}
}
.size(32.dp),
imageVector = Icons.Default.Close
)
}
}
}
@@ -44,6 +44,7 @@ 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.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
@@ -320,7 +321,7 @@ fun DictRuleScreen(
trailingAction = {
SmallIconButton(
onClick = { showDeleteRuleDialog = item.rule },
imageVector = Icons.Default.Delete
imageVector = AppIcons.Delete
)
}
)
@@ -376,6 +376,7 @@ fun BookGroupItemList(
fun BookItem(
book: BookShelfItem,
layoutMode: Int,
modifier: Modifier = Modifier,
gridStyle: Int = 0,
isCompact: Boolean = false,
isUpdating: Boolean = false,
@@ -403,6 +404,7 @@ fun BookItem(
isGrid = layoutMode != 0,
gridStyle = gridStyle,
isCompact = isCompact,
modifier = modifier,
cover = { modifier ->
BookshelfCover(
name = book.name,
@@ -74,6 +74,8 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
@@ -197,20 +199,18 @@ fun BookshelfScreen(
}
val bookGroupStyle = BookshelfConfig.bookGroupStyle
// 控制是å¦å¤„äºŽâ€œæ–‡ä»¶å¤¹åˆ—è¡¨â€æ ¹è§†å›¾ï¼Œè¿˜æ˜¯â€œæ–‡ä»¶å¤¹å†…部â€ä¹¦ç±è§†å›?
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
val baseTitle = when {
bookGroupStyle == 1 -> {
val baseTitle = when (bookGroupStyle) {
1 -> {
uiState.groups.getOrNull(pagerState.currentPage)?.groupName
?: stringResource(R.string.bookshelf)
}
bookGroupStyle == 2 && uiState.groups.isNotEmpty() -> {
2 if uiState.groups.isNotEmpty() -> {
if (isInFolderRoot) stringResource(R.string.bookshelf)
else uiState.groups.getOrNull(pagerState.currentPage)?.groupName
?: stringResource(R.string.bookshelf)
}
else -> stringResource(R.string.bookshelf)
}
val title = if (uiState.upBooksCount > 0) {
@@ -329,7 +329,7 @@ fun BookshelfScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.adaptiveHorizontalPadding(),
.adaptiveHorizontalPaddingTab(),
verticalAlignment = Alignment.CenterVertically
) {
val tabTitles = remember(uiState.groups) {
@@ -600,6 +600,19 @@ fun BookshelfPage(
onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit
) {
if (books.isEmpty()) {
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
),
messageResId = R.string.bookshelf_empty
)
return
}
val columns = if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid
val isGridMode = bookshelfLayoutMode != 0
val totalHorizontalPadding =
@@ -623,6 +636,7 @@ fun BookshelfPage(
items(books, key = { it.bookUrl }) { book ->
BookItem(
book = book,
modifier = Modifier.animateItem(),
layoutMode = bookshelfLayoutMode,
gridStyle = BookshelfConfig.bookshelfGridLayout,
isCompact = BookshelfConfig.bookshelfLayoutCompact,
@@ -27,6 +27,8 @@ 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.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
@@ -48,6 +50,7 @@ fun GroupManageSheet(
val groups by bookshelfViewModel.allGroupsFlow.collectAsState()
var editingGroup by remember { mutableStateOf<BookGroup?>(null) }
var isEditing by remember { mutableStateOf(false) }
var coverPath by remember(editingGroup) { mutableStateOf(editingGroup?.cover) }
var listData by remember { mutableStateOf(groups) }
val listState = rememberLazyListState()
@@ -76,15 +79,36 @@ fun GroupManageSheet(
show = show,
onDismissRequest = onDismissRequest,
title = if (!isEditing) stringResource(R.string.group_manage) else stringResource(R.string.group_edit),
startAction = editingGroup?.takeIf {
isEditing && (it.groupId > 0 || it.groupId == Long.MIN_VALUE)
}?.let { group ->
{
GroupDeleteAction(
group = group,
onDismissRequest = {
editingGroup = null
isEditing = false
},
viewModel = viewModel
)
}
},
endAction = {
if (!isEditing) {
SmallIconButton(
onClick = {
editingGroup = null
coverPath = null
isEditing = true
},
imageVector = Icons.Default.Add
)
} else {
GroupResetCoverAction(
group = editingGroup,
onCoverPathChange = { coverPath = it },
viewModel = viewModel
)
}
}
) {
@@ -98,7 +122,12 @@ fun GroupManageSheet(
if (editing) {
GroupEditContent(
group = editingGroup,
onDismissRequest = { isEditing = false },
onDismissRequest = {
editingGroup = null
isEditing = false
},
coverPath = coverPath,
onCoverPathChange = { coverPath = it },
viewModel = viewModel
)
} else {
@@ -115,12 +144,13 @@ fun GroupManageSheet(
title = group.groupName.ifBlank { manageNameInfo.suffix.orEmpty() },
subtitle = if (group.groupName.isNotBlank()) manageNameInfo.suffix else null,
isEnabled = group.show,
containerColor = LegadoTheme.colorScheme.surface,
containerColor = LegadoTheme.colorScheme.onSheetContent,
onEnabledChange = { isChecked ->
viewModel.upGroup(group.copy(show = isChecked))
},
onClickEdit = {
editingGroup = group
coverPath = group.cover
isEditing = true
}
)
@@ -67,6 +67,7 @@ import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
@@ -159,6 +160,19 @@ fun ExploreScreen(
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
if (uiState.items.isEmpty()) {
EmptyMessage(
modifier = Modifier
.fillMaxSize()
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
),
messageResId = R.string.explore_empty
)
return@Box
}
FastScrollLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
@@ -72,6 +72,7 @@ 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.AlertButton
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.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
@@ -368,21 +369,20 @@ fun GroupSelector(
}
}
}
IconButton(onClick = onManageClick) {
Icon(Icons.Default.Settings, "Manage")
}
MediumIconButton(
onClick = onManageClick,
imageVector = Icons.Default.Settings
)
}
}
@Composable
fun ManageGroupDialog(
show: Boolean, // 💡 新增 show 参数,外层直接传值,不要用 if 包裹!
show: Boolean,
groups: List<String>,
onDismiss: () -> Unit,
onDelete: (List<String>) -> Unit
) {
// 💡 使用 Set 来管理多选状态,并且以 show 作为 key。
// 这样每次重新打开弹窗时,选中状态会自动清空,防止上次的残留
var selectedGroups by remember(show) { mutableStateOf(emptySet<String>()) }
AppAlertDialog(
@@ -395,7 +395,6 @@ fun ManageGroupDialog(
} else {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
// 去掉了 spacedBy(8.dp),因为 CheckboxItem 内部已经有了合适的 padding
) {
groups.forEach { group ->
val isSelected = selectedGroups.contains(group)
@@ -418,7 +417,6 @@ fun ManageGroupDialog(
confirmText = "删除选中",
onConfirm = {
onDelete(selectedGroups.toList())
// 注意:外层需要在 onDelete 回调中将 show 置为 false
},
dismissText = "关闭",
onDismiss = onDismiss
@@ -34,7 +34,7 @@ import io.legado.app.R
import io.legado.app.data.entities.RssStar
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.SourceIcon
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -186,7 +186,7 @@ fun RssFavoritesScreen(
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
EmptyMessage(
message = "还没有收藏订阅!",
isLoading = state.isLoading
)
@@ -203,7 +203,7 @@ fun RssFavoritesScreen(
SelectionItemCard(
title = rssStar.title,
subtitle = if (rssStar.group.isNotBlank()) {
"${rssStar.group} �${rssStar.pubDate ?: ""}"
"${rssStar.group} ?${rssStar.pubDate ?: ""}"
} else {
rssStar.pubDate
},
@@ -16,15 +16,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import io.legado.app.ui.widget.components.AppFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -49,7 +46,7 @@ import io.legado.app.ui.association.ImportReplaceRuleDialog
import io.legado.app.ui.association.ImportRssSourceDialog
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
@@ -123,7 +120,7 @@ fun RuleSubScreen(
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
EmptyMessage(
message = stringResource(R.string.rule_sub_empty_msg),
isLoading = state.isLoading
)
@@ -14,6 +14,13 @@ fun Modifier.adaptiveHorizontalPadding(): Modifier {
return this.padding(horizontal = horizontal)
}
@Composable
fun Modifier.adaptiveHorizontalPaddingTab(): Modifier {
val start = if (ThemeResolver.isMiuixEngine(composeEngine)) 12.dp else 0.dp
val end = if (ThemeResolver.isMiuixEngine(composeEngine)) 12.dp else 16.dp
return this.padding(start = start, end = end)
}
@Composable
fun Modifier.adaptiveHorizontalPadding(
vertical: Dp,
@@ -11,9 +11,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@@ -194,8 +196,10 @@ fun AppTheme(
onTertiaryFixed = miuixColorScheme.onTertiaryContainer,
onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer,
cardContainer = miuixColorScheme.disabledPrimary,
onCardContainer = miuixColorScheme.primary
cardContainer = miuixColorScheme.primaryContainer.copy(alpha = 0.32f)
.compositeOver(miuixColorScheme.surfaceContainer),
onCardContainer = miuixColorScheme.primary,
onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f),
)
}
@@ -229,32 +233,3 @@ fun AppTheme(
}
}
}
private fun Typography.toLegadoTypography(): LegadoTypography {
return LegadoTypography(
headlineLarge = headlineLarge,
headlineLargeEmphasized = headlineLargeEmphasized,
headlineMedium = headlineMedium,
headlineMediumEmphasized = headlineMediumEmphasized,
headlineSmall = headlineSmall,
headlineSmallEmphasized = headlineSmallEmphasized,
titleLarge = titleLarge,
titleLargeEmphasized = titleLargeEmphasized,
titleMedium = titleMedium,
titleMediumEmphasized = titleMediumEmphasized,
titleSmall = titleSmall,
titleSmallEmphasized = titleSmallEmphasized,
bodyLarge = bodyLarge,
bodyLargeEmphasized = bodyLargeEmphasized,
bodyMedium = bodyMedium,
bodyMediumEmphasized = bodyMediumEmphasized,
bodySmall = bodySmall,
bodySmallEmphasized = bodySmallEmphasized,
labelLarge = labelLarge,
labelLargeEmphasized = labelLargeEmphasized,
labelMedium = labelMedium,
labelMediumEmphasized = labelMediumEmphasized,
labelSmall = labelSmall,
labelSmallEmphasized = labelSmallEmphasized
)
}
@@ -51,6 +51,29 @@ fun Modifier.responsiveHazeEffect(
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun Modifier.responsiveHazeEffectFixedStyle(
state: HazeState
): Modifier {
val enableBlur = ThemeConfig.enableBlur
if (!enableBlur) return this
val style = HazeLegado.ultraThinPlus()
return this.hazeEffect(
state = state,
style = style
) {
progressive =
HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f
)
}
}
/**
* 仅判断 enableBlur 的简单 HazeEffect
*/
@@ -14,14 +14,15 @@ import androidx.core.graphics.scale
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Size
import com.materialkolor.quantize.QuantizerCelebi
import com.materialkolor.score.Score
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val IMAGE_COLOR_EXTRACT_SIZE_PX = 256
private const val IMAGE_QUANTIZE_BITMAP_MAX_SIZE = 128
private const val IMAGE_MAX_QUANTIZE_COLORS = 128
private const val IMAGE_COLOR_EXTRACT_SIZE_PX = 128
private const val IMAGE_QUANTIZE_BITMAP_MAX_SIZE = 64
private const val IMAGE_MAX_QUANTIZE_COLORS = 64
private const val IMAGE_FALLBACK_SEED_COLOR = 0xFF4285F4.toInt()
suspend fun ImageLoader.extractSeedColor(
@@ -32,6 +33,7 @@ suspend fun ImageLoader.extractSeedColor(
val request = ImageRequest.Builder(context)
.data(data)
.allowHardware(false)
.size(Size(IMAGE_COLOR_EXTRACT_SIZE_PX, IMAGE_COLOR_EXTRACT_SIZE_PX))
.apply(configureRequest)
.build()
@@ -73,7 +73,8 @@ data class LegadoColorScheme(
val onTertiaryFixedVariant: Color,
val cardContainer: Color,
val onCardContainer: Color
val onCardContainer: Color,
val onSheetContent: Color
)
data class LegadoTypography(
@@ -68,7 +68,8 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
onTertiaryFixed = onTertiaryFixed,
onTertiaryFixedVariant = onTertiaryFixedVariant,
cardContainer = primaryContainer.copy(alpha = 0.5f),
onCardContainer = primary
onCardContainer = primary,
onSheetContent = surface
)
}
@@ -55,6 +55,7 @@ fun ProvideThemeOverride(
content: @Composable () -> Unit,
) {
var appliedTheme by remember { mutableStateOf<ThemeOverrideState?>(null) }
val baseTheme = LocalLegadoThemeColors.current
LaunchedEffect(theme) {
if (theme == null) {
@@ -66,15 +67,16 @@ fun ProvideThemeOverride(
}
val currentTheme = appliedTheme
if (currentTheme == null) {
content()
} else {
ProvideColorSchemeOverride(
colorScheme = currentTheme.colorScheme,
seedColor = currentTheme.seedColor,
content = content
?: ThemeOverrideState(
seedColor = baseTheme.seedColor,
colorScheme = baseTheme.colorScheme
)
}
ProvideColorSchemeOverride(
colorScheme = currentTheme.colorScheme,
seedColor = currentTheme.seedColor,
content = content
)
}
@Composable
@@ -49,3 +49,33 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography {
labelSmallEmphasized = miuixStyles.footnote2.copy(fontWeight = FontWeight.Medium)
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun Typography.toLegadoTypography(): LegadoTypography {
return LegadoTypography(
headlineLarge = headlineLarge,
headlineLargeEmphasized = headlineLargeEmphasized,
headlineMedium = headlineMedium,
headlineMediumEmphasized = headlineMediumEmphasized,
headlineSmall = headlineSmall,
headlineSmallEmphasized = headlineSmallEmphasized,
titleLarge = titleLarge,
titleLargeEmphasized = titleLargeEmphasized,
titleMedium = titleMedium,
titleMediumEmphasized = titleMediumEmphasized,
titleSmall = titleSmall,
titleSmallEmphasized = titleSmallEmphasized,
bodyLarge = bodyLarge,
bodyLargeEmphasized = bodyLargeEmphasized,
bodyMedium = bodyMedium,
bodyMediumEmphasized = bodyMediumEmphasized,
bodySmall = bodySmall,
bodySmallEmphasized = bodySmallEmphasized,
labelLarge = labelLarge,
labelLargeEmphasized = labelLargeEmphasized,
labelMedium = labelMedium,
labelMediumEmphasized = labelMediumEmphasized,
labelSmall = labelSmall,
labelSmallEmphasized = labelSmallEmphasized
)
}
@@ -14,6 +14,16 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
object HazeLegado {
@Composable
@ReadOnlyComposable
fun ultraThinPlus(
containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
): HazeStyle = hazeLegado(
containerColor = containerColor,
lightAlpha = 0.0f,
darkAlpha = 0.0f,
)
@Composable
@ReadOnlyComposable
fun ultraThin(
@@ -0,0 +1,34 @@
package io.legado.app.ui.widget.components
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
// 这里建议使用 as 关键字为两个库的控件起别名,避免同名冲突
import androidx.compose.material3.LinearProgressIndicator
// 请替换为实际的 MIUIX 控件包名
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator as MiuixLinearProgressIndicator
@Composable
fun AppLinearProgressIndicator(
modifier: Modifier = Modifier,
progress: Float? = null,
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixLinearProgressIndicator(
modifier = modifier,
progress = progress
)
} else {
if (progress != null) {
LinearProgressIndicator(
progress = { progress },
modifier = modifier
)
} else {
LinearProgressIndicator(
modifier = modifier
)
}
}
}
@@ -0,0 +1,168 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.ExpandedFullScreenContainedSearchBar
import androidx.compose.material3.ExpandedFullScreenSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SearchBarValue
import androidx.compose.material3.SearchBarValue.Collapsed
import androidx.compose.material3.SearchBarValue.Expanded
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.InputField as MiuixInputField
import top.yukonga.miuix.kmp.basic.SearchBar as MiuixSearchBar
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppSearchBar(
modifier: Modifier = Modifier,
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
label: String,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
if (isMiuix) {
var internalExpanded by remember { mutableStateOf(expanded) }
var internalQuery by remember { mutableStateOf(query) }
LaunchedEffect(expanded) {
internalExpanded = expanded
}
LaunchedEffect(query) {
if (query != internalQuery) {
internalQuery = query
}
}
MiuixSearchBar(
modifier = modifier,
inputField = {
MiuixInputField(
query = internalQuery,
onQueryChange = { newQuery ->
if (newQuery.isNotEmpty() || internalExpanded) {
internalQuery = newQuery
onQueryChange(newQuery)
}
},
onSearch = {
onSearch(it)
onExpandedChange(false)
},
expanded = internalExpanded,
onExpandedChange = { newExpanded ->
internalExpanded = newExpanded
onExpandedChange(newExpanded)
},
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
)
},
expanded = internalExpanded,
onExpandedChange = onExpandedChange,
content = content,
)
return
}
val initialSearchBarValue = remember {
if (expanded) SearchBarValue.Expanded else SearchBarValue.Collapsed
}
val searchBarState = rememberSearchBarState(initialValue = initialSearchBarValue)
val textFieldState = rememberTextFieldState(initialText = query)
val scope = rememberCoroutineScope()
val latestQuery by rememberUpdatedState(query)
val latestExpanded by rememberUpdatedState(expanded)
val latestOnQueryChange by rememberUpdatedState(onQueryChange)
val latestOnExpandedChange by rememberUpdatedState(onExpandedChange)
val latestOnSearch by rememberUpdatedState(onSearch)
LaunchedEffect(query) {
if (query != textFieldState.text.toString()) {
textFieldState.setTextAndPlaceCursorAtEnd(query)
}
}
LaunchedEffect(textFieldState) {
snapshotFlow { textFieldState.text.toString() }
.distinctUntilChanged()
.collect { newQuery ->
if (newQuery != latestQuery) {
latestOnQueryChange(newQuery)
}
}
}
LaunchedEffect(expanded) {
if (expanded && searchBarState.targetValue != Expanded) {
searchBarState.animateToExpanded()
} else if (!expanded && searchBarState.targetValue != Collapsed) {
searchBarState.animateToCollapsed()
}
}
LaunchedEffect(searchBarState) {
snapshotFlow { searchBarState.currentValue == Expanded }
.distinctUntilChanged()
.collect { isExpanded ->
if (isExpanded != latestExpanded) {
latestOnExpandedChange(isExpanded)
}
}
}
val inputField: @Composable () -> Unit = {
SearchBarDefaults.InputField(
textFieldState = textFieldState,
searchBarState = searchBarState,
onSearch = { keyword ->
latestOnSearch(keyword)
scope.launch { searchBarState.animateToCollapsed() }
},
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
)
}
SearchBar(
modifier = modifier.padding(horizontal = 16.dp),
state = searchBarState,
inputField = inputField
)
ExpandedFullScreenSearchBar(
state = searchBarState,
inputField = inputField,
content = content
)
}
@@ -28,6 +28,7 @@ import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.InputField as MiuixSearchBarInputField
import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField
@OptIn(ExperimentalMaterial3Api::class)
@@ -300,13 +301,48 @@ fun AppDenseTextField(
scrollState: ScrollState = rememberScrollState(),
shape: Shape = TextFieldDefaults.shape,
interactionSource: MutableInteractionSource? = null,
miuixUseSearchBarInputField: Boolean = false,
miuixSearchBarLabel: String = label ?: "",
miuixOnSearch: (String) -> Unit = {},
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
if (isMiuix && miuixUseSearchBarInputField) {
MiuixSearchBarInputField(
query = state.text.toString(),
onQueryChange = { newQuery ->
val current = state.text.toString()
if (newQuery != current) {
state.edit {
replace(0, length, newQuery)
}
}
},
onSearch = miuixOnSearch,
expanded = false,
onExpandedChange = {},
modifier = modifier.heightIn(min = 45.dp),
label = miuixSearchBarLabel,
enabled = enabled,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
interactionSource = interactionSource
)
return
}
val denseMinHeight = if (isMiuix) 45.dp else 48.dp
val denseBackgroundColor = if (isMiuix && backgroundColor == Color.Unspecified) {
MiuixTheme.colorScheme.surfaceContainerHigh
} else {
backgroundColor
}
AppTextField(
state = state,
modifier = modifier.heightIn(min = 48.dp),
modifier = modifier.heightIn(min = denseMinHeight),
enabled = enabled,
readOnly = readOnly,
backgroundColor = backgroundColor,
backgroundColor = denseBackgroundColor,
label = label,
labelPosition = labelPosition,
placeholder = placeholder,
@@ -28,7 +28,7 @@ import io.legado.app.ui.widget.components.text.AnimatedTextLine
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun EmptyMessageView(
fun EmptyMessage(
message: String,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
@@ -81,7 +81,7 @@ fun EmptyMessageView(
}
@Composable
fun EmptyMessageView(
fun EmptyMessage(
@StringRes messageResId: Int,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
@@ -93,7 +93,7 @@ fun EmptyMessageView(
onFaceClick: (() -> Unit)? = null
) {
val message = stringResource(id = messageResId)
EmptyMessageView(
EmptyMessage(
message = message,
modifier = modifier,
isLoading = isLoading,
@@ -9,14 +9,9 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -49,7 +44,6 @@ fun SearchBarSection(
trailingIcon: @Composable (() -> Unit)? = null,
dropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null
) {
var showMenu by remember { mutableStateOf(false) }
val textFieldState = rememberTextFieldState(initialText = query)
LaunchedEffect(query) {
@@ -76,30 +70,20 @@ fun SearchBarSection(
if (isMiuix) MiuixTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLow
}
val modifier = modifier
.fillMaxWidth()
.then(
if (isMiuix) modifier.padding(vertical = 4.dp)
else modifier
)
val searchTextField = @Composable {
if (isMiuix) {
AppDenseTextField(
state = textFieldState,
modifier = modifier,
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
placeholder = { AppText(placeholder) },
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
lineLimits = TextFieldLineLimits.SingleLine,
backgroundColor = if (!isMiuix) {
Color.Transparent
} else {
resolvedBackgroundColor
}
backgroundColor = resolvedBackgroundColor,
miuixUseSearchBarInputField = true,
miuixSearchBarLabel = placeholder
)
}
if (isMiuix) {
searchTextField()
} else {
Surface(
modifier = Modifier
@@ -108,7 +92,15 @@ fun SearchBarSection(
shape = RoundedCornerShape(32.dp),
color = resolvedBackgroundColor
) {
searchTextField()
AppDenseTextField(
state = textFieldState,
modifier = modifier.fillMaxWidth(),
placeholder = { AppText(placeholder) },
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
lineLimits = TextFieldLineLimits.SingleLine,
backgroundColor = Color.Transparent
)
}
}
}
@@ -154,6 +154,7 @@ fun <T> AppAlertDialog(
data: T?,
onDismissRequest: () -> Unit,
title: String? = null,
text: String? = null,
confirmText: String = "确定",
onConfirm: ((T) -> Unit)? = null,
dismissText: String = "取消",
@@ -173,6 +174,7 @@ fun <T> AppAlertDialog(
show = data != null,
onDismissRequest = onDismissRequest,
title = title,
text = text,
modifier = modifier,
confirmText = confirmText,
onConfirm = onConfirm?.let { { it(currentData) } },
@@ -73,7 +73,8 @@ fun SmallIconButton(
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = contentDescription
contentDescription = contentDescription,
modifier = Modifier.size(smallIconSize),
)
}
} else {
@@ -1,13 +1,19 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.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
@@ -15,9 +21,11 @@ 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.Button as MiuixButton
import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults
import top.yukonga.miuix.kmp.basic.Text as MiuixText
import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
@Composable
fun SmallTextButton(
@@ -27,14 +35,31 @@ fun SmallTextButton(
onClick: () -> Unit
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixButton(
Card(
onClick = onClick,
modifier = modifier,
colors = MiuixButtonDefaults.buttonColors()
) {
MiuixText(
text = text
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(
@@ -46,14 +46,15 @@ fun ToggleChip(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
) {
AnimatedVisibility(visible = selected) {
Row(verticalAlignment = Alignment.CenterVertically) {
MiuixIcon(
imageVector = Icons.Default.Check,
contentDescription = checkedContentDescription,
modifier = Modifier.size(16.dp)
modifier = Modifier.size(18.dp),
tint = if (selected) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurface
)
Spacer(Modifier.width(6.dp))
}
@@ -61,9 +62,10 @@ fun ToggleChip(
MiuixText(
text = label,
style = LegadoTheme.typography.labelSmall,
style = LegadoTheme.typography.labelMediumEmphasized,
maxLines = 1,
softWrap = false
softWrap = false,
color = if (selected) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurface
)
}
}
@@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.card
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -39,15 +40,24 @@ private fun BaseCard(
color = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer.copy(alpha)),
contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface
)
MiuixCard(
modifier = modifier,
cornerRadius = cornerRadius,
pressFeedbackType = pressFeedbackType,
showIndication = true,
onClick = onClick,
content = content,
colors = colors
)
if (onClick != null) {
MiuixCard(
modifier = modifier,
cornerRadius = cornerRadius,
pressFeedbackType = pressFeedbackType,
showIndication = true,
onClick = onClick,
content = content,
colors = colors
)
} else {
MiuixCard(
modifier = modifier,
cornerRadius = cornerRadius,
content = content,
colors = colors
)
}
} else {
val colors = CardDefaults.cardColors(
containerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer).copy(
@@ -60,7 +70,7 @@ private fun BaseCard(
if (onClick != null) {
Card(
modifier = modifier,
shape = shape,
shape = RoundedCornerShape(cornerRadius),
colors = colors,
elevation = elevation,
border = border,
@@ -70,7 +80,7 @@ private fun BaseCard(
} else {
Card(
modifier = modifier,
shape = shape,
shape = RoundedCornerShape(cornerRadius),
colors = colors,
elevation = elevation,
border = border,
@@ -44,6 +44,8 @@ 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.checkBox.AppCheckbox
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.text.AppText
import sh.calvin.reorderable.ReorderableItem
@@ -70,8 +72,6 @@ fun SelectionItemCard(
containerColor: Color? = null,
selectedContainerColor: Color? = null
) {
var showMenu by remember { mutableStateOf(false) }
val composeEngine = ThemeResolver.isMiuixEngine(composeEngine)
val animatedContainerColor by animateColorAsState(
targetValue = if (isSelected)
@@ -92,48 +92,104 @@ fun SelectionItemCard(
containerColor = animatedContainerColor,
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
SelectionItemCardContent(
title = title,
subtitle = subtitle,
supportingContent = supportingContent,
isEnabled = isEnabled,
isSelected = isSelected,
inSelectionMode = inSelectionMode,
leadingContent = leadingContent,
onEnabledChange = onEnabledChange,
onClickEdit = onClickEdit,
trailingAction = trailingAction,
dropdownContent = dropdownContent
)
}
}
@Composable
fun SelectionItemCardContent(
title: String,
subtitle: String? = null,
supportingContent: @Composable (() -> Unit)? = null,
isEnabled: Boolean = true,
isSelected: Boolean = false,
inSelectionMode: Boolean = false,
leadingContent: @Composable (() -> Unit)? = null,
onEnabledChange: ((Boolean) -> Unit)? = null,
onClickEdit: (() -> Unit)? = null,
trailingAction: @Composable (RowScope.() -> Unit)? = null,
dropdownContent: @Composable (ColumnScope.(onDismiss: () -> Unit) -> Unit)? = null
) {
var showMenu by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = inSelectionMode || leadingContent != null
) {
AnimatedVisibility(
visible = inSelectionMode || leadingContent != null
Box(
modifier = Modifier.padding(start = 12.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.padding(start = 12.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = inSelectionMode,
label = "LeadingContent"
) { selectionMode ->
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = null
)
} else {
leadingContent?.invoke()
}
AnimatedContent(
targetState = inSelectionMode,
label = "LeadingContent"
) { selectionMode ->
if (selectionMode) {
AppCheckbox(
checked = isSelected,
onCheckedChange = null
)
} else {
leadingContent?.invoke()
}
}
}
}
if (composeEngine) {
BasicComponent(
modifier = Modifier.weight(1f)
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
BasicComponent(
modifier = Modifier.weight(1f)
) {
AppText(
text = title,
style = LegadoTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
when {
supportingContent != null -> supportingContent()
!subtitle.isNullOrBlank() -> {
AppText(
text = subtitle,
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
} else {
ListItem(
modifier = Modifier.weight(1f),
headlineContent = {
AppText(
text = title,
style = LegadoTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
when {
supportingContent != null -> supportingContent()
!subtitle.isNullOrBlank() -> {
},
supportingContent = when {
supportingContent != null -> supportingContent
!subtitle.isNullOrBlank() -> {
{
AppText(
text = subtitle,
style = LegadoTheme.typography.bodySmall,
@@ -142,75 +198,50 @@ fun SelectionItemCard(
)
}
}
}
} else {
ListItem(
modifier = Modifier.weight(1f),
headlineContent = {
AppText(
text = title,
style = LegadoTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = when {
supportingContent != null -> supportingContent
!subtitle.isNullOrBlank() -> {
{
AppText(
text = subtitle,
style = LegadoTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
else -> null
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
else -> null
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(end = 8.dp)
) {
onEnabledChange?.let {
AdaptiveSwitch(
modifier = Modifier.scale(0.8f),
checked = isEnabled,
onCheckedChange = it
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(end = 8.dp)
) {
onEnabledChange?.let {
AdaptiveSwitch(
modifier = Modifier.scale(0.8f),
checked = isEnabled,
onCheckedChange = it
)
}
if (onClickEdit != null) {
SmallIconButton(
onClick = onClickEdit,
imageVector = AppIcons.Edit,
contentDescription = "Edit"
)
}
if (onClickEdit != null) {
if (trailingAction != null) {
trailingAction()
}
if (dropdownContent != null) {
Box {
SmallIconButton(
onClick = onClickEdit,
imageVector = Icons.Default.Edit,
contentDescription = "Edit"
onClick = { showMenu = true },
imageVector = AppIcons.MoreVert,
contentDescription = "More"
)
}
if (trailingAction != null) {
trailingAction()
}
if (dropdownContent != null) {
Box {
SmallIconButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = "More"
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
dropdownContent { showMenu = false }
}
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
dropdownContent { showMenu = false }
}
}
}
@@ -44,8 +44,7 @@ fun TextCard(
NormalCard(
modifier = modifier,
shape = RoundedCornerShape(cornerRadius),
cornerRadius = 4.dp,
cornerRadius = cornerRadius,
containerColor = finalBackgroundColor,
contentColor = finalContentColor,
onClick = onClick
@@ -14,44 +14,45 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.text.AppText
@Composable
fun CheckboxItem(
title: String,
color: Color = Color.Transparent,
color: Color = LegadoTheme.colorScheme.onSheetContent,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit
) {
val alpha = if (enabled) 1f else 0.5f
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(
color = if (checked && enabled) LegadoTheme.colorScheme.secondaryContainer
else color,
shape = MaterialTheme.shapes.small
)
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(vertical = 12.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
GlassCard(
cornerRadius = 12.dp,
containerColor = if (checked && enabled) LegadoTheme.colorScheme.secondaryContainer else color,
) {
AppCheckbox(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.alpha(alpha)
)
AppText(
text = title,
style = LegadoTheme.typography.bodyMediumEmphasized,
maxLines = 1,
Row(
modifier = Modifier
.padding(start = 12.dp)
.alpha(alpha)
)
.fillMaxWidth()
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(vertical = 12.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppCheckbox(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.alpha(alpha)
)
AppText(
text = title,
style = LegadoTheme.typography.bodyMediumEmphasized,
maxLines = 1,
modifier = Modifier
.padding(start = 12.dp)
.alpha(alpha)
)
}
}
}
@@ -39,9 +39,10 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.core.graphics.withSave
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.NormalCard
import org.koin.compose.koinInject
import io.legado.app.model.BookCover as BookCoverModel
@@ -81,86 +82,93 @@ fun BookCover(
isFinalPathLoaded = false
}
BoxWithConstraints(
modifier = modifier
.aspectRatio(5f / 7f)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
} else Modifier
)
.clip(RoundedCornerShape(4.dp))
.then(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
Modifier.background(LegadoTheme.colorScheme.surfaceContainer)
} else Modifier
)
) {
val density = LocalDensity.current
val iconSizeDp = with(density) {
(minOf(maxWidth, maxHeight).toPx() / 3f).toDp()
}
NormalCard(
cornerRadius = 4.dp
){
BoxWithConstraints(
modifier = modifier
.aspectRatio(5f / 7f)
.then(
if (CoverConfig.coverShowShadow) {
Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
} else Modifier
)
.then(
if (!hasCustomDefault && !isOnlineCoverLoaded) {
Modifier.background(LegadoTheme.colorScheme.surfaceContainer)
} else Modifier
)
) {
val density = LocalDensity.current
val iconSizeDp = with(density) {
(minOf(maxWidth, maxHeight).toPx() / 3f).toDp()
}
if (hasCustomDefault && !isFinalPathLoaded) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(randomPath)
.crossfade(true)
.build(),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
if (hasCustomDefault && !isFinalPathLoaded) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = randomPath,
sourceOrigin = null,
loadOnlyWifi = false,
crossfade = true,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
if (finalPath != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(finalPath)
.crossfade(true)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", CoverConfig.loadCoverOnlyWifi)
.build(),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onSuccess = {
isOnlineCoverLoaded = true
isFinalPathLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
if (finalPath != null) {
AsyncImage(
model = buildCoverImageRequest(
context = context,
data = finalPath,
sourceOrigin = sourceOrigin,
loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
crossfade = true,
),
contentDescription = null,
imageLoader = koinInject(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onSuccess = {
isOnlineCoverLoaded = true
isFinalPathLoaded = true
onLoadFinish?.invoke()
},
onError = {
isOnlineCoverLoaded = false
onLoadFinish?.invoke()
}
)
} else {
LaunchedEffect(Unit) {
onLoadFinish?.invoke()
}
)
} else {
LaunchedEffect(Unit) {
onLoadFinish?.invoke()
}
}
if (!hasCustomDefault && !isOnlineCoverLoaded) {
Icon(
Icons.Default.Book,
contentDescription = null,
tint = LegadoTheme.colorScheme.secondary,
modifier = Modifier
.size(iconSizeDp)
.align(Alignment.Center)
)
}
if (!hasCustomDefault && !isOnlineCoverLoaded) {
Icon(
Icons.Default.Book,
contentDescription = null,
tint = LegadoTheme.colorScheme.secondary,
modifier = Modifier
.size(iconSizeDp)
.align(Alignment.Center)
)
}
if (!isOnlineCoverLoaded) {
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
}
if (!isOnlineCoverLoaded) {
CoverTextOverlay(
name = name,
author = author,
isNight = isNight
)
}
}
}
}
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.card.TextCard
@Composable
@@ -42,6 +43,7 @@ fun BookshelfCover(
TextCard(
text = badgeText,
icon = if (showBadgeDot) Icons.Default.Update else null,
iconSize = 12.dp,
backgroundColor = LegadoTheme.colorScheme.cardContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer,
modifier = Modifier
@@ -49,7 +51,7 @@ fun BookshelfCover(
.padding(2.dp),
cornerRadius = 4.dp,
horizontalPadding = 4.dp,
verticalPadding = 0.dp
verticalPadding = 2.dp
)
}
@@ -63,20 +65,17 @@ fun BookshelfCover(
.padding(2.dp),
cornerRadius = 4.dp,
horizontalPadding = 4.dp,
verticalPadding = 0.dp
verticalPadding = 2.dp
)
}
if (isUpdating) {
LinearProgressIndicator(
AppLinearProgressIndicator(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp)
.height(3.dp),
color = LegadoTheme.colorScheme.primary,
trackColor = LegadoTheme.colorScheme.primary.copy(alpha = 0.2f),
strokeCap = StrokeCap.Round
.height(3.dp)
)
}
}
@@ -24,7 +24,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.ui.theme.LegadoTheme
import org.koin.compose.koinInject
@@ -57,16 +56,18 @@ fun Cover(
)
} else {
AsyncImage(
model = ImageRequest.Builder(context)
.data(path)
.crossfade(true)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", loadOnlyWifi)
.listener(
model = buildCoverImageRequest(
context = context,
data = path,
sourceOrigin = sourceOrigin,
loadOnlyWifi = loadOnlyWifi,
crossfade = true,
) {
listener(
onSuccess = { _, _ -> onLoadFinish?.invoke() },
onError = { _, _ -> onLoadFinish?.invoke() }
)
.build(),
},
imageLoader = imageLoader,
contentDescription = null,
contentScale = ContentScale.Crop,
@@ -93,4 +94,4 @@ fun Cover(
}
}
}
}
}
@@ -0,0 +1,21 @@
package io.legado.app.ui.widget.components.cover
import android.content.Context
import coil.request.ImageRequest
fun buildCoverImageRequest(
context: Context,
data: Any?,
sourceOrigin: String?,
loadOnlyWifi: Boolean,
crossfade: Boolean = true,
configure: ImageRequest.Builder.() -> Unit = {},
): ImageRequest {
return ImageRequest.Builder(context)
.data(data)
.crossfade(crossfade)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", loadOnlyWifi)
.apply(configure)
.build()
}
@@ -5,11 +5,19 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RssFeed
@@ -18,15 +26,23 @@ import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.main.MainDestination
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import okhttp3.internal.http2.Settings
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.extended.Album
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.Close
import top.yukonga.miuix.kmp.icon.extended.ContactsBook
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Edit
import top.yukonga.miuix.kmp.icon.extended.Favorites
import top.yukonga.miuix.kmp.icon.extended.Filter
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.icon.extended.Pin
import top.yukonga.miuix.kmp.icon.extended.Refresh
import top.yukonga.miuix.kmp.icon.extended.Settings
import top.yukonga.miuix.kmp.icon.extended.Unpin
import top.yukonga.miuix.kmp.icon.extended.WorldClock
object AppIcons {
@@ -38,6 +54,18 @@ object AppIcons {
@Composable
get() = if (isMiuix) MiuixIcons.Basic.Search else Icons.Default.Search
val MoreVert: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.More else Icons.Default.MoreVert
val Edit: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Edit else Icons.Default.Edit
val Delete: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Delete else Icons.Default.Delete
val Close: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Close else Icons.Default.Clear
@@ -46,6 +74,26 @@ object AppIcons {
@Composable
get() = if (isMiuix) MiuixIcons.Back else Icons.AutoMirrored.Filled.ArrowBack
val Filter: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Filter else Icons.Default.FilterList
val Settings: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Settings else Icons.Default.Settings
val PrecisionSearch: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Pin else Icons.Default.MyLocation
val UnPrecisionSearch: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Unpin else Icons.Default.LocationSearching
val History: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.WorldClock else Icons.Default.History
val Replay: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay
@@ -45,7 +45,7 @@ fun RoundDropdownMenu(
if (isMiuix) {
val popupContentColor = LegadoTheme.colorScheme.onSurface
OverlayListPopup(
WindowListPopup(
show = expanded,
onDismissRequest = onDismissRequest,
popupModifier = modifier
@@ -137,7 +137,7 @@ fun RoundDropdownMenuItem(
else -> defaultContentColor.copy(alpha = 0.38f)
}
}
val containerColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surface
val containerColor = colorScheme.surface
Surface(
onClick = onClick,
@@ -76,7 +76,7 @@ fun <T> RuleListScaffold(
show = showDeleteConfirmDialog,
onDismissRequest = { showDeleteConfirmDialog = false },
title = stringResource(R.string.delete),
text = stringResource(R.string.del_msg),
text = stringResource(R.string.sure_del),
confirmText = stringResource(R.string.ok),
onConfirm = {
onDeleteSelected(state.selectedIds)
@@ -2,6 +2,7 @@
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -30,6 +31,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
@@ -41,7 +43,6 @@ import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField
@Composable
fun SliderSettingItem(
title: String,
color: Color? = null,
value: Float,
defaultValue: Float,
valueRange: ClosedFloatingPointRange<Float>,
@@ -56,7 +57,10 @@ fun SliderSettingItem(
if (ThemeResolver.isMiuixEngine(composeEngine)) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
BasicComponent(
title = title,
summary = description,
@@ -143,7 +147,7 @@ fun SliderSettingItem(
} else {
SettingItem(
title = title,
color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
color = MaterialTheme.colorScheme.surfaceContainerLow,
option = description,
expanded = expanded,
onExpandChange = { expanded = it },
@@ -75,7 +75,7 @@ fun <T> DynamicTopAppBar(
if (!isSelecting) {
TopBarActionButton(
onClick = { onSearchToggle(!state.isSearch) },
imageVector = Icons.Default.Search,
imageVector = AppIcons.Search,
contentDescription = "搜索"
)