[新增] 由Compose重写的发现界面,可能存在bug
This commit is contained in:
@@ -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.otherConfig.OtherConfigViewModel
|
||||||
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
||||||
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
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.my.MyViewModel
|
||||||
|
import io.legado.app.ui.main.rss.RssViewModel
|
||||||
import io.legado.app.ui.replace.ReplaceEditRoute
|
import io.legado.app.ui.replace.ReplaceEditRoute
|
||||||
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
||||||
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
||||||
@@ -82,6 +84,8 @@ val appModule = module {
|
|||||||
viewModelOf(::TocViewModel)
|
viewModelOf(::TocViewModel)
|
||||||
viewModelOf(::RemoteBookViewModel)
|
viewModelOf(::RemoteBookViewModel)
|
||||||
viewModelOf(::BookInfoViewModel)
|
viewModelOf(::BookInfoViewModel)
|
||||||
|
viewModelOf(::ExploreViewModel)
|
||||||
|
viewModelOf(::RssViewModel)
|
||||||
|
|
||||||
viewModel { (route: ReplaceEditRoute) ->
|
viewModel { (route: ReplaceEditRoute) ->
|
||||||
ReplaceEditViewModel(
|
ReplaceEditViewModel(
|
||||||
|
|||||||
@@ -28,4 +28,11 @@ object ThemeConfig {
|
|||||||
!bgImageLight.isNullOrEmpty()
|
!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
|
package io.legado.app.ui.main.explore
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.LayoutInflater
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.SubMenu
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.SearchView
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isGone
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.core.view.isVisible
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.Fragment
|
||||||
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 io.legado.app.ui.main.MainFragmentInterface
|
import io.legado.app.ui.main.MainFragmentInterface
|
||||||
import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
|
import io.legado.app.ui.theme.AppTheme
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发现界面
|
* 发现界面
|
||||||
*/
|
*/
|
||||||
class ExploreFragment() : VMBaseFragment<ExploreViewModel>(R.layout.fragment_explore),
|
class ExploreFragment() : Fragment(), MainFragmentInterface {
|
||||||
MainFragmentInterface,
|
|
||||||
ExploreAdapter.CallBack {
|
|
||||||
|
|
||||||
constructor(position: Int) : this() {
|
constructor(position: Int) : this() {
|
||||||
val bundle = Bundle()
|
arguments = Bundle().apply {
|
||||||
bundle.putInt("position", position)
|
putInt("position", position)
|
||||||
arguments = bundle
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val position: Int? get() = arguments?.getInt("position")
|
override val position: Int
|
||||||
|
get() = arguments?.getInt("position") ?: 0
|
||||||
|
|
||||||
override val viewModel by viewModels<ExploreViewModel>()
|
override fun onCreateView(
|
||||||
private val binding by viewBinding(FragmentExploreBinding::bind)
|
inflater: LayoutInflater,
|
||||||
private val adapter by lazy {
|
container: ViewGroup?,
|
||||||
ExploreAdapter(requireContext(), this)
|
savedInstanceState: Bundle?
|
||||||
}
|
): View {
|
||||||
private val linearLayoutManager by lazy { LinearLayoutManager(context) }
|
return ComposeView(requireContext()).apply {
|
||||||
private val searchView: SearchView by lazy { binding.searchBar }
|
setViewCompositionStrategy(
|
||||||
private val diffItemCallBack = ExploreDiffItemCallBack()
|
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
|
||||||
private val groups = linkedSetOf<String>()
|
)
|
||||||
private var exploreFlowJob: Job? = null
|
setContent {
|
||||||
private var groupsMenu: SubMenu? = null
|
AppTheme {
|
||||||
|
ExploreScreen()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
fun compressExplore() {
|
||||||
if (!adapter.compressExplore()) {
|
// Compose version handled by ViewModel state
|
||||||
if (AppConfig.isEInkMode) {
|
|
||||||
binding.rvFind.scrollToPosition(0)
|
|
||||||
} else {
|
|
||||||
binding.rvFind.smoothScrollToPosition(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package io.legado.app.ui.main.explore
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.BookSourcePart
|
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.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) {
|
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) {
|
fun topSource(bookSource: BookSourcePart) {
|
||||||
execute {
|
execute {
|
||||||
val minXh = appDb.bookSourceDao.minOrder
|
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.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.compose.ui.platform.ComposeView
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import io.legado.app.R
|
import androidx.fragment.app.Fragment
|
||||||
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 io.legado.app.ui.main.MainFragmentInterface
|
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.ui.theme.AppTheme
|
||||||
import io.legado.app.utils.openUrl
|
|
||||||
import io.legado.app.utils.startActivity
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅界面
|
* 订阅界面
|
||||||
*/
|
*/
|
||||||
class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
|
class RssFragment() : Fragment(), MainFragmentInterface {
|
||||||
MainFragmentInterface {
|
|
||||||
|
|
||||||
constructor(position: Int) : this() {
|
constructor(position: Int) : this() {
|
||||||
val bundle = Bundle()
|
arguments = Bundle().apply {
|
||||||
bundle.putInt("position", position)
|
putInt("position", position)
|
||||||
arguments = bundle
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val position: Int? get() = arguments?.getInt("position")
|
override val position: Int
|
||||||
|
get() = arguments?.getInt("position") ?: 0
|
||||||
override val viewModel by viewModels<RssViewModel>()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@@ -44,66 +30,14 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
|
|||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
return ComposeView(requireContext()).apply {
|
return ComposeView(requireContext()).apply {
|
||||||
|
setViewCompositionStrategy(
|
||||||
|
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
|
||||||
|
)
|
||||||
setContent {
|
setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
RssScreen(
|
RssScreen()
|
||||||
viewModel = viewModel,
|
|
||||||
onOpenRss = { openRss(it) },
|
|
||||||
onEdit = { edit(it) },
|
|
||||||
onOpenStar = { startActivity<RssFavoritesActivity>() },
|
|
||||||
onOpenConfig = { startActivity<RssSourceActivity>() },
|
|
||||||
onOpenRuleSub = { startActivity<RuleSubActivity>() },
|
|
||||||
onDelete = { del(it) },
|
|
||||||
onLogin = { login(it) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Group
|
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.filled.VerticalAlignTop
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material.icons.outlined.Star
|
|
||||||
import androidx.compose.material.icons.outlined.Subscriptions
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -39,12 +41,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.R
|
||||||
import io.legado.app.data.entities.RssSource
|
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.SourceIcon
|
||||||
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
||||||
import io.legado.app.ui.widget.components.divider.PillDivider
|
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.MenuItemIcon
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
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.menuItem.RoundDropdownMenuItem
|
||||||
|
import io.legado.app.utils.openUrl
|
||||||
|
import io.legado.app.utils.startActivity
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RssScreen(
|
fun RssScreen(
|
||||||
viewModel: RssViewModel,
|
viewModel: RssViewModel = koinViewModel()
|
||||||
onOpenRss: (RssSource) -> Unit,
|
|
||||||
onEdit: (RssSource) -> Unit,
|
|
||||||
onOpenStar: () -> Unit,
|
|
||||||
onOpenConfig: () -> Unit,
|
|
||||||
onOpenRuleSub: () -> Unit,
|
|
||||||
onDelete: (RssSource) -> Unit,
|
|
||||||
onLogin: (RssSource) -> Unit
|
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
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(
|
ListScaffold(
|
||||||
title = stringResource(R.string.rss),
|
title = stringResource(R.string.rss),
|
||||||
@@ -78,20 +118,23 @@ fun RssScreen(
|
|||||||
searchPlaceholder = stringResource(R.string.search_rss_source),
|
searchPlaceholder = stringResource(R.string.search_rss_source),
|
||||||
topBarActions = {
|
topBarActions = {
|
||||||
TopBarActionButton(
|
TopBarActionButton(
|
||||||
onClick = onOpenRuleSub,
|
onClick = { context.startActivity<RuleSubActivity>() },
|
||||||
imageVector = Icons.Outlined.Subscriptions,
|
imageVector = Icons.Default.Subscriptions,
|
||||||
contentDescription = stringResource(R.string.rule_subscription)
|
contentDescription = stringResource(R.string.rule_subscription)
|
||||||
)
|
)
|
||||||
TopBarActionButton(
|
TopBarActionButton(
|
||||||
onClick = onOpenStar,
|
onClick = { context.startActivity<RssFavoritesActivity>() },
|
||||||
imageVector = Icons.Outlined.Star,
|
imageVector = Icons.Default.Star,
|
||||||
contentDescription = stringResource(R.string.favorite)
|
contentDescription = stringResource(R.string.favorite)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
dropDownMenuContent = { dismiss ->
|
dropDownMenuContent = { dismiss ->
|
||||||
RoundDropdownMenuItem(
|
RoundDropdownMenuItem(
|
||||||
onClick = onOpenConfig,
|
onClick = {
|
||||||
leadingIcon = { MenuItemIcon(Icons.Outlined.Settings) },
|
context.startActivity<RssSourceActivity>()
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.Settings) },
|
||||||
text = { Text("订阅源管理") }
|
text = { Text("订阅源管理") }
|
||||||
)
|
)
|
||||||
PillDivider()
|
PillDivider()
|
||||||
@@ -131,16 +174,39 @@ fun RssScreen(
|
|||||||
RssSourceGridItem(
|
RssSourceGridItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
source = source,
|
source = source,
|
||||||
onClick = { onOpenRss(source) },
|
onClick = { openRss(source) },
|
||||||
onTop = { viewModel.topSource(source) },
|
onTop = { viewModel.topSource(source) },
|
||||||
onEdit = { onEdit(source) },
|
onEdit = { edit(source) },
|
||||||
onDelete = { onDelete(source) },
|
onDelete = { sourceToDelete = source },
|
||||||
onDisable = { viewModel.disable(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)
|
@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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -38,7 +39,7 @@ fun SplicedColumnGroup(
|
|||||||
|
|
||||||
GlassCard(
|
GlassCard(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package io.legado.app.ui.widget.components.card
|
|||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardColors
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CardElevation
|
import androidx.compose.material3.CardElevation
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
import io.legado.app.ui.config.themeConfig.ThemeConfig
|
||||||
|
|
||||||
@@ -17,22 +17,20 @@ fun GlassCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
shape: Shape = CardDefaults.shape,
|
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(),
|
elevation: CardElevation = CardDefaults.cardElevation(),
|
||||||
border: BorderStroke? = null,
|
border: BorderStroke? = null,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val opacity = ThemeConfig.containerOpacity
|
|
||||||
val cardColors = CardDefaults.cardColors(
|
|
||||||
containerColor = color.copy(alpha = opacity / 100f)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
colors = cardColors,
|
colors = colors,
|
||||||
elevation = elevation,
|
elevation = elevation,
|
||||||
border = border,
|
border = border,
|
||||||
content = content
|
content = content
|
||||||
@@ -41,7 +39,7 @@ fun GlassCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
colors = cardColors,
|
colors = colors,
|
||||||
elevation = elevation,
|
elevation = elevation,
|
||||||
border = border,
|
border = border,
|
||||||
content = content
|
content = content
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ fun SelectionItemCard(
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = containerColor,
|
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
|
elevation = CardDefaults.cardElevation(defaultElevation = elevation)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,6 +37,7 @@ fun TextCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector? = null,
|
icon: ImageVector? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
backgroundColor: Color = colorScheme.primaryContainer,
|
backgroundColor: Color = colorScheme.primaryContainer,
|
||||||
contentColor: Color = colorScheme.onPrimaryContainer,
|
contentColor: Color = colorScheme.onPrimaryContainer,
|
||||||
cornerRadius: Dp = 8.dp,
|
cornerRadius: Dp = 8.dp,
|
||||||
@@ -49,7 +51,8 @@ fun TextCard(
|
|||||||
GlassCard(
|
GlassCard(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(cornerRadius),
|
shape = RoundedCornerShape(cornerRadius),
|
||||||
color = backgroundColor
|
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||||
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
@@ -69,7 +72,7 @@ fun TextCard(
|
|||||||
Spacer(modifier = Modifier.width(spacing))
|
Spacer(modifier = Modifier.width(spacing))
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedText(
|
AnimatedTextLine(
|
||||||
text = text,
|
text = text,
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
@@ -62,7 +63,9 @@ fun SettingItem(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
shape = shape,
|
shape = shape,
|
||||||
color = color ?: MaterialTheme.colorScheme.surfaceContainerLow,
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = color ?: MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|||||||
Reference in New Issue
Block a user