Преглед изворни кода

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

HapeLee пре 8 месеци
родитељ
комит
d6f0ca01b6

+ 4 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -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(

+ 7 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -28,4 +28,11 @@ object ThemeConfig {
             !bgImageLight.isNullOrEmpty()
         }
     }
+
+    /*
+    fun hasImageBg(isDark: Boolean): Boolean {
+        return if (isDark) bgImageDark.isNullOrBlank() else bgImageLight.isNullOrBlank()
+    }
+     */
+
 }

+ 28 - 236
app/src/main/java/io/legado/app/ui/main/explore/ExploreFragment.kt

@@ -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
-    }
-
-    override val position: Int? get() = arguments?.getInt("position")
-
-    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)
+        arguments = Bundle().apply {
+            putInt("position", position)
+        }
+    }
+
+    override val position: Int
+        get() = arguments?.getInt("position") ?: 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
     }
 
 }

+ 522 - 0
app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt

@@ -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
+    )
+}

+ 136 - 1
app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt

@@ -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>
+
+}

+ 12 - 78
app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt

@@ -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) }
-                    )
-                }
-            }
-        }
-    }
-
-    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)
+                    RssScreen()
                 }
             }
-        } 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)
-            }
         }
     }
 }

+ 87 - 21
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -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)

+ 0 - 299
app/src/main/java/io/legado/app/ui/widget/components/Calender.kt

@@ -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()) }
-}

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -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(

+ 8 - 10
app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt

@@ -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
         )
     }
-}
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt

@@ -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 - 3
app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -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,

+ 4 - 1
app/src/main/java/io/legado/app/ui/widget/components/settingItem/SettingItem.kt

@@ -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(