[新增] 由Compose重写的发现界面,可能存在bug

This commit is contained in:
HapeLee
2026-03-13 01:05:35 +08:00
parent 55a95dcd07
commit d6f0ca01b6
13 changed files with 814 additions and 648 deletions
@@ -29,7 +29,9 @@ import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
import io.legado.app.ui.dict.rule.DictRuleViewModel
import io.legado.app.ui.main.explore.ExploreViewModel
import io.legado.app.ui.main.my.MyViewModel
import io.legado.app.ui.main.rss.RssViewModel
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleViewModel
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
@@ -82,6 +84,8 @@ val appModule = module {
viewModelOf(::TocViewModel)
viewModelOf(::RemoteBookViewModel)
viewModelOf(::BookInfoViewModel)
viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel)
viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
@@ -28,4 +28,11 @@ object ThemeConfig {
!bgImageLight.isNullOrEmpty()
}
}
/*
fun hasImageBg(isDark: Boolean): Boolean {
return if (isDark) bgImageDark.isNullOrBlank() else bgImageLight.isNullOrBlank()
}
*/
}
@@ -1,256 +1,48 @@
package io.legado.app.ui.main.explore
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import io.legado.app.R
import io.legado.app.base.VMBaseFragment
import io.legado.app.constant.AppLog
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.databinding.FragmentExploreBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import io.legado.app.ui.main.MainFragmentInterface
import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
import io.legado.app.utils.startActivity
import io.legado.app.utils.transaction
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import io.legado.app.ui.theme.AppTheme
/**
* 发现界面
*/
class ExploreFragment() : VMBaseFragment<ExploreViewModel>(R.layout.fragment_explore),
MainFragmentInterface,
ExploreAdapter.CallBack {
class ExploreFragment() : Fragment(), MainFragmentInterface {
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
arguments = Bundle().apply {
putInt("position", position)
}
}
override val position: Int? get() = arguments?.getInt("position")
override val position: Int
get() = arguments?.getInt("position") ?: 0
override val viewModel by viewModels<ExploreViewModel>()
private val binding by viewBinding(FragmentExploreBinding::bind)
private val adapter by lazy {
ExploreAdapter(requireContext(), this)
}
private val linearLayoutManager by lazy { LinearLayoutManager(context) }
private val searchView: SearchView by lazy { binding.searchBar }
private val diffItemCallBack = ExploreDiffItemCallBack()
private val groups = linkedSetOf<String>()
private var exploreFlowJob: Job? = null
private var groupsMenu: SubMenu? = null
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
initSearchView()
initRecyclerView()
initGroupData()
upExploreData()
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.appBar.fitsSystemWindows = true
}
override fun onPause() {
super.onPause()
searchView.clearFocus()
}
private fun initSearchView() {
searchView.queryHint = getString(R.string.screen_find)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
upExploreData(query)
searchView.clearFocus()
return true
}
override fun onQueryTextChange(newText: String): Boolean {
upExploreData(newText)
return true
}
})
}
private fun initRecyclerView() {
//binding.rvFind.setEdgeEffectColor(primaryColor)
binding.rvFind.layoutManager = linearLayoutManager
binding.rvFind.adapter = adapter
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0) {
binding.rvFind.scrollToPosition(0)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme {
ExploreScreen()
}
}
})
}
private fun initGroupData() {
viewLifecycleOwner.lifecycleScope.launch {
appDb.bookSourceDao.flowExploreGroups()
.flowWithLifecycleAndDatabaseChange(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.BOOK_SOURCE_TABLE_NAME
)
.conflate()
.distinctUntilChanged()
.collect {
groups.clear()
groups.addAll(it)
upGroupsMenu()
}
}
}
private fun upExploreData(searchKey: String? = null) {
exploreFlowJob?.cancel()
exploreFlowJob = viewLifecycleOwner.lifecycleScope.launch {
val query = searchKey ?: searchView.query?.toString() ?: ""
when {
query.isBlank() -> {
appDb.bookSourceDao.flowExplore()
}
query.startsWith("group:") -> {
val key = query.substringAfter("group:")
appDb.bookSourceDao.flowGroupExplore(key)
}
else -> {
appDb.bookSourceDao.flowExplore(query)
}
}.flowWithLifecycleAndDatabaseChange(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.BOOK_SOURCE_TABLE_NAME
).catch {
AppLog.put("发现界面更新数据出错", it)
}.conflate().flowOn(IO).collect {
binding.emptyView.isGone = it.isNotEmpty() || query.isNotEmpty()
adapter.setItems(it, diffItemCallBack)
}
}
}
private fun upGroupsMenu() = groupsMenu?.transaction { subMenu ->
subMenu.removeGroup(R.id.menu_group_text)
subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, getString(R.string.all))
groups.forEach {
subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it)
}
}
override val scope: CoroutineScope
get() = viewLifecycleOwner.lifecycleScope
override fun onCompatCreateOptionsMenu(menu: Menu) {
menuInflater.inflate(R.menu.main_explore, menu)
groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupsMenu()
}
override fun onCompatOptionsItemSelected(item: MenuItem) {
super.onCompatOptionsItemSelected(item)
if (item.groupId == R.id.menu_group_text) {
val title = item.title.toString()
if (title == getString(R.string.all)) {
searchView.setQuery("", false)
upExploreData("")
} else {
searchView.setQuery(title, false)
}
} else if (item.itemId == R.id.menu_exp_search) {
TransitionManager.beginDelayedTransition(binding.rootView)
if (searchView.isVisible) {
searchView.isVisible = false
searchView.clearFocus()
} else {
searchView.isVisible = true
searchView.requestFocus()
}
}
}
override fun scrollTo(pos: Int) {
(binding.rvFind.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0)
}
override fun openExplore(sourceUrl: String, title: String, exploreUrl: String?) {
if (exploreUrl.isNullOrBlank()) return
startActivity<ExploreShowActivity> {
putExtra("exploreName", title)
putExtra("sourceUrl", sourceUrl)
putExtra("exploreUrl", exploreUrl)
}
}
override fun editSource(sourceUrl: String) {
startActivity<BookSourceEditActivity> {
putExtra("sourceUrl", sourceUrl)
}
}
override fun toTop(source: BookSourcePart) {
viewModel.topSource(source)
}
override fun deleteSource(source: BookSourcePart) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + source.bookSourceName)
noButton()
yesButton {
viewModel.deleteSource(source)
}
}
}
override fun searchBook(bookSource: BookSourcePart) {
startActivity<SearchActivity> {
putExtra("searchScope", SearchScope(bookSource).toString())
}
}
fun compressExplore() {
if (!adapter.compressExplore()) {
if (AppConfig.isEInkMode) {
binding.rvFind.scrollToPosition(0)
} else {
binding.rvFind.smoothScrollToPosition(0)
}
}
// Compose version handled by ViewModel state
}
}
@@ -0,0 +1,522 @@
package io.legado.app.ui.main.explore
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
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.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.utils.startActivity
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ExploreScreen(
viewModel: ExploreViewModel = koinViewModel()
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// 自动滚动置顶
LaunchedEffect(uiState.expandedId) {
uiState.expandedId?.let { id ->
var realIndex = 0
for (item in uiState.items) {
if (item.bookSourceUrl == id) break
realIndex++
}
if (realIndex >= 0) {
listState.animateScrollToItem(realIndex)
}
}
}
val stickyHeaderSource by remember {
derivedStateOf {
val expandedId = uiState.expandedId ?: return@derivedStateOf null
val expandedSource =
uiState.items.find { it.bookSourceUrl == expandedId } ?: return@derivedStateOf null
var headerIndex = 0
var contentRowCount = 0
for (item in uiState.items) {
if (item.bookSourceUrl == expandedId) {
contentRowCount = calculateRows(uiState.exploreKinds, 6).size
break
}
headerIndex++
}
val lastContentIndex = headerIndex + contentRowCount
val firstVisible = listState.firstVisibleItemIndex
if (firstVisible in (headerIndex + 1)..lastContentIndex) {
expandedSource
} else {
null
}
}
}
ListScaffold(
title = stringResource(R.string.screen_find),
state = uiState,
subtitle = uiState.selectedGroup.ifEmpty { stringResource(R.string.all) },
onSearchQueryChange = { viewModel.search(it) },
onSearchToggle = { viewModel.toggleSearchVisible(it) },
searchPlaceholder = stringResource(R.string.search),
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Group) },
text = { Text(stringResource(R.string.all)) },
onClick = { viewModel.setGroup(""); dismiss() }
)
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Outlined.Label) },
text = { Text(group) },
onClick = { viewModel.setGroup(group); dismiss() }
)
}
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
FastScrollLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 16.dp
)
) {
uiState.items.forEach { item ->
val isExpanded = uiState.expandedId == item.bookSourceUrl
item(key = item.bookSourceUrl) {
ExploreSourceHeader(
modifier = Modifier.animateItem(),
item = item,
isExpanded = isExpanded,
loadingKinds = if (isExpanded) uiState.loadingKinds else false,
onClick = { viewModel.toggleExpand(item) },
onTop = { viewModel.topSource(item) },
onEdit = {
context.startActivity<BookSourceEditActivity> {
putExtra("sourceUrl", item.bookSourceUrl)
}
},
onSearch = {
context.startActivity<SearchActivity> {
putExtra("searchScope", SearchScope(item).toString())
}
},
onLogin = {
context.startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", item.bookSourceUrl)
}
},
onRefresh = { viewModel.refreshExploreKinds(item) },
onDelete = { sourceToDelete = item }
)
}
if (isExpanded) {
val rows = calculateRows(uiState.exploreKinds, 6)
itemsIndexed(
items = rows,
key = { index, _ -> "${item.bookSourceUrl}_$index" }
) { _, rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.animateItem()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEach { (kind, span) ->
val isClickable = !kind.url.isNullOrBlank()
ExploreKindItem(
kind = kind,
isClickable = isClickable,
modifier = Modifier.weight(span.toFloat()),
onClick = {
if (isClickable) {
context.startActivity<ExploreShowActivity> {
putExtra("exploreName", kind.title)
putExtra("sourceUrl", item.bookSourceUrl)
putExtra("exploreUrl", kind.url)
}
}
}
)
}
val totalSpan = rowItems.sumOf { it.second }
if (totalSpan < 6) {
Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat())
)
}
}
}
}
}
}
AnimatedVisibility(
visible = stickyHeaderSource != null,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = paddingValues.calculateTopPadding() + 4.dp, start = 8.dp),
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it }
) {
var lastSource by remember { mutableStateOf<BookSourcePart?>(null) }
stickyHeaderSource?.let { lastSource = it }
lastSource?.let { item ->
ExploreStickyCard(
item = item,
onClick = {
scope.launch {
val index =
uiState.items.indexOfFirst { it.bookSourceUrl == item.bookSourceUrl }
if (index >= 0) listState.animateScrollToItem(index)
}
}
)
}
}
}
}
sourceToDelete?.let { source ->
AlertDialog(
onDismissRequest = { sourceToDelete = null },
title = { Text(stringResource(R.string.draw)) },
text = { Text(stringResource(R.string.sure_del) + "\n" + source.bookSourceName) },
confirmButton = {
TextButton(onClick = {
viewModel.deleteSource(source)
sourceToDelete = null
}) {
Text(stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = { sourceToDelete = null }) {
Text(stringResource(R.string.no))
}
}
)
}
}
private fun calculateRows(
kinds: List<ExploreKind>,
maxSpan: Int
): List<List<Pair<ExploreKind, Int>>> {
val rows = mutableListOf<MutableList<Pair<ExploreKind, Int>>>()
var currentRow = mutableListOf<Pair<ExploreKind, Int>>()
var currentSpan = 0
kinds.forEach { kind ->
val style = kind.style()
val span = when {
style.layout_wrapBefore || style.layout_flexBasisPercent >= 1.0f -> maxSpan
style.layout_flexBasisPercent > 0 -> (maxSpan * style.layout_flexBasisPercent).roundToInt()
.coerceIn(1, maxSpan)
style.layout_flexGrow > 0f -> 3
else -> 2
}
if ((style.layout_wrapBefore && currentRow.isNotEmpty()) || (currentSpan + span > maxSpan)) {
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
currentRow.add(kind to span)
currentSpan += span
if (currentSpan >= maxSpan) {
rows.add(currentRow)
currentRow = mutableListOf()
currentSpan = 0
}
}
if (currentRow.isNotEmpty()) rows.add(currentRow)
return rows
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExploreSourceHeader(
modifier: Modifier = Modifier,
item: BookSourcePart,
isExpanded: Boolean,
loadingKinds: Boolean,
onClick: () -> Unit,
onTop: () -> Unit,
onEdit: () -> Unit,
onSearch: () -> Unit,
onLogin: () -> Unit,
onRefresh: () -> Unit,
onDelete: () -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(if (isExpanded) 90f else 0f, label = "rotation")
val containerColor by animateColorAsState(
targetValue = if (isExpanded)
MaterialTheme.colorScheme.secondaryContainer
else
MaterialTheme.colorScheme.surfaceContainerLow,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
label = "CardColor"
)
GlassCard(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(containerColor = containerColor),
) {
ListItem(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = { showMenu = true }
)
.fillMaxWidth(),
colors = ListItemDefaults.colors(
containerColor = Color.Transparent
),
headlineContent = {
Text(
text = item.bookSourceName,
style = MaterialTheme.typography.titleMedium,
color = if (isExpanded) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
)
},
trailingContent = {
if (loadingKinds) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier
.rotate(rotation)
.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
PillHeaderDivider(title = item.bookSourceName)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.VerticalAlignTop) },
text = { Text(stringResource(R.string.to_top)) },
onClick = { onTop(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
text = { Text(stringResource(R.string.edit)) },
onClick = { onEdit(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Search) },
text = { Text(stringResource(R.string.search)) },
onClick = { onSearch(); showMenu = false }
)
if (item.hasLoginUrl) {
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
text = { Text(stringResource(R.string.login)) },
onClick = { onLogin(); showMenu = false }
)
}
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
text = { Text(stringResource(R.string.refresh)) },
onClick = { onRefresh(); showMenu = false }
)
RoundDropdownMenuItem(
leadingIcon = {
MenuItemIcon(
Icons.Default.Delete,
tint = MaterialTheme.colorScheme.error
)
},
text = {
Text(
stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error
)
},
onClick = { onDelete(); showMenu = false }
)
}
}
@Composable
fun ExploreStickyCard(
item: BookSourcePart,
onClick: () -> Unit
) {
TextCard(
text = item.bookSourceName,
backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh,
textStyle = MaterialTheme.typography.labelLarge,
horizontalPadding = 8.dp,
verticalPadding = 6.dp,
onClick = onClick
)
}
@Composable
fun ExploreKindItem(
kind: ExploreKind,
isClickable: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides Dp.Unspecified
) {
val shape = MaterialTheme.shapes.medium
if (isClickable) {
GlassCard(
onClick = onClick,
shape = shape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
),
modifier = modifier
) {
KindText(kind)
}
} else {
OutlinedCard(
shape = shape,
colors = CardDefaults.outlinedCardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
),
modifier = modifier
) {
KindText(kind)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun KindText(
kind: ExploreKind
) {
Text(
text = kind.title,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
style = MaterialTheme.typography.labelMediumEmphasized,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
@@ -1,13 +1,135 @@
package io.legado.app.ui.main.explore
import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.help.source.SourceHelp
import io.legado.app.help.source.clearExploreKindsCache
import io.legado.app.help.source.exploreKinds
import io.legado.app.ui.widget.components.rules.ListUiState
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ExploreViewModel(application: Application) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(ExploreUiState())
val uiState = _uiState.asStateFlow()
private var exploreJob: Job? = null
private var kindsJob: Job? = null
init {
observeGroups()
observeExplore()
}
private fun observeGroups() {
viewModelScope.launch {
appDb.bookSourceDao.flowExploreGroups().collectLatest { groups ->
_uiState.update { it.copy(groups = groups) }
}
}
}
fun search(key: String) {
_uiState.update { it.copy(searchKey = key, expandedId = null) }
observeExplore()
}
fun setGroup(group: String) {
_uiState.update { it.copy(selectedGroup = group, expandedId = null) }
observeExplore()
}
fun toggleSearchVisible(visible: Boolean) {
_uiState.update { it.copy(isSearch = visible) }
if (!visible) {
search("")
}
}
private fun observeExplore() {
exploreJob?.cancel()
exploreJob = viewModelScope.launch {
val state = _uiState.value
val query = state.searchKey
val selectedGroup = state.selectedGroup
val flow = when {
query.isNotBlank() -> {
if (query.startsWith("group:")) {
val key = query.substringAfter("group:")
appDb.bookSourceDao.flowGroupExplore(key)
} else {
appDb.bookSourceDao.flowExplore(query)
}
}
selectedGroup.isNotBlank() -> {
appDb.bookSourceDao.flowGroupExplore(selectedGroup)
}
else -> {
appDb.bookSourceDao.flowExplore()
}
}
flow.flowOn(IO).collectLatest { items ->
_uiState.update { it.copy(items = items) }
}
}
}
fun toggleExpand(source: BookSourcePart) {
val newExpandedId =
if (_uiState.value.expandedId == source.bookSourceUrl) null else source.bookSourceUrl
_uiState.update {
it.copy(
expandedId = newExpandedId,
exploreKinds = emptyList(),
loadingKinds = newExpandedId != null
)
}
if (newExpandedId != null) {
loadExploreKinds(source)
}
}
private fun loadExploreKinds(source: BookSourcePart) {
kindsJob?.cancel()
kindsJob = viewModelScope.launch(IO) {
try {
val kinds = source.exploreKinds()
_uiState.update {
if (it.expandedId == source.bookSourceUrl) {
it.copy(exploreKinds = kinds, loadingKinds = false)
} else it
}
} catch (e: Exception) {
_uiState.update { it.copy(loadingKinds = false) }
}
}
}
fun refreshExploreKinds(source: BookSourcePart) {
viewModelScope.launch(IO) {
source.clearExploreKindsCache()
if (_uiState.value.expandedId == source.bookSourceUrl) {
loadExploreKinds(source)
}
}
}
fun topSource(bookSource: BookSourcePart) {
execute {
val minXh = appDb.bookSourceDao.minOrder
@@ -22,4 +144,17 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
}
}
}
data class ExploreUiState(
override val items: List<BookSourcePart> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<String> = emptyList(),
val selectedGroup: String = "",
val expandedId: String? = null,
val exploreKinds: List<ExploreKind> = emptyList(),
val loadingKinds: Boolean = false
) : ListUiState<BookSourcePart>
}
@@ -5,38 +5,24 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.viewModels
import io.legado.app.R
import io.legado.app.base.VMBaseFragment
import io.legado.app.data.entities.RssSource
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.login.SourceLoginActivity
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import io.legado.app.ui.main.MainFragmentInterface
import io.legado.app.ui.rss.article.RssSortActivity
import io.legado.app.ui.rss.favorites.RssFavoritesActivity
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.rss.source.manage.RssSourceActivity
import io.legado.app.ui.rss.subscription.RuleSubActivity
import io.legado.app.ui.theme.AppTheme
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
/**
* 订阅界面
*/
class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
MainFragmentInterface {
class RssFragment() : Fragment(), MainFragmentInterface {
constructor(position: Int) : this() {
val bundle = Bundle()
bundle.putInt("position", position)
arguments = bundle
arguments = Bundle().apply {
putInt("position", position)
}
}
override val position: Int? get() = arguments?.getInt("position")
override val viewModel by viewModels<RssViewModel>()
override val position: Int
get() = arguments?.getInt("position") ?: 0
override fun onCreateView(
inflater: LayoutInflater,
@@ -44,66 +30,14 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme {
RssScreen(
viewModel = viewModel,
onOpenRss = { openRss(it) },
onEdit = { edit(it) },
onOpenStar = { startActivity<RssFavoritesActivity>() },
onOpenConfig = { startActivity<RssSourceActivity>() },
onOpenRuleSub = { startActivity<RuleSubActivity>() },
onDelete = { del(it) },
onLogin = { login(it) }
)
RssScreen()
}
}
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
// Compose handles UI
}
private fun openRss(rssSource: RssSource) {
if (rssSource.singleUrl) {
viewModel.getSingleUrl(rssSource) { url ->
if (url.startsWith("http", true)) {
startActivity<ReadRssActivity> {
putExtra("title", rssSource.sourceName)
putExtra("origin", url)
}
} else {
context?.openUrl(url)
}
}
} else {
startActivity<RssSortActivity> {
putExtra("url", rssSource.sourceUrl)
}
}
}
private fun edit(rssSource: RssSource) {
startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", rssSource.sourceUrl)
}
}
private fun login(rssSource: RssSource) {
startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", rssSource.sourceUrl)
}
}
private fun del(rssSource: RssSource) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
noButton()
yesButton {
viewModel.del(rssSource)
}
}
}
}
@@ -23,13 +23,15 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Subscriptions
import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.Subscriptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -39,12 +41,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.rss.article.RssSortActivity
import io.legado.app.ui.rss.favorites.RssFavoritesActivity
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.rss.source.manage.RssSourceActivity
import io.legado.app.ui.rss.subscription.RuleSubActivity
import io.legado.app.ui.widget.components.SourceIcon
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.divider.PillDivider
@@ -53,20 +63,50 @@ import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssScreen(
viewModel: RssViewModel,
onOpenRss: (RssSource) -> Unit,
onEdit: (RssSource) -> Unit,
onOpenStar: () -> Unit,
onOpenConfig: () -> Unit,
onOpenRuleSub: () -> Unit,
onDelete: (RssSource) -> Unit,
onLogin: (RssSource) -> Unit
viewModel: RssViewModel = koinViewModel()
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
var sourceToDelete by remember { mutableStateOf<RssSource?>(null) }
val openRss: (RssSource) -> Unit = { rssSource ->
if (rssSource.singleUrl) {
viewModel.getSingleUrl(rssSource) { url ->
if (url.startsWith("http", true)) {
context.startActivity<ReadRssActivity> {
putExtra("title", rssSource.sourceName)
putExtra("origin", url)
}
} else {
context.openUrl(url)
}
}
} else {
context.startActivity<RssSortActivity> {
putExtra("url", rssSource.sourceUrl)
}
}
}
val edit: (RssSource) -> Unit = { rssSource ->
context.startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", rssSource.sourceUrl)
}
}
val login: (RssSource) -> Unit = { rssSource ->
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", rssSource.sourceUrl)
}
}
ListScaffold(
title = stringResource(R.string.rss),
@@ -78,20 +118,23 @@ fun RssScreen(
searchPlaceholder = stringResource(R.string.search_rss_source),
topBarActions = {
TopBarActionButton(
onClick = onOpenRuleSub,
imageVector = Icons.Outlined.Subscriptions,
onClick = { context.startActivity<RuleSubActivity>() },
imageVector = Icons.Default.Subscriptions,
contentDescription = stringResource(R.string.rule_subscription)
)
TopBarActionButton(
onClick = onOpenStar,
imageVector = Icons.Outlined.Star,
onClick = { context.startActivity<RssFavoritesActivity>() },
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite)
)
},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
onClick = onOpenConfig,
leadingIcon = { MenuItemIcon(Icons.Outlined.Settings) },
onClick = {
context.startActivity<RssSourceActivity>()
dismiss()
},
leadingIcon = { MenuItemIcon(Icons.Default.Settings) },
text = { Text("订阅源管理") }
)
PillDivider()
@@ -131,16 +174,39 @@ fun RssScreen(
RssSourceGridItem(
modifier = Modifier.animateItem(),
source = source,
onClick = { onOpenRss(source) },
onClick = { openRss(source) },
onTop = { viewModel.topSource(source) },
onEdit = { onEdit(source) },
onDelete = { onDelete(source) },
onEdit = { edit(source) },
onDelete = { sourceToDelete = source },
onDisable = { viewModel.disable(source) },
onLogin = { onLogin(source) }
onLogin = { login(source) }
)
}
}
}
sourceToDelete?.let { source ->
AlertDialog(
onDismissRequest = { sourceToDelete = null },
title = { Text(stringResource(R.string.draw)) },
text = { Text(stringResource(R.string.sure_del) + "\n" + source.sourceName) },
confirmButton = {
TextButton(
onClick = {
viewModel.del(source)
sourceToDelete = null
}
) {
Text(stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = { sourceToDelete = null }) {
Text(stringResource(R.string.no))
}
}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@@ -1,299 +0,0 @@
package io.legado.app.ui.widget.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.card.GlassCard
import kotlinx.coroutines.launch
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
const val START_PAGE_OFFSET = 100
/**
* @param modifier 外部修饰符
* @param initialDate 初始显示的月份日期
* @param onDateSelected 日期被选中时的回调
*/
@Composable
fun Calendar(
modifier: Modifier = Modifier,
initialDate: LocalDate = LocalDate.now(),
onDateSelected: (LocalDate) -> Unit,
selectedDate: LocalDate? = null,
onClearDate: (() -> Unit)? = null
) {
val baseMonth = remember(selectedDate) {
selectedDate?.withDayOfMonth(1) ?: initialDate.withDayOfMonth(1)
}
val pagerState = rememberPagerState(initialPage = START_PAGE_OFFSET) {
START_PAGE_OFFSET * 2
}
val coroutineScope = rememberCoroutineScope()
val currentMonth by remember {
derivedStateOf {
val pageOffset = pagerState.currentPage - START_PAGE_OFFSET
baseMonth.plusMonths(pageOffset.toLong())
}
}
GlassCard(
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer
) {
Column(
modifier = Modifier
.padding(vertical = 12.dp)
.animateContentSize()
) {
MonthNavigation(
currentMonth = currentMonth,
selectedDate = selectedDate,
onPreviousClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
onNextClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
onClearClick = onClearDate
)
Spacer(modifier = Modifier.height(8.dp))
DayOfWeekHeader()
Spacer(modifier = Modifier.height(8.dp))
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
verticalAlignment = Alignment.Top
) { page ->
val pageOffset = page - START_PAGE_OFFSET
val monthForPage = baseMonth.plusMonths(pageOffset.toLong())
MonthPageContent(
month = monthForPage,
selectedDate = selectedDate,
onDateClick = onDateSelected
)
}
}
}
}
@Composable
fun MonthPageContent(
month: LocalDate,
selectedDate: LocalDate?,
onDateClick: (LocalDate) -> Unit
) {
val datesInMonth = getDatesInMonth(month)
val firstDayOfWeek = month.dayOfWeek.value % 7
val offset = firstDayOfWeek
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier
.fillMaxWidth(),
userScrollEnabled = false,
horizontalArrangement = Arrangement.SpaceAround
) {
items(offset) {
Spacer(modifier = Modifier.size(36.dp))
}
items(datesInMonth) { date ->
DateItem(
date = date,
isSelected = date == selectedDate,
isToday = date == LocalDate.now(),
isCurrentMonth = date.month == month.month,
onClick = { onDateClick(date) }
)
}
}
}
@Composable
fun MonthNavigation(
currentMonth: LocalDate,
selectedDate: LocalDate?,
onPreviousClick: () -> Unit,
onNextClick: () -> Unit,
onClearClick: (() -> Unit)?
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = currentMonth.year.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
AnimatedTextLine(
text = currentMonth.format(DateTimeFormatter.ofPattern("MMMM", Locale.getDefault())),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (selectedDate != null && onClearClick != null) {
FilledTonalIconButton(onClick = onClearClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear Date Selection",
)
}
}
IconButton(onClick = onPreviousClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "Previous Month",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = onNextClick) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = "Next Month",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
fun DayOfWeekHeader() {
val dayNames = listOf(
DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY
).map {
it.getDisplayName(TextStyle.SHORT, Locale.getDefault())
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
dayNames.forEach { dayName ->
Text(
text = dayName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(36.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
@Composable
fun DateItem(
date: LocalDate,
isSelected: Boolean,
isToday: Boolean,
isCurrentMonth: Boolean,
onClick: () -> Unit
) {
val buttonColors = when {
isSelected -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
isCurrentMonth -> ButtonDefaults.textButtonColors(
contentColor = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
else -> ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
val borderStroke = if (isToday && !isSelected) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
} else {
null
}
Button(
onClick = onClick,
modifier = Modifier
.size(36.dp)
.padding(4.dp),
shape = CircleShape,
colors = buttonColors,
border = borderStroke,
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)
) {
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
)
}
}
fun getDatesInMonth(month: LocalDate): List<LocalDate> {
val firstDayOfMonth = month.withDayOfMonth(1)
val lengthOfMonth = month.lengthOfMonth()
return (0 until lengthOfMonth).map { firstDayOfMonth.plusDays(it.toLong()) }
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,7 +39,7 @@ fun SplicedColumnGroup(
GlassCard(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Column(
@@ -3,12 +3,12 @@ package io.legado.app.ui.widget.components.card
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import io.legado.app.ui.config.themeConfig.ThemeConfig
@@ -17,22 +17,20 @@ fun GlassCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
shape: Shape = CardDefaults.shape,
color: Color = MaterialTheme.colorScheme.surfaceContainerLow,
colors: CardColors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = ThemeConfig.containerOpacity / 100f),
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
),
elevation: CardElevation = CardDefaults.cardElevation(),
border: BorderStroke? = null,
content: @Composable ColumnScope.() -> Unit
) {
val opacity = ThemeConfig.containerOpacity
val cardColors = CardDefaults.cardColors(
containerColor = color.copy(alpha = opacity / 100f)
)
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = cardColors,
colors = colors,
elevation = elevation,
border = border,
content = content
@@ -41,10 +39,10 @@ fun GlassCard(
Card(
modifier = modifier,
shape = shape,
colors = cardColors,
colors = colors,
elevation = elevation,
border = border,
content = content
)
}
}
}
@@ -79,7 +79,7 @@ fun SelectionItemCard(
.padding(horizontal = 16.dp)
.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = containerColor,
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
) {
Row(
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
@@ -19,7 +20,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.AnimatedTextLine
@Preview(showBackground = true)
@Composable
@@ -36,6 +37,7 @@ fun TextCard(
modifier: Modifier = Modifier,
text: String,
icon: ImageVector? = null,
onClick: (() -> Unit)? = null,
backgroundColor: Color = colorScheme.primaryContainer,
contentColor: Color = colorScheme.onPrimaryContainer,
cornerRadius: Dp = 8.dp,
@@ -49,7 +51,8 @@ fun TextCard(
GlassCard(
modifier = modifier,
shape = RoundedCornerShape(cornerRadius),
color = backgroundColor
colors = CardDefaults.cardColors(containerColor = backgroundColor),
onClick = onClick
) {
Row(
modifier = Modifier.padding(
@@ -69,7 +72,7 @@ fun TextCard(
Spacer(modifier = Modifier.width(spacing))
}
AnimatedText(
AnimatedTextLine(
text = text,
style = textStyle,
color = contentColor,
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -62,7 +63,9 @@ fun SettingItem(
modifier = modifier
.fillMaxWidth(),
shape = shape,
color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
colors = CardDefaults.cardColors(
containerColor = color ?: MaterialTheme.colorScheme.surfaceContainerLow
),
) {
Column {
ListItem(