diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesFragment.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesFragment.kt deleted file mode 100644 index 246fd646a..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.legado.app.ui.rss.favorites - - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import io.legado.app.R -import io.legado.app.base.VMBaseFragment -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.data.entities.RssStar -import io.legado.app.databinding.FragmentRssArticlesBinding -import io.legado.app.lib.dialogs.alert -//import io.legado.app.lib.theme.primaryColor -import io.legado.app.ui.rss.read.ReadRssActivity -import io.legado.app.ui.widget.recycler.VerticalDivider -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.startActivity -import io.legado.app.utils.viewbindingdelegate.viewBinding -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch - -class RssFavoritesFragment() : VMBaseFragment(R.layout.fragment_rss_articles), - RssFavoritesAdapter.CallBack { - - constructor(group: String) : this() { - arguments = Bundle().apply { - putString("group", group) - } - } - - private val binding by viewBinding(FragmentRssArticlesBinding::bind) - override val viewModel by viewModels() - private val adapter: RssFavoritesAdapter by lazy { - RssFavoritesAdapter(requireContext(), this@RssFavoritesFragment) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initView() - loadArticles() - } - - private fun initView() = binding.run { - refreshLayout.isEnabled = false -// recyclerView.setEdgeEffectColor(primaryColor) - recyclerView.layoutManager = run { - recyclerView.addItemDecoration(VerticalDivider(requireContext())) - LinearLayoutManager(requireContext()) - } - recyclerView.adapter = adapter - recyclerView.applyNavigationBarPadding() - } - - private fun loadArticles() { - lifecycleScope.launch { - val group = arguments?.getString("group") ?: "默认分组" - appDb.rssStarDao.flowByGroup(group).catch { - AppLog.put("订阅文章界面获取数据失败\n${it.localizedMessage}", it) - }.flowOn(IO).collect { - adapter.setItems(it) - } - } - } - - override fun readRss(rssStar: RssStar) { - startActivity { - putExtra("title", rssStar.title) - putExtra("origin", rssStar.origin) - putExtra("link", rssStar.link) - } - } - - override fun delStar(rssStar: RssStar) { - alert(R.string.draw) { - setMessage(getString(R.string.sure_del) + "\n<" + rssStar.title + ">") - noButton() - yesButton { - appDb.rssStarDao.delete(rssStar.origin, rssStar.link) - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt index 745195064..c2808857b 100644 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt @@ -11,36 +11,37 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Group import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import coil.request.ImageRequest import io.legado.app.R +import io.legado.app.data.entities.RssStar import io.legado.app.ui.rss.read.ReadRssActivity +import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.EmptyMessageView +import io.legado.app.ui.widget.components.SourceIcon +import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.dialog.TextListInputDialog import io.legado.app.ui.widget.components.divider.PillDivider import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.rules.RuleListScaffold @@ -57,14 +58,55 @@ fun RssFavoritesScreen( val groups by viewModel.groups.collectAsState() val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(groups) { - if (state.currentGroup.isEmpty() && groups.isNotEmpty()) { - viewModel.onGroupChange(groups.first()) - } + var showAddToGroupDialog by remember { mutableStateOf(false) } + var showRemoveFromGroupDialog by remember { mutableStateOf(false) } + var showSetGroupDialog by remember { mutableStateOf(null) } + + if (showAddToGroupDialog) { + TextListInputDialog( + title = stringResource(R.string.add_group), + hint = stringResource(R.string.group_name), + suggestions = groups, + onDismissRequest = { showAddToGroupDialog = false }, + onConfirm = { + viewModel.selectionAddToGroups(state.selectedIds, it) + showAddToGroupDialog = false + viewModel.clearSelection() + } + ) + } + + if (showRemoveFromGroupDialog) { + TextListInputDialog( + title = stringResource(R.string.remove_group), + hint = stringResource(R.string.group_name), + suggestions = groups, + onDismissRequest = { showRemoveFromGroupDialog = false }, + onConfirm = { + viewModel.selectionRemoveFromGroups(state.selectedIds, it) + showRemoveFromGroupDialog = false + viewModel.clearSelection() + } + ) + } + + showSetGroupDialog?.let { rssStar -> + TextListInputDialog( + title = stringResource(R.string.change_group), + hint = stringResource(R.string.group_name), + initialValue = rssStar.group, + suggestions = groups, + onDismissRequest = { showSetGroupDialog = null }, + onConfirm = { + viewModel.updateGroup(rssStar, it) + showSetGroupDialog = null + } + ) } RuleListScaffold( title = stringResource(R.string.favorite), + subtitle = state.currentGroup.ifEmpty { stringResource(R.string.all) }, state = state, onBackClick = onBackClick, onSearchToggle = viewModel::onSearchToggle, @@ -74,7 +116,16 @@ fun RssFavoritesScreen( onSelectInvert = viewModel::selectInvert, onDeleteSelected = { viewModel.deleteSelected() }, snackbarHostState = snackbarHostState, - selectionSecondaryActions = emptyList(), + selectionSecondaryActions = listOf( + ActionItem( + text = stringResource(R.string.add_group), + onClick = { showAddToGroupDialog = true } + ), + ActionItem( + text = stringResource(R.string.remove_group), + onClick = { showRemoveFromGroupDialog = true } + ) + ), topBarActions = {}, dropDownMenuContent = { dismiss -> RoundDropdownMenuItem( @@ -128,7 +179,7 @@ fun RssFavoritesScreen( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.DeleteForever, null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.delete_all)) + Text(stringResource(R.string.all)) } }, onClick = { @@ -136,23 +187,6 @@ fun RssFavoritesScreen( dismiss() } ) - }, - bottomContent = { - if (groups.size > 1) { - PrimaryScrollableTabRow( - selectedTabIndex = groups.indexOf(state.currentGroup).coerceAtLeast(0), - edgePadding = 16.dp, - divider = {} - ) { - groups.forEach { group -> - Tab( - selected = state.currentGroup == group, - onClick = { viewModel.onGroupChange(group) }, - text = { Text(group) } - ) - } - } - } } ) { paddingValues -> if (state.items.isEmpty()) { @@ -178,38 +212,48 @@ fun RssFavoritesScreen( val isSelected = state.selectedIds.contains(id) SelectionItemCard( title = rssStar.title, - subtitle = rssStar.pubDate, + subtitle = if (rssStar.group.isNotBlank()) { + "${rssStar.group} • ${rssStar.pubDate ?: ""}" + } else { + rssStar.pubDate + }, isSelected = isSelected, inSelectionMode = state.selectedIds.isNotEmpty(), onToggleSelection = { - if (state.selectedIds.isNotEmpty()) { - viewModel.toggleSelection(rssStar) - } else { + viewModel.toggleSelection(rssStar) + }, + leadingContent = if (!rssStar.image.isNullOrBlank()) { + { + SourceIcon( + path = rssStar.image, + sourceOrigin = rssStar.origin, + contentScale = ContentScale.Crop, + modifier = Modifier.size(54.dp) + ) + } + } else null, + trailingAction = { + val openAction = { context.startActivity { putExtra("title", rssStar.title) putExtra("origin", rssStar.origin) putExtra("link", rssStar.link) } } - }, - trailingAction = { - if (!rssStar.image.isNullOrBlank()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(rssStar.image) - .setHeader("sourceOrigin", rssStar.origin) - .crossfade(true) - .build(), - contentDescription = null, - modifier = Modifier - .size(width = 80.dp, height = 50.dp) - .padding(start = 8.dp) - .clip(MaterialTheme.shapes.small), - contentScale = ContentScale.Crop - ) - } + SmallIconButton( + onClick = openAction, + icon = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open" + ) }, dropdownContent = { dismiss -> + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.change_group)) }, + onClick = { + showSetGroupDialog = rssStar + dismiss() + } + ) RoundDropdownMenuItem( text = { Text(stringResource(R.string.delete)) }, onClick = { diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt index a2b174a5f..655b679f7 100644 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt @@ -37,8 +37,6 @@ class RssFavoritesViewModel(application: Application) : BaseViewModel(applicatio _selectedIds, _currentGroup.flatMapLatest { group -> if (group.isEmpty()) { - // If group is empty, we might want to wait for the first group from 'groups' flow - // Or just show nothing/default. The Activity/Screen should set the first group. appDb.rssStarDao.liveAll() } else { appDb.rssStarDao.flowByGroup(group) @@ -73,6 +71,10 @@ class RssFavoritesViewModel(application: Application) : BaseViewModel(applicatio _selectedIds.value = emptySet() } + fun setSelection(ids: Set) { + _selectedIds.value = ids + } + fun toggleSelection(rssStar: RssStar) { val id = "${rssStar.origin}|${rssStar.link}" _selectedIds.update { @@ -108,6 +110,44 @@ class RssFavoritesViewModel(application: Application) : BaseViewModel(applicatio } } + fun selectionAddToGroups(selectedIds: Set, groupName: String) { + viewModelScope.launch(IO) { + state.value.items.forEach { + val id = "${it.origin}|${it.link}" + if (selectedIds.contains(id)) { + val groups = it.group.split(",").toMutableList() + if (!groups.contains(groupName)) { + groups.add(groupName) + appDb.rssStarDao.update(it.copy(group = groups.filter { g -> g.isNotBlank() } + .joinToString(","))) + } + } + } + } + } + + fun selectionRemoveFromGroups(selectedIds: Set, groupName: String) { + viewModelScope.launch(IO) { + state.value.items.forEach { + val id = "${it.origin}|${it.link}" + if (selectedIds.contains(id)) { + val groups = it.group.split(",").toMutableList() + if (groups.contains(groupName)) { + groups.remove(groupName) + appDb.rssStarDao.update(it.copy(group = groups.filter { g -> g.isNotBlank() } + .joinToString(","))) + } + } + } + } + } + + fun updateGroup(rssStar: RssStar, group: String) { + viewModelScope.launch(IO) { + appDb.rssStarDao.update(rssStar.copy(group = group)) + } + } + fun deleteGroup(group: String) { viewModelScope.launch(IO) { appDb.rssStarDao.deleteByGroup(group) diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt index b6680390d..48439b4f7 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt @@ -242,7 +242,7 @@ fun RssSourceScreen( RuleListScaffold( title = stringResource(R.string.rss_source), - subtitle = uiState.groupFilterName, + subtitle = uiState.groupFilterName ?: stringResource(R.string.all), state = uiState, onBackClick = { onBackClick() }, onSearchToggle = { active -> viewModel.setSearchMode(active) }, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt b/app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt index d5d99dffa..24d0f1249 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt @@ -31,6 +31,7 @@ fun SourceIcon( modifier: Modifier = Modifier.size(32.dp), sourceOrigin: String? = null, loadOnlyWifi: Boolean = false, + contentScale: ContentScale = ContentScale.Fit, imageLoader: ImageLoader = koinInject(), placeholderIcon: @Composable () -> Unit = { Icon( @@ -59,7 +60,7 @@ fun SourceIcon( .build(), imageLoader = imageLoader, contentDescription = null, - contentScale = ContentScale.Fit, // 不裁切 + contentScale = contentScale, // 不裁切 modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt index 25b56aead..08019d6ea 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt @@ -1,15 +1,12 @@ package io.legado.app.ui.widget.components.card +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,7 +21,6 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -46,6 +42,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState @@ -59,6 +56,7 @@ fun SelectionItemCard( inSelectionMode: Boolean = false, elevation: Dp = 0.dp, onToggleSelection: () -> Unit = {}, + leadingContent: @Composable (() -> Unit)? = null, onEnabledChange: ((Boolean) -> Unit)? = null, onClickEdit: (() -> Unit)? = null, trailingAction: @Composable (RowScope.() -> Unit)? = null, @@ -87,20 +85,30 @@ fun SelectionItemCard( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp) .animateContentSize(), verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility( - visible = inSelectionMode, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally() + visible = inSelectionMode || leadingContent != null ) { - Checkbox( - checked = isSelected, - onCheckedChange = null, - modifier = Modifier.padding(start = 12.dp) - ) + Box( + modifier = Modifier.padding(start = 12.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = inSelectionMode, + label = "LeadingContent" + ) { selectionMode -> + if (selectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = null + ) + } else { + leadingContent?.invoke() + } + } + } } ListItem( @@ -159,7 +167,7 @@ fun SelectionItemCard( icon = Icons.Default.MoreVert, contentDescription = "More" ) - DropdownMenu( + RoundDropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false } ) { @@ -185,6 +193,7 @@ fun LazyItemScope.ReorderableSelectionItem( inSelectionMode: Boolean = false, canReorder: Boolean = true, onToggleSelection: () -> Unit = {}, + leadingContent: @Composable (() -> Unit)? = null, onEnabledChange: ((Boolean) -> Unit)? = null, onClickEdit: (() -> Unit)? = null, trailingAction: @Composable (RowScope.() -> Unit)? = null, @@ -206,6 +215,7 @@ fun LazyItemScope.ReorderableSelectionItem( inSelectionMode = inSelectionMode, elevation = elevation, onToggleSelection = onToggleSelection, + leadingContent = leadingContent, onEnabledChange = onEnabledChange, onClickEdit = onClickEdit, trailingAction = trailingAction, @@ -227,4 +237,4 @@ fun LazyItemScope.ReorderableSelectionItem( .animateItem() ) } -} \ No newline at end of file +}