diff --git a/app/src/main/java/io/legado/app/data/repository/RssRepository.kt b/app/src/main/java/io/legado/app/data/repository/RssRepository.kt new file mode 100644 index 000000000..a31184073 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/RssRepository.kt @@ -0,0 +1,31 @@ +package io.legado.app.data.repository + +import io.legado.app.data.appDb +import io.legado.app.data.entities.RssSource +import io.legado.app.help.source.SourceHelp +import kotlinx.coroutines.flow.Flow + +class RssRepository { + private val dao = appDb.rssSourceDao + + fun getEnabledSources(): Flow> = dao.flowEnabled() + + fun getEnabledSources(searchKey: String): Flow> = dao.flowEnabled(searchKey) + + fun getEnabledSourcesByGroup(group: String): Flow> = + dao.flowEnabledByGroup(group) + + fun getEnabledGroups(): Flow> = dao.flowEnabledGroups() + + suspend fun updateSources(vararg sources: RssSource) { + dao.update(*sources) + } + + suspend fun deleteSources(sources: List) { + SourceHelp.deleteRssSources(sources) + } + + fun getMinOrder(): Int = dao.minOrder + + fun getMaxOrder(): Int = dao.maxOrder +} diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt deleted file mode 100644 index 71b04d480..000000000 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.legado.app.ui.main.rss - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.PopupMenu -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import com.bumptech.glide.request.RequestOptions -import io.legado.app.R -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.RssSource -import io.legado.app.databinding.ItemRssBinding -import io.legado.app.help.glide.ImageLoader -import io.legado.app.help.glide.OkHttpModelLoader -import splitties.views.onLongClick - -class RssAdapter( - context: Context, - private val fragment: Fragment, - private val callBack: CallBack, - private val lifecycle: Lifecycle -) : RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemRssBinding { - return ItemRssBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemRssBinding, - item: RssSource, - payloads: MutableList - ) { - binding.apply { - tvName.text = item.sourceName - val options = RequestOptions() - .set(OkHttpModelLoader.sourceOriginOption, item.sourceUrl) - ImageLoader.load(fragment, lifecycle, item.sourceIcon) - .apply(options) - .centerCrop() - .placeholder(R.drawable.image_rss) - .error(R.drawable.image_rss) - .into(ivIcon) - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemRssBinding) { - binding.apply { - root.setOnClickListener { - getItemByLayoutPosition(holder.layoutPosition)?.let { - callBack.openRss(it) - } - } - root.onLongClick { - getItemByLayoutPosition(holder.layoutPosition)?.let { - showMenu(ivIcon, it) - } - } - } - } - - private fun showMenu(view: View, rssSource: RssSource) { - val popupMenu = PopupMenu(context, view) - popupMenu.inflate(R.menu.rss_main_item) - popupMenu.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_top -> callBack.toTop(rssSource) - R.id.menu_edit -> callBack.edit(rssSource) - R.id.menu_del -> callBack.del(rssSource) - R.id.menu_disable -> callBack.disable(rssSource) - } - true - } - popupMenu.show() - } - - interface CallBack { - fun openRss(rssSource: RssSource) - fun toTop(rssSource: RssSource) - fun edit(rssSource: RssSource) - fun del(rssSource: RssSource) - fun disable(rssSource: RssSource) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt index 4e349e256..2169850d0 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt @@ -1,26 +1,16 @@ package io.legado.app.ui.main.rss -import android.os.Build import android.os.Bundle -import android.transition.TransitionManager -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.isVisible +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope 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.RssSource -import io.legado.app.databinding.FragmentRssBinding -import io.legado.app.databinding.ItemRssBinding 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.rss.article.RssSortActivity import io.legado.app.ui.rss.favorites.RssFavoritesActivity @@ -28,25 +18,15 @@ 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.utils.flowWithLifecycleAndDatabaseChange +import io.legado.app.ui.theme.AppTheme import io.legado.app.utils.openUrl import io.legado.app.utils.startActivity -import io.legado.app.utils.transaction -import io.legado.app.utils.viewbindingdelegate.viewBinding -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch - /** * 订阅界面 */ class RssFragment() : VMBaseFragment(R.layout.fragment_rss), - MainFragmentInterface, - RssAdapter.CallBack { + MainFragmentInterface { constructor(position: Int) : this() { val bundle = Bundle() @@ -56,140 +36,36 @@ class RssFragment() : VMBaseFragment(R.layout.fragment_rss), override val position: Int? get() = arguments?.getInt("position") - private val binding by viewBinding(FragmentRssBinding::bind) override val viewModel by viewModels() - private val adapter by lazy { - RssAdapter(requireContext(), this, this, viewLifecycleOwner.lifecycle) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + AppTheme { + RssScreen( + viewModel = viewModel, + onOpenRss = { openRss(it) }, + onEdit = { edit(it) }, + onOpenStar = { startActivity() }, + onOpenConfig = { startActivity() }, + onOpenRuleSub = { startActivity() }, + onDelete = { del(it) }, + onLogin = { login(it) } + ) + } + } + } } - private val searchView: SearchView by lazy { binding.searchBar } - private var groupsFlowJob: Job? = null - private var rssFlowJob: Job? = null - private val groups = linkedSetOf() - private var groupsMenu: SubMenu? = null override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - setSupportToolbar(binding.topBar) - initSearchView() - initRecyclerView() - initGroupData() - upRssFlowJob() - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S) - binding.appBar.fitsSystemWindows = true + // Compose handles UI } - override fun onCompatCreateOptionsMenu(menu: Menu) { - menuInflater.inflate(R.menu.main_rss, menu) - groupsMenu = menu.findItem(R.id.menu_group)?.subMenu - upGroupsMenu() - } - - override fun onCompatOptionsItemSelected(item: MenuItem) { - super.onCompatOptionsItemSelected(item) - when (item.itemId) { - R.id.menu_rss_config -> startActivity() - R.id.menu_rss_star -> startActivity() - R.id.menu_rss_search -> { - TransitionManager.beginDelayedTransition(binding.rootView) - binding.searchBar.visibility = - if (binding.searchBar.isVisible) View.GONE else View.VISIBLE - } - else -> if (item.groupId == R.id.menu_group_text) { - if (item.title == getString(R.string.all)) { - upRssFlowJob() - searchView.setQuery("", false) - } else { - searchView.setQuery(item.title, false) - upRssFlowJob("group:${item.title}") - } - } - - } - } - - override fun onPause() { - super.onPause() - searchView.clearFocus() - } - - 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) - } - } - - private fun initSearchView() { - searchView.queryHint = getString(R.string.search_rss_source) - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - upRssFlowJob(query) - searchView.clearFocus() - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - upRssFlowJob(newText) - return true - } - }) - } - - private fun initRecyclerView() { - //binding.recyclerView.setEdgeEffectColor(primaryColor) - binding.recyclerView.adapter = adapter - adapter.addHeaderView { - ItemRssBinding.inflate(layoutInflater, it, false).apply { - tvName.setText(R.string.rule_subscription) - ivIcon.setImageResource(R.drawable.image_legado) - root.setOnClickListener { - startActivity() - } - } - } - } - - private fun initGroupData() { - groupsFlowJob?.cancel() - groupsFlowJob = viewLifecycleOwner.lifecycleScope.launch { - appDb.rssSourceDao.flowEnabledGroups().catch { - AppLog.put("订阅界面获取分组数据失败\n${it.localizedMessage}", it) - }.flowWithLifecycleAndDatabaseChange( - viewLifecycleOwner.lifecycle, - Lifecycle.State.RESUMED, - AppDatabase.RSS_SOURCE_TABLE_NAME - ).conflate().collect { - groups.clear() - groups.addAll(it) - upGroupsMenu() - } - } - } - - private fun upRssFlowJob(searchKey: String? = null) { - rssFlowJob?.cancel() - rssFlowJob = viewLifecycleOwner.lifecycleScope.launch { - when { - searchKey.isNullOrEmpty() -> appDb.rssSourceDao.flowEnabled() - searchKey.startsWith("group:") -> { - val key = searchKey.substringAfter("group:") - appDb.rssSourceDao.flowEnabledByGroup(key) - } - - else -> appDb.rssSourceDao.flowEnabled(searchKey) - }.flowWithLifecycleAndDatabaseChange( - viewLifecycleOwner.lifecycle, - Lifecycle.State.RESUMED, - AppDatabase.RSS_SOURCE_TABLE_NAME - ).catch { - AppLog.put("订阅界面更新数据出错", it) - }.flowOn(IO).collect { - adapter.setItems(it) - } - } - } - - override fun openRss(rssSource: RssSource) { + private fun openRss(rssSource: RssSource) { if (rssSource.singleUrl) { viewModel.getSingleUrl(rssSource) { url -> if (url.startsWith("http", true)) { @@ -208,17 +84,20 @@ class RssFragment() : VMBaseFragment(R.layout.fragment_rss), } } - override fun toTop(rssSource: RssSource) { - viewModel.topSource(rssSource) - } - - override fun edit(rssSource: RssSource) { + private fun edit(rssSource: RssSource) { startActivity { putExtra("sourceUrl", rssSource.sourceUrl) } } - override fun del(rssSource: RssSource) { + private fun login(rssSource: RssSource) { + startActivity { + 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() @@ -227,8 +106,4 @@ class RssFragment() : VMBaseFragment(R.layout.fragment_rss), } } } - - override fun disable(rssSource: RssSource) { - viewModel.disable(rssSource) - } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt new file mode 100644 index 000000000..e2016b93c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt @@ -0,0 +1,245 @@ +package io.legado.app.ui.main.rss + +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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.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.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.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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.widget.components.SourceIcon +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.PillHeaderDivider +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 + +@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 +) { + val uiState by viewModel.uiState.collectAsState() + + ListScaffold( + title = stringResource(R.string.rss), + state = uiState, + subtitle = uiState.group.ifEmpty { "全部" }, + onBackClick = null, + onSearchToggle = { viewModel.toggleSearchVisible(it) }, + onSearchQueryChange = { viewModel.search(it) }, + searchPlaceholder = stringResource(R.string.search_rss_source), + topBarActions = { + TopBarActionButton( + onClick = onOpenRuleSub, + imageVector = Icons.Outlined.Subscriptions, + contentDescription = stringResource(R.string.rule_subscription) + ) + TopBarActionButton( + onClick = onOpenStar, + imageVector = Icons.Outlined.Star, + contentDescription = stringResource(R.string.favorite) + ) + }, + dropDownMenuContent = { dismiss -> + RoundDropdownMenuItem( + onClick = onOpenConfig, + leadingIcon = { MenuItemIcon(Icons.Outlined.Settings) }, + text = { Text("订阅源管理") } + ) + PillDivider() + 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 -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 72.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = paddingValues.calculateTopPadding() + 8.dp, + bottom = paddingValues.calculateBottomPadding() + 12.dp + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.items, key = { it.sourceUrl }) { source -> + RssSourceGridItem( + modifier = Modifier.animateItem(), + source = source, + onClick = { onOpenRss(source) }, + onTop = { viewModel.topSource(source) }, + onEdit = { onEdit(source) }, + onDelete = { onDelete(source) }, + onDisable = { viewModel.disable(source) }, + onLogin = { onLogin(source) } + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RssSourceGridItem( + modifier: Modifier = Modifier, + source: RssSource, + onClick: () -> Unit, + onTop: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onDisable: () -> Unit, + onLogin: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .combinedClickable( + onClick = onClick, + onLongClick = { showMenu = true } + ) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box { + SourceIcon( + path = source.sourceIcon.ifEmpty { R.drawable.image_rss }, + sourceOrigin = source.sourceUrl, + modifier = Modifier.size(40.dp) + ) + RoundDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + PillHeaderDivider(title = source.sourceName) + 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 + } + ) + if (!source.loginUrl.isNullOrBlank()) { + RoundDropdownMenuItem( + leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) }, + text = { Text(stringResource(R.string.login)) }, + onClick = { + onLogin() + showMenu = false + } + ) + } + RoundDropdownMenuItem( + leadingIcon = { MenuItemIcon(Icons.Default.Close) }, + text = { Text(stringResource(R.string.disable_source)) }, + onClick = { + onDisable() + 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 + } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = source.sourceName, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt new file mode 100644 index 000000000..3060961cf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt @@ -0,0 +1,14 @@ +package io.legado.app.ui.main.rss + +import io.legado.app.data.entities.RssSource +import io.legado.app.ui.widget.components.rules.ListUiState + +data class RssUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + override val isSearch: Boolean = false, + override val isLoading: Boolean = false, + val groups: List = emptyList(), + val group: String = "" +) : ListUiState diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt index ace716925..30dd13ea4 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt @@ -1,15 +1,83 @@ package io.legado.app.ui.main.rss import android.app.Application +import androidx.lifecycle.viewModelScope import com.script.rhino.runScriptWithContext import io.legado.app.base.BaseViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource import io.legado.app.help.source.SourceHelp import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class RssViewModel(application: Application) : BaseViewModel(application) { + private val _uiState = MutableStateFlow(RssUiState()) + val uiState = _uiState.asStateFlow() + + init { + initGroupData() + initRssData() + } + + private fun initGroupData() { + viewModelScope.launch { + appDb.rssSourceDao.flowEnabledGroups() + .flowOn(IO) + .collect { groups -> + _uiState.update { state -> state.copy(groups = groups) } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun initRssData() { + combine( + _uiState.map { it.searchKey }.distinctUntilChanged(), + _uiState.map { it.group }.distinctUntilChanged() + ) { searchKey, group -> + searchKey to group + } + .flatMapLatest { (searchKey, group) -> + when { + searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey) + group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group) + else -> appDb.rssSourceDao.flowEnabled() + } + } + .flowOn(IO) + .onEach { sources -> + _uiState.update { state -> state.copy(items = sources) } + } + .launchIn(viewModelScope) + } + + fun search(key: String) { + _uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) } + } + + fun setGroup(group: String) { + _uiState.update { it.copy(group = group, searchKey = "", isSearch = false) } + } + + fun toggleSearchVisible(visible: Boolean) { + _uiState.update { + it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "") + } + } + fun topSource(vararg sources: RssSource) { execute { sources.sortBy { it.customOrder } @@ -78,6 +146,4 @@ class RssViewModel(application: Application) : BaseViewModel(application) { context.toastOnUi(it.localizedMessage) } } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt deleted file mode 100644 index 7946b4849..000000000 --- a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.legado.app.ui.replace - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.res.stringResource -import androidx.compose.ui.unit.dp -import io.legado.app.R -import io.legado.app.ui.widget.components.card.GlassCard -import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupManageBottomSheet( - groups: List, - onDismissRequest: () -> Unit, - viewModel: ReplaceRuleViewModel -) { - var editingGroup by remember { mutableStateOf(null) } - var updatedGroupName by remember { mutableStateOf("") } - - GlassModalBottomSheet( - onDismissRequest = onDismissRequest - ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource(R.string.group_manage), - style = MaterialTheme.typography.titleMedium - ) - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(groups) { group -> - GlassCard(modifier = Modifier.fillMaxWidth()) { - if (editingGroup == group) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedTextField( - value = updatedGroupName, - onValueChange = { updatedGroupName = it }, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = { - viewModel.upGroup(group, updatedGroupName) - editingGroup = null - }) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(id = R.string.ok) - ) - } - } - } else { - ListItem( - headlineContent = { Text(group) }, - trailingContent = { - Row { - IconButton(onClick = { - editingGroup = group - updatedGroupName = group - }) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(id = R.string.edit) - ) - } - IconButton(onClick = { viewModel.delGroup(group) }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(id = R.string.delete) - ) - } - } - } - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt index 1759cd482..20229fdd9 100644 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt @@ -1,177 +1,22 @@ -@file:Suppress("DEPRECATION") - package io.legado.app.ui.rss.favorites import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.SubMenu -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentStatePagerAdapter -import androidx.lifecycle.lifecycleScope -import androidx.viewpager.widget.ViewPager -import io.legado.app.R -import io.legado.app.base.BaseActivity -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.databinding.ActivityRssFavoritesBinding -import io.legado.app.lib.dialogs.alert -//import io.legado.app.lib.theme.accentColor -import io.legado.app.utils.gone -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch +import androidx.compose.runtime.Composable +import io.legado.app.base.BaseComposeActivity /** * 收藏夹 */ -class RssFavoritesActivity : BaseActivity() { - - override val binding by viewBinding(ActivityRssFavoritesBinding::inflate) - private val adapter by lazy { TabFragmentPageAdapter() } - private var groupList = mutableListOf() - private var groupsMenu: SubMenu? = null - private var currentGroup = "" +class RssFavoritesActivity : BaseComposeActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - initView() - upFragments() } - override fun onResume() { - super.onResume() - //从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项 - if (currentGroup.isNotEmpty() && groupList.isNotEmpty()){ - var item = groupList.indexOf(currentGroup) - val currentItem = binding.viewPager.currentItem - //如果坐标没有变化,则结束 - if(item == currentItem){ - return - } - if (item == -1){ - item = currentItem - } - lifecycleScope.launch { - delay(100) - binding.tabLayout.getTabAt(item)?.select() - } - } - } - - private fun initView() { - binding.viewPager.adapter = adapter - binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { - override fun onPageScrolled( - position: Int, - positionOffset: Float, - positionOffsetPixels: Int - ) { - } - - override fun onPageSelected(position: Int) { - currentGroup = groupList[position] - } - - override fun onPageScrollStateChanged(state: Int) {} - - }) - binding.tabLayout.setupWithViewPager(binding.viewPager) - //binding.tabLayout.setSelectedTabIndicatorColor(accentColor) - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.rss_favorites, menu) - groupsMenu = menu.findItem(R.id.menu_group)?.subMenu - upGroupsMenu() - return super.onCompatCreateOptionsMenu(menu) - } - - private fun upGroupsMenu() = groupsMenu?.let { subMenu -> - subMenu.removeGroup(R.id.menu_group) - groupList.forEachIndexed { index, it -> - subMenu.add(R.id.menu_group, Menu.NONE, index, it) - } - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - if (item.groupId == R.id.menu_group) { - binding.viewPager.setCurrentItem(item.order) - } else { - when (item.itemId) { - R.id.menu_del_group -> deleteGroup() - R.id.menu_del_all -> deleteAll() - } - } - return super.onCompatOptionsItemSelected(item) - } - - private fun upFragments() { - lifecycleScope.launch { - appDb.rssStarDao.flowGroups().catch { - AppLog.put("订阅分组数据获取失败\n${it.localizedMessage}", it) - }.distinctUntilChanged().flowOn(IO).collect { - groupList.clear() - groupList.addAll(it) - if (groupList.size == 1) { - binding.tabLayout.gone() - } else { - binding.tabLayout.visible() - } - if (groupsMenu != null) { - upGroupsMenu() - } - adapter.notifyDataSetChanged() - } - } - } - - private fun deleteGroup() { - alert(R.string.draw) { - val item = binding.viewPager.currentItem - val group = groupList[item] - setMessage(getString(R.string.sure_del) + "\n<" + group + ">" + getString(R.string.group)) - noButton() - yesButton { - appDb.rssStarDao.deleteByGroup(group) - } - } - } - - private fun deleteAll() { - alert(R.string.draw) { - setMessage(getString(R.string.sure_del) + "\n<" + getString(R.string.all) + ">" + getString(R.string.favorite)) - noButton() - yesButton { - appDb.rssStarDao.deleteAll() - } - } - } - - private inner class TabFragmentPageAdapter : - FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - - override fun getItemPosition(`object`: Any): Int { - return POSITION_NONE - } - - override fun getPageTitle(position: Int): CharSequence { - return groupList[position] - } - - override fun getItem(position: Int): Fragment { - val group = groupList[position] - return RssFavoritesFragment(group) - } - - override fun getCount(): Int { - return groupList.size - } - + @Composable + override fun Content() { + RssFavoritesScreen( + onBackClick = { finish() } + ) } } 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 new file mode 100644 index 000000000..745195064 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt @@ -0,0 +1,226 @@ +package io.legado.app.ui.rss.favorites + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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.remember +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.ui.rss.read.ReadRssActivity +import io.legado.app.ui.widget.components.EmptyMessageView +import io.legado.app.ui.widget.components.card.SelectionItemCard +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 +import io.legado.app.utils.startActivity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RssFavoritesScreen( + onBackClick: () -> Unit, + viewModel: RssFavoritesViewModel = viewModel() +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + val groups by viewModel.groups.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(groups) { + if (state.currentGroup.isEmpty() && groups.isNotEmpty()) { + viewModel.onGroupChange(groups.first()) + } + } + + RuleListScaffold( + title = stringResource(R.string.favorite), + state = state, + onBackClick = onBackClick, + onSearchToggle = viewModel::onSearchToggle, + onSearchQueryChange = viewModel::onSearchQueryChange, + onClearSelection = viewModel::clearSelection, + onSelectAll = viewModel::selectAll, + onSelectInvert = viewModel::selectInvert, + onDeleteSelected = { viewModel.deleteSelected() }, + snackbarHostState = snackbarHostState, + selectionSecondaryActions = emptyList(), + topBarActions = {}, + dropDownMenuContent = { dismiss -> + RoundDropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Group, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(12.dp)) + Text(stringResource(R.string.all)) + } + }, + onClick = { + viewModel.onGroupChange("") + dismiss() + } + ) + groups.forEach { group -> + RoundDropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.AutoMirrored.Outlined.Label, + null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(12.dp)) + Text(group) + } + }, + onClick = { + viewModel.onGroupChange(group) + dismiss() + } + ) + } + PillDivider() + RoundDropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.DeleteSweep, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(12.dp)) + Text(stringResource(R.string.delete_select_group)) + } + }, + onClick = { + viewModel.deleteGroup(state.currentGroup) + dismiss() + } + ) + RoundDropdownMenuItem( + text = { + 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)) + } + }, + onClick = { + viewModel.deleteAll() + 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()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + EmptyMessageView( + message = "还没有收藏订阅!", + isLoading = state.isLoading + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { "${it.origin}|${it.link}" }) { rssStar -> + val id = "${rssStar.origin}|${rssStar.link}" + val isSelected = state.selectedIds.contains(id) + SelectionItemCard( + title = rssStar.title, + subtitle = rssStar.pubDate, + isSelected = isSelected, + inSelectionMode = state.selectedIds.isNotEmpty(), + onToggleSelection = { + if (state.selectedIds.isNotEmpty()) { + viewModel.toggleSelection(rssStar) + } else { + 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 + ) + } + }, + dropdownContent = { dismiss -> + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + onClick = { + viewModel.deleteStar(rssStar) + dismiss() + } + ) + } + ) + } + } + } + } +} 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 bc028698a..a2b174a5f 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 @@ -1,9 +1,137 @@ package io.legado.app.ui.rss.favorites 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.RssStar +import io.legado.app.ui.widget.components.rules.ListUiState +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class RssFavoritesViewModel(application: Application) : BaseViewModel(application) { + private val _searchKey = MutableStateFlow("") + private val _isSearch = MutableStateFlow(false) + private val _selectedIds = MutableStateFlow>(emptySet()) + private val _currentGroup = MutableStateFlow("") + + val groups = appDb.rssStarDao.flowGroups() + .flowOn(IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = combine( + _currentGroup, + _searchKey, + _isSearch, + _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) + } + } + ) { group, searchKey, isSearch, selectedIds, items -> + val filteredItems = if (isSearch && searchKey.isNotBlank()) { + items.filter { it.title.contains(searchKey, ignoreCase = true) } + } else { + items + } + RssFavoritesUiState( + items = filteredItems, + selectedIds = selectedIds, + searchKey = searchKey, + isSearch = isSearch, + currentGroup = group + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RssFavoritesUiState()) + + fun onSearchToggle(isSearch: Boolean) { + _isSearch.value = isSearch + if (!isSearch) _searchKey.value = "" + } + + fun onSearchQueryChange(query: String) { + _searchKey.value = query + } + + fun onGroupChange(group: String) { + _currentGroup.value = group + _selectedIds.value = emptySet() + } + + fun toggleSelection(rssStar: RssStar) { + val id = "${rssStar.origin}|${rssStar.link}" + _selectedIds.update { + if (it.contains(id)) it - id else it + id + } + } + + fun selectAll() { + _selectedIds.value = state.value.items.map { "${it.origin}|${it.link}" }.toSet() + } + + fun selectInvert() { + val allIds = state.value.items.map { "${it.origin}|${it.link}" }.toSet() + _selectedIds.update { current -> + allIds - current + } + } + + fun clearSelection() { + _selectedIds.value = emptySet() + } + + fun deleteSelected() { + val selected = _selectedIds.value + viewModelScope.launch(IO) { + state.value.items.forEach { + val id = "${it.origin}|${it.link}" + if (selected.contains(id)) { + appDb.rssStarDao.delete(it.origin, it.link) + } + } + clearSelection() + } + } + + fun deleteGroup(group: String) { + viewModelScope.launch(IO) { + appDb.rssStarDao.deleteByGroup(group) + } + } + + fun deleteAll() { + viewModelScope.launch(IO) { + appDb.rssStarDao.deleteAll() + } + } + + fun deleteStar(rssStar: RssStar) { + viewModelScope.launch(IO) { + appDb.rssStarDao.delete(rssStar.origin, rssStar.link) + } + } + + data class RssFavoritesUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + override val isSearch: Boolean = false, + override val isLoading: Boolean = false, + val currentGroup: String = "" + ) : ListUiState } diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt deleted file mode 100644 index 7d635acaf..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt +++ /dev/null @@ -1,146 +0,0 @@ -package io.legado.app.ui.rss.source.manage - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.appDb -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogRecyclerViewBinding -import io.legado.app.databinding.ItemGroupManageBinding -import io.legado.app.lib.dialogs.alert -//import io.legado.app.lib.theme.accentColor -//import io.legado.app.lib.theme.backgroundColor -//import io.legado.app.lib.theme.primaryColor -import io.legado.app.ui.widget.recycler.VerticalDivider -import io.legado.app.utils.requestInputMethod -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.launch - - -class GroupManageDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view), - Toolbar.OnMenuItemClickListener { - - private val viewModel: RssSourceViewModel by activityViewModels() - private val binding by viewBinding(DialogRecyclerViewBinding::bind) - private val adapter by lazy { GroupAdapter(requireContext()) } - - override fun onStart() { - super.onStart() - - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { - //toolBar.setBackgroundColor(primaryColor) - toolBar.title = getString(R.string.group_manage) - toolBar.inflateMenu(R.menu.group_manage) - //toolBar.menu.applyTint(requireContext()) - toolBar.setOnMenuItemClickListener(this@GroupManageDialog) - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.addItemDecoration(VerticalDivider(requireContext())) - recyclerView.adapter = adapter - //tvOk.setTextColor(requireContext().accentColor) - tvOk.visible() - tvOk.setOnClickListener { - dismissAllowingStateLoss() - } - initData() - } - - private fun initData() { - lifecycleScope.launch { - appDb.rssSourceDao.flowGroups().conflate().collect { - adapter.setItems(it) - } - } - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_add -> addGroup() - } - return true - } - - @SuppressLint("InflateParams") - private fun addGroup() { - alert(title = getString(R.string.add_group)) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.setHint(R.string.group_name) - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { - if (it.isNotBlank()) { - viewModel.addGroup(it) - } - } - } - cancelButton() - }.requestInputMethod() - } - - @SuppressLint("InflateParams") - private fun editGroup(group: String) { - alert(title = getString(R.string.group_edit)) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.setHint(R.string.group_name) - editView.setText(group) - } - customView { alertBinding.root } - okButton { - viewModel.upGroup(group, alertBinding.editView.text?.toString()) - } - cancelButton() - }.requestInputMethod() - } - - private inner class GroupAdapter(context: Context) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding { - return ItemGroupManageBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemGroupManageBinding, - item: String, - payloads: MutableList - ) { - binding.run { - //root.setBackgroundColor(context.backgroundColor) - tvGroup.text = item - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) { - binding.apply { - tvEdit.setOnClickListener { - getItem(holder.layoutPosition)?.let { - editGroup(it) - } - } - - tvDel.setOnClickListener { - getItem(holder.layoutPosition)?.let { - viewModel.delGroup(it) - } - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt index 3460fb96a..a35dc5833 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt @@ -1,424 +1,28 @@ package io.legado.app.ui.rss.source.manage -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.MotionEvent -import android.view.SubMenu -import androidx.activity.viewModels -import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.data.entities.RssSource -import io.legado.app.databinding.ActivityRssSourceBinding -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.help.DirectLinkUpload -import io.legado.app.lib.dialogs.alert -import io.legado.app.ui.association.ImportRssSourceDialog -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.ui.qrcode.QrCodeResult +import androidx.compose.runtime.Composable +import io.legado.app.base.BaseComposeActivity import io.legado.app.ui.rss.source.edit.RssSourceEditActivity -import io.legado.app.ui.widget.SelectActionBar -import io.legado.app.ui.widget.recycler.DragSelectTouchHelper -import io.legado.app.ui.widget.recycler.ItemTouchCallback -import io.legado.app.ui.widget.recycler.VerticalDivider -import io.legado.app.utils.ACache -import io.legado.app.utils.dpToPx -import io.legado.app.utils.hideSoftInput -import io.legado.app.utils.isAbsUrl -import io.legado.app.utils.launch -import io.legado.app.utils.readText -import io.legado.app.utils.sendToClip -import io.legado.app.utils.share -import io.legado.app.utils.shouldHideSoftInput -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.showHelp -import io.legado.app.utils.splitNotBlank +import io.legado.app.ui.theme.AppTheme import io.legado.app.utils.startActivity -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.transaction -import io.legado.app.utils.viewbindingdelegate.viewBinding -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -/** - * 订阅源管理 - */ -class RssSourceActivity : VMBaseActivity(), - PopupMenu.OnMenuItemClickListener, - SelectActionBar.CallBack, - RssSourceAdapter.CallBack { +class RssSourceActivity : BaseComposeActivity() { - override val binding by viewBinding(ActivityRssSourceBinding::inflate) - override val viewModel by viewModels() - private val importRecordKey = "rssSourceRecordKey" - private val adapter by lazy { RssSourceAdapter(this, this) } - private val searchView: SearchView by lazy { - binding.titleBar.findViewById(R.id.search_view) - } - private var sourceFlowJob: Job? = null - private var groups = arrayListOf() - private var groupMenu: SubMenu? = null - private val qrCodeResult = registerForActivityResult(QrCodeResult()) { - it ?: return@registerForActivityResult - showDialogFragment( - ImportRssSourceDialog(it) - ) - } - private val importDoc = registerForActivityResult(HandleFileContract()) { - kotlin.runCatching { - it.uri?.readText(this)?.let { - showDialogFragment( - ImportRssSourceDialog(it) - ) - } - }.onFailure { - toastOnUi("readTextError:${it.localizedMessage}") - } - } - private val exportResult = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - alert(R.string.export_success) { - if (uri.toString().isAbsUrl()) { - setMessage(DirectLinkUpload.getSummary()) - } - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = getString(R.string.path) - editView.setText(uri.toString()) - } - customView { alertBinding.root } - okButton { - sendToClip(uri.toString()) - } - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - initRecyclerView() - initSearchView() - initGroupFlow() - upSourceFlow() - initSelectActionBar() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.rss_source, menu) - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - groupMenu = menu.findItem(R.id.menu_group)?.subMenu - upGroupMenu() - return super.onPrepareOptionsMenu(menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_add -> startActivity() - R.id.menu_import_local -> importDoc.launch { - mode = HandleFileContract.FILE - allowExtensions = arrayOf("txt", "json") - } - - R.id.menu_import_onLine -> showImportDialog() - R.id.menu_import_qr -> qrCodeResult.launch() - R.id.menu_group_manage -> showDialogFragment() - R.id.menu_import_default -> viewModel.importDefault() - R.id.menu_enabled_group -> { - searchView.setQuery(getString(R.string.enabled), true) - } - - R.id.menu_disabled_group -> { - searchView.setQuery(getString(R.string.disabled), true) - } - - R.id.menu_group_login -> { - searchView.setQuery(getString(R.string.need_login), true) - } - - R.id.menu_group_null -> { - searchView.setQuery(getString(R.string.no_group), true) - } - - R.id.menu_help -> showHelp("SourceMRssHelp") - else -> if (item.groupId == R.id.source_group) { - searchView.setQuery("group:${item.title}", true) - } - } - return super.onCompatOptionsItemSelected(item) - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection) - R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection) - R.id.menu_add_group -> selectionAddToGroups() - R.id.menu_remove_group -> selectionRemoveFromGroups() - R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray()) - R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray()) - R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file -> - exportResult.launch { - mode = HandleFileContract.EXPORT - fileData = HandleFileContract.FileData( - "exportRssSource.json", file, "application/json" - ) - } - } - - R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) { - share(it) - } - - R.id.menu_check_selected_interval -> adapter.checkSelectedInterval() - } - return true - } - - private fun initRecyclerView() { - //binding.recyclerView.setEdgeEffectColor(primaryColor) - binding.recyclerView.addItemDecoration(VerticalDivider(this)) - binding.recyclerView.adapter = adapter - // When this page is opened, it is in selection mode - val dragSelectTouchHelper: DragSelectTouchHelper = - DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50) - dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView) - dragSelectTouchHelper.activeSlideSelect() - // Note: need judge selection first, so add ItemTouchHelper after it. - val itemTouchCallback = ItemTouchCallback(adapter) - itemTouchCallback.isCanDrag = true - ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) - } - - private fun initSearchView() { - binding.titleBar.findViewById(R.id.search_view).let { - //it.applyTint(primaryTextColor) - it.onActionViewExpanded() - it.queryHint = getString(R.string.search_rss_source) - it.clearFocus() - it.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - return false - } - - override fun onQueryTextChange(newText: String?): Boolean { - upSourceFlow(newText) - return false - } - }) - } - } - - private fun initSelectActionBar() { - binding.selectActionBar.setMainActionText(R.string.delete) - binding.selectActionBar.inflateMenu(R.menu.rss_source_sel) - binding.selectActionBar.setOnMenuItemClickListener(this) - binding.selectActionBar.setCallBack(this) - } - - private fun initGroupFlow() { - lifecycleScope.launch { - appDb.rssSourceDao.flowGroups().conflate().collect { - groups.clear() - groups.addAll(it) - upGroupMenu() - } - } - } - - @SuppressLint("InflateParams") - private fun selectionAddToGroups() { - alert(titleResource = R.string.add_group) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.setHint(R.string.group_name) - editView.setFilterValues(groups.toList()) - editView.dropDownHeight = 180.dpToPx() - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { - if (it.isNotEmpty()) { - viewModel.selectionAddToGroups(adapter.selection, it) + @Composable + override fun Content() { + AppTheme { + RssSourceScreen( + onBackClick = { finish() }, + onEditSource = { source -> + startActivity { + putExtra("sourceUrl", source.sourceUrl) } + }, + onAddSource = { + startActivity() } - } - cancelButton() + ) } } - @SuppressLint("InflateParams") - private fun selectionRemoveFromGroups() { - alert(titleResource = R.string.remove_group) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.setHint(R.string.group_name) - editView.setFilterValues(groups.toList()) - editView.dropDownHeight = 180.dpToPx() - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { - if (it.isNotEmpty()) { - viewModel.selectionRemoveFromGroups(adapter.selection, it) - } - } - } - cancelButton() - } - } - - override fun selectAll(selectAll: Boolean) { - if (selectAll) { - adapter.selectAll() - } else { - adapter.revertSelection() - } - } - - override fun revertSelection() { - adapter.revertSelection() - } - - override fun onClickSelectBarMainAction() { - delSourceDialog() - } - - private fun delSourceDialog() { - alert(titleResource = R.string.draw, messageResource = R.string.sure_del) { - yesButton { viewModel.del(*adapter.selection.toTypedArray()) } - noButton() - } - } - - private fun upGroupMenu() = groupMenu?.transaction { menu -> - menu.removeGroup(R.id.source_group) - groups.forEach { - menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it) - } - } - - private fun upSourceFlow(searchKey: String? = null) { - sourceFlowJob?.cancel() - sourceFlowJob = lifecycleScope.launch { - when { - searchKey.isNullOrBlank() -> { - appDb.rssSourceDao.flowAll() - } - - searchKey == getString(R.string.enabled) -> { - appDb.rssSourceDao.flowEnabled() - } - - searchKey == getString(R.string.disabled) -> { - appDb.rssSourceDao.flowDisabled() - } - - searchKey == getString(R.string.need_login) -> { - appDb.rssSourceDao.flowLogin() - } - - searchKey == getString(R.string.no_group) -> { - appDb.rssSourceDao.flowNoGroup() - } - - searchKey.startsWith("group:") -> { - val key = searchKey.substringAfter("group:") - appDb.rssSourceDao.flowGroupSearch(key) - } - - else -> { - appDb.rssSourceDao.flowSearch(searchKey) - } - }.catch { - AppLog.put("订阅源管理界面更新数据出错", it) - }.flowOn(IO).conflate().collect { - adapter.setItems(it, adapter.diffItemCallback) - delay(100) - } - } - } - - override fun upCountView() { - binding.selectActionBar.upCountView( - adapter.selection.size, - adapter.itemCount - ) - } - - @SuppressLint("InflateParams") - private fun showImportDialog() { - val aCache = ACache.get(cacheDir = false) - val cacheUrls: MutableList = aCache - .getAsString(importRecordKey) - ?.splitNotBlank(",") - ?.toMutableList() ?: mutableListOf() - alert(titleResource = R.string.import_on_line) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "url" - editView.setFilterValues(cacheUrls) - editView.delCallBack = { - cacheUrls.remove(it) - aCache.put(importRecordKey, cacheUrls.joinToString(",")) - } - } - customView { alertBinding.root } - okButton { - val text = alertBinding.editView.text?.toString() - text?.let { - if (it.isAbsUrl() && !cacheUrls.contains(it)) { - cacheUrls.add(0, it) - aCache.put(importRecordKey, cacheUrls.joinToString(",")) - } - showDialogFragment( - ImportRssSourceDialog(it) - ) - } - } - cancelButton() - } - } - - override fun del(source: RssSource) { - alert(R.string.draw) { - setMessage(getString(R.string.sure_del) + "\n" + source.sourceName) - noButton() - yesButton { - viewModel.del(source) - } - } - } - - override fun edit(source: RssSource) { - startActivity { - putExtra("sourceUrl", source.sourceUrl) - } - } - - override fun update(vararg source: RssSource) { - viewModel.update(*source) - } - - override fun toTop(source: RssSource) { - viewModel.topSource(source) - } - - override fun toBottom(source: RssSource) { - viewModel.bottomSource(source) - } - - override fun upOrder() { - viewModel.upOrder() - } - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt deleted file mode 100644 index ef7dd7fe8..000000000 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt +++ /dev/null @@ -1,254 +0,0 @@ -package io.legado.app.ui.rss.source.manage - -import android.content.Context -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.core.os.bundleOf -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import io.legado.app.R -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.RssSource -import io.legado.app.databinding.ItemRssSourceBinding -//import io.legado.app.lib.theme.backgroundColor -import io.legado.app.ui.widget.recycler.DragSelectTouchHelper -import io.legado.app.ui.widget.recycler.ItemTouchCallback -import java.util.Collections - - -class RssSourceAdapter(context: Context, val callBack: CallBack) : - RecyclerAdapter(context), - ItemTouchCallback.Callback { - - private val selected = linkedSetOf() - - val selection: List - get() { - return getItems().filter { - selected.contains(it) - } - } - - val diffItemCallback = object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { - return oldItem.sourceUrl == newItem.sourceUrl - } - - override fun areContentsTheSame(oldItem: RssSource, newItem: RssSource): Boolean { - return oldItem.sourceName == newItem.sourceName - && oldItem.sourceGroup == newItem.sourceGroup - && oldItem.enabled == newItem.enabled - } - - override fun getChangePayload(oldItem: RssSource, newItem: RssSource): Any? { - val payload = Bundle() - if (oldItem.sourceName != newItem.sourceName - || oldItem.sourceGroup != newItem.sourceGroup - ) { - payload.putBoolean("upName", true) - } - if (oldItem.enabled != newItem.enabled) { - payload.putBoolean("enabled", newItem.enabled) - } - if (payload.isEmpty) { - return null - } - return payload - } - } - - override fun getViewBinding(parent: ViewGroup): ItemRssSourceBinding { - return ItemRssSourceBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemRssSourceBinding, - item: RssSource, - payloads: MutableList - ) { - binding.run { - if (payloads.isEmpty()) { - //root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) - cbSource.text = item.getDisplayNameGroup() - swtEnabled.isChecked = item.enabled - cbSource.isChecked = selected.contains(item) - } else { - for (i in payloads.indices) { - val bundle = payloads[i] as Bundle - bundle.keySet().forEach { - when (it) { - "upName" -> cbSource.text = item.getDisplayNameGroup() - "enabled" -> swtEnabled.isChecked = bundle.getBoolean("enabled") - "selected" -> cbSource.isChecked = selected.contains(item) - } - } - } - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemRssSourceBinding) { - binding.apply { - swtEnabled.setOnCheckedChangeListener { view, checked -> - if (view.isPressed) { - getItem(holder.layoutPosition)?.let { - if (view.isPressed) { - it.enabled = checked - callBack.update(it) - } - } - } - } - cbSource.setOnCheckedChangeListener { view, checked -> - if (view.isPressed) { - getItem(holder.layoutPosition)?.let { - if (view.isPressed) { - if (checked) { - selected.add(it) - } else { - selected.remove(it) - } - callBack.upCountView() - } - } - } - } - ivEdit.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.edit(it) - } - } - ivMenuMore.setOnClickListener { - showMenu(ivMenuMore, holder.layoutPosition) - } - } - } - - override fun onCurrentListChanged() { - callBack.upCountView() - } - - fun selectAll() { - getItems().forEach { - selected.add(it) - } - notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) - callBack.upCountView() - } - - fun revertSelection() { - getItems().forEach { - if (selected.contains(it)) { - selected.remove(it) - } else { - selected.add(it) - } - } - notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null))) - callBack.upCountView() - } - - fun checkSelectedInterval() { - val selectedPosition = linkedSetOf() - getItems().forEachIndexed { index, it -> - if (selected.contains(it)) { - selectedPosition.add(index) - } - } - val minPosition = Collections.min(selectedPosition) - val maxPosition = Collections.max(selectedPosition) - val itemCount = maxPosition - minPosition + 1 - for (i in minPosition..maxPosition) { - getItem(i)?.let { - selected.add(it) - } - } - notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null))) - callBack.upCountView() - } - - private fun showMenu(view: View, position: Int) { - val source = getItem(position) ?: return - val popupMenu = PopupMenu(context, view) - popupMenu.inflate(R.menu.rss_source_item) - popupMenu.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.menu_top -> callBack.toTop(source) - R.id.menu_bottom -> callBack.toBottom(source) - R.id.menu_del -> { - callBack.del(source) - selected.remove(source) - } - } - true - } - popupMenu.show() - } - - override fun swap(srcPosition: Int, targetPosition: Int): Boolean { - val srcItem = getItem(srcPosition) - val targetItem = getItem(targetPosition) - if (srcItem != null && targetItem != null) { - if (srcItem.customOrder == targetItem.customOrder) { - callBack.upOrder() - } else { - val srcOrder = srcItem.customOrder - srcItem.customOrder = targetItem.customOrder - targetItem.customOrder = srcOrder - movedItems.add(srcItem) - movedItems.add(targetItem) - } - } - swapItem(srcPosition, targetPosition) - return true - } - - private val movedItems = hashSetOf() - - override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - if (movedItems.isNotEmpty()) { - callBack.update(*movedItems.toTypedArray()) - movedItems.clear() - } - } - - val dragSelectCallback: DragSelectTouchHelper.Callback = - object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { - override fun currentSelectedId(): MutableSet { - return selected - } - - override fun getItemId(position: Int): RssSource { - return getItem(position)!! - } - - override fun updateSelectState(position: Int, isSelected: Boolean): Boolean { - getItem(position)?.let { - if (isSelected) { - selected.add(it) - } else { - selected.remove(it) - } - notifyItemChanged(position, bundleOf(Pair("selected", null))) - callBack.upCountView() - return true - } - return false - } - } - - interface CallBack { - fun del(source: RssSource) - fun edit(source: RssSource) - fun update(vararg source: RssSource) - fun toTop(source: RssSource) - fun toBottom(source: RssSource) - fun upOrder() - fun upCountView() - } -} 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 new file mode 100644 index 000000000..b6680390d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt @@ -0,0 +1,403 @@ +package io.legado.app.ui.rss.source.manage + +import android.content.ClipData +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.base.BaseRuleEvent +import io.legado.app.data.entities.RssSource +import io.legado.app.ui.widget.components.ActionItem +import io.legado.app.ui.widget.components.DraggableSelectionHandler +import io.legado.app.ui.widget.components.GroupManageBottomSheet +import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.card.ReorderableSelectionItem +import io.legado.app.ui.widget.components.dialog.TextListInputDialog +import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.importComponents.BatchImportDialog +import io.legado.app.ui.widget.components.importComponents.SourceInputDialog +import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn +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.rules.RuleListScaffold +import org.koin.androidx.compose.koinViewModel +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RssSourceScreen( + viewModel: RssSourceViewModel = koinViewModel(), + onBackClick: () -> Unit, + onEditSource: (RssSource) -> Unit, + onAddSource: () -> Unit +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + val groups by viewModel.groupsFlow.collectAsStateWithLifecycle() + + val rules = uiState.items + val selectedIds = uiState.selectedIds + val inSelectionMode = selectedIds.isNotEmpty() + + val listState = rememberLazyListState() + val hapticFeedback = LocalHapticFeedback.current + + var showDeleteRuleDialog by remember { mutableStateOf(null) } + var showUrlInput by remember { mutableStateOf(false) } + var showFilePickerSheet by remember { mutableStateOf(false) } + var showAddToGroupDialog by remember { mutableStateOf(false) } + var showRemoveFromGroupDialog by remember { mutableStateOf(false) } + var showGroupManageSheet by remember { mutableStateOf(false) } + + var showImportMenu by remember { mutableStateOf(false) } + + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + viewModel.moveItemInList(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + + val clipboardManager = LocalClipboard.current + val snackbarHostState = remember { SnackbarHostState() } + val importState by viewModel.importState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is BaseRuleEvent.ShowSnackbar -> { + val result = snackbarHostState.showSnackbar( + message = event.message, + actionLabel = event.actionLabel, + withDismissAction = true + ) + if (result == SnackbarResult.ActionPerformed && event.url != null) { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText("url", event.url)) + ) + } + } + } + } + } + + val importDoc = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { + context.contentResolver.openInputStream(it)?.use { stream -> + val text = stream.reader().readText() + viewModel.importSource(text) + } + } + } + ) + + val exportDoc = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + onResult = { uri -> + uri?.let { viewModel.exportToUri(it, rules, selectedIds) } + } + ) + + if (showUrlInput) { + SourceInputDialog( + title = stringResource(R.string.import_on_line), + onDismissRequest = { showUrlInput = false }, + onConfirm = { + showUrlInput = false + viewModel.importSource(it) + } + ) + } + + if (showAddToGroupDialog) { + TextListInputDialog( + title = stringResource(R.string.add_group), + hint = stringResource(R.string.group_name), + suggestions = groups, + onDismissRequest = { showAddToGroupDialog = false }, + onConfirm = { + viewModel.selectionAddToGroups(selectedIds, it) + showAddToGroupDialog = false + viewModel.setSelection(emptySet()) + } + ) + } + + if (showRemoveFromGroupDialog) { + TextListInputDialog( + title = stringResource(R.string.remove_group), + hint = stringResource(R.string.group_name), + suggestions = groups, + onDismissRequest = { showRemoveFromGroupDialog = false }, + onConfirm = { + viewModel.selectionRemoveFromGroups(selectedIds, it) + showRemoveFromGroupDialog = false + viewModel.setSelection(emptySet()) + } + ) + } + + if (showGroupManageSheet) { + GroupManageBottomSheet( + groups = groups, + onDismissRequest = { showGroupManageSheet = false }, + onUpdateGroup = { old, new -> viewModel.upGroup(old, new) }, + onDeleteGroup = { viewModel.delGroup(it) } + ) + } + + if (showFilePickerSheet) { + FilePickerSheet( + onDismissRequest = { showFilePickerSheet = false }, + onSelectSysDir = { + showFilePickerSheet = false + exportDoc.launch("exportRssSource.json") + }, + onUpload = { + showFilePickerSheet = false + viewModel.uploadSelectedRules(selectedIds, rules) + }, + allowExtensions = arrayOf("json") + ) + } + + (importState as? BaseImportUiState.Success)?.let { state -> + BatchImportDialog( + title = stringResource(R.string.import_rss_source), + importState = state, + onDismissRequest = { viewModel.cancelImport() }, + onToggleItem = { viewModel.toggleImportSelection(it) }, + onToggleAll = { viewModel.toggleImportAll(it) }, + onConfirm = { viewModel.saveImportedRules() }, + itemContent = { source, _ -> + Column { + Text(source.sourceName, style = MaterialTheme.typography.titleMedium) + Text(source.sourceUrl, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + ) + } + + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + viewModel.saveSortOrder() + } + } + + showDeleteRuleDialog?.let { source -> + AlertDialog( + onDismissRequest = { showDeleteRuleDialog = null }, + title = { Text(stringResource(R.string.delete)) }, + text = { Text(stringResource(R.string.del_msg)) }, + confirmButton = { + OutlinedButton(onClick = { + viewModel.del(source); showDeleteRuleDialog = null + }) { Text(stringResource(R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = { showDeleteRuleDialog = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + RuleListScaffold( + title = stringResource(R.string.rss_source), + subtitle = uiState.groupFilterName, + state = uiState, + onBackClick = { onBackClick() }, + onSearchToggle = { active -> viewModel.setSearchMode(active) }, + onSearchQueryChange = { viewModel.setSearchKey(it) }, + searchPlaceholder = stringResource(R.string.search_rss_source), + onClearSelection = { viewModel.setSelection(emptySet()) }, + onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) }, + onSelectInvert = { + val allIds = rules.map { it.id }.toSet() + viewModel.setSelection(allIds - selectedIds) + }, + topBarActions = {}, + selectionSecondaryActions = listOf( + ActionItem(text = stringResource(R.string.enable), onClick = { + viewModel.enableSelectionByIds(selectedIds) + viewModel.setSelection(emptySet()) + }), + ActionItem(text = stringResource(R.string.disable_selection), onClick = { + viewModel.disableSelectionByIds(selectedIds) + viewModel.setSelection(emptySet()) + }), + ActionItem( + text = stringResource(R.string.add_group), + onClick = { showAddToGroupDialog = true }), + ActionItem( + text = stringResource(R.string.remove_group), + onClick = { showRemoveFromGroupDialog = true }), + ActionItem( + text = stringResource(R.string.export), + onClick = { showFilePickerSheet = true }), + ActionItem(text = stringResource(R.string.check_selected_interval), onClick = { + viewModel.checkSelectedInterval(selectedIds, rules) + }) + ), + onDeleteSelected = { ids -> + @Suppress("UNCHECKED_CAST") + viewModel.delSelectionByIds(ids as Set) + viewModel.setSelection(emptySet()) + }, + onAddClick = { onAddSource() }, + snackbarHostState = snackbarHostState, + dropDownMenuContent = { dismiss -> + RoundDropdownMenuItem( + onClick = { showGroupManageSheet = true }, + text = { Text("分组管理") }, + ) + Box { + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.import_rss_source)) }, + onClick = { showImportMenu = true } + ) + RoundDropdownMenu( + expanded = showImportMenu, + onDismissRequest = { showImportMenu = false } + ) { + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.import_on_line)) }, + onClick = { + showImportMenu = false + dismiss() + showUrlInput = true + } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.import_local)) }, + onClick = { + showImportMenu = false + dismiss() + importDoc.launch(arrayOf("text/plain", "application/json")) + } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.import_default_rule)) }, + onClick = { + showImportMenu = false + dismiss() + viewModel.importDefault() + } + ) + } + } + PillDivider() + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.all)) }, + onClick = { dismiss(); viewModel.setGroupFilter(null) } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.enabled)) }, + onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_ENABLED) } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.disabled)) }, + onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_DISABLED) } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.need_login)) }, + onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_LOGIN) } + ) + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.no_group)) }, + onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_NO_GROUP) } + ) + PillDivider() + groups.forEach { group -> + RoundDropdownMenuItem( + text = { Text(group) }, + onClick = { dismiss(); viewModel.setGroupFilter("${RssSourceViewModel.PREFIX_GROUP}$group") } + ) + } + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + FastScrollLazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 8.dp, + bottom = paddingValues.calculateBottomPadding() + 80.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(rules, key = { it.id }) { item -> + ReorderableSelectionItem( + state = reorderableState, + key = item.id, + title = item.name, + subtitle = item.group, + isEnabled = item.isEnabled, + isSelected = selectedIds.contains(item.id), + inSelectionMode = inSelectionMode, + onToggleSelection = { viewModel.toggleSelection(item.id) }, + onEnabledChange = { enabled -> viewModel.update(item.source.copy(enabled = enabled)) }, + onClickEdit = { onEditSource(item.source) }, + trailingAction = { + SmallIconButton( + onClick = { showDeleteRuleDialog = item.source }, + icon = Icons.Default.Delete + ) + } + ) + } + } + if (inSelectionMode) { + DraggableSelectionHandler( + listState = listState, + items = rules, + selectedIds = selectedIds, + onSelectionChange = { viewModel.setSelection(it) }, + idProvider = { it.id }, + modifier = Modifier + .fillMaxHeight() + .width(60.dp) + .align(Alignment.TopStart) + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt index 3158e4535..e69b9292c 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt @@ -1,82 +1,280 @@ package io.legado.app.ui.rss.source.manage import android.app.Application -import android.text.TextUtils -import io.legado.app.base.BaseViewModel +import androidx.compose.runtime.Immutable +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.base.BaseRuleViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.RssSource +import io.legado.app.data.repository.UploadRepository import io.legado.app.help.DefaultData import io.legado.app.help.source.SourceHelp +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.rules.InteractionState +import io.legado.app.ui.widget.components.rules.ListUiState +import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON -import io.legado.app.utils.splitNotBlank +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.isJsonArray +import io.legado.app.utils.isJsonObject import io.legado.app.utils.stackTraceStr import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File -/** - * 订阅源管理数据修改 - * 修改数据要copy,直接修改会导致界面不刷新 - */ -class RssSourceViewModel(application: Application) : BaseViewModel(application) { +@Immutable +data class RssSourceItemUi( + override val id: String, + val name: String, + val group: String?, + val isEnabled: Boolean, + val source: RssSource +) : SelectableItem + +data class RssSourceUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + val groupFilterName: String? = null, + val interaction: InteractionState = InteractionState() +) : ListUiState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isLoading: Boolean get() = interaction.isUploading +} + +class RssSourceViewModel( + application: Application, + uploadRepository: UploadRepository +) : BaseRuleViewModel( + application, + RssSourceUiState(interaction = InteractionState(isLoading = true)), + uploadRepository +) { + companion object { + const val FILTER_ENABLED = "@enabled" + const val FILTER_DISABLED = "@disabled" + const val FILTER_LOGIN = "@login" + const val FILTER_NO_GROUP = "@noGroup" + const val PREFIX_GROUP = "group:" + } + + private val dao = appDb.rssSourceDao + + override val rawDataFlow: Flow> = dao.flowAll() + + val groupsFlow: StateFlow> = dao.flowGroups() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val _groupFilterName = MutableStateFlow(null) + val groupFilterName = _groupFilterName.asStateFlow() + + override val uiState: StateFlow by lazy { + combine( + super.uiState, + _groupFilterName + ) { baseState, filterName -> + baseState.copy(groupFilterName = filterName) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = initialState + ) + } + + override fun setGroupFilter(filter: String?) { + super.setGroupFilter(filter) + _groupFilterName.value = when { + filter == null -> null + filter == FILTER_ENABLED -> context.getString(R.string.enabled) + filter == FILTER_DISABLED -> context.getString(R.string.disabled) + filter == FILTER_LOGIN -> context.getString(R.string.need_login) + filter == FILTER_NO_GROUP -> context.getString(R.string.no_group) + filter.startsWith(PREFIX_GROUP) -> filter.substringAfter(PREFIX_GROUP) + else -> filter + } + } + + override fun filterData( + data: List, + searchKey: String, + groupFilter: String + ): List { + var filtered = data + + if (groupFilter.isNotEmpty()) { + filtered = when { + groupFilter == FILTER_ENABLED -> filtered.filter { it.enabled } + groupFilter == FILTER_DISABLED -> filtered.filter { !it.enabled } + groupFilter == FILTER_LOGIN -> filtered.filter { !it.loginUrl.isNullOrEmpty() } + groupFilter == FILTER_NO_GROUP -> filtered.filter { + it.sourceGroup.isNullOrEmpty() || it.sourceGroup?.contains( + "未分组" + ) == true + } + + groupFilter.startsWith(PREFIX_GROUP) -> { + val groupName = groupFilter.substringAfter(PREFIX_GROUP) + filtered.filter { it.sourceGroup?.split(",")?.contains(groupName) == true } + } + + else -> filtered + } + } + + if (searchKey.isNotEmpty()) { + filtered = filtered.filter { + it.sourceName.contains(searchKey, ignoreCase = true) || + it.sourceUrl.contains(searchKey, ignoreCase = true) || + it.sourceGroup?.contains(searchKey, ignoreCase = true) == true || + it.sourceComment?.contains(searchKey, ignoreCase = true) == true + } + } + + return filtered.sortedBy { it.customOrder } + } + + override fun composeUiState( + items: List, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): RssSourceUiState { + return RssSourceUiState( + items = items, + selectedIds = selectedIds, + searchKey = _searchKey.value, + interaction = InteractionState( + isSearchMode = isSearch, + isUploading = isUploading || (importState is BaseImportUiState.Loading), + isLoading = false + ) + ) + } + + override fun RssSource.toUiItem() = + RssSourceItemUi(sourceUrl, sourceName, sourceGroup, enabled, this) + + override fun ruleItemToEntity(item: RssSourceItemUi): RssSource = item.source + + override suspend fun generateJson(entities: List): String = GSON.toJson(entities) + + override fun parseImportRules(text: String): List { + return when { + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonObject() -> listOf(GSON.fromJsonObject(text).getOrThrow()) + else -> throw Exception("格式不正确") + } + } + + override fun hasChanged(newRule: RssSource, oldRule: RssSource): Boolean { + return !newRule.equal(oldRule) + } + + override suspend fun findOldRule(newRule: RssSource): RssSource? { + return withContext(Dispatchers.IO) { dao.getByKey(newRule.sourceUrl) } + } + + override fun saveImportedRules() { + val state = _importState.value as? BaseImportUiState.Success ?: return + viewModelScope.launch(Dispatchers.IO) { + val rulesToSave = state.items + .filter { it.isSelected } + .map { it.data } + dao.insert(*rulesToSave.toTypedArray()) + withContext(Dispatchers.Main) { + _importState.value = BaseImportUiState.Idle + } + } + } + + fun saveSortOrder() { + val currentLocal = _localItems.value ?: return + viewModelScope.launch(Dispatchers.IO) { + val sources = currentLocal.mapIndexed { index, item -> + item.source.copy(customOrder = index + 1) + } + dao.update(*sources.toTypedArray()) + withContext(Dispatchers.Main) { + _localItems.value = null + } + } + } fun topSource(vararg sources: RssSource) { - execute { - sources.sortBy { it.customOrder } - val minOrder = appDb.rssSourceDao.minOrder - 1 - val array = Array(sources.size) { - sources[it].copy(customOrder = minOrder - it) + viewModelScope.launch(Dispatchers.IO) { + val minOrder = dao.minOrder - 1 + val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source -> + source.copy(customOrder = minOrder - index) } - appDb.rssSourceDao.update(*array) + dao.update(*updated.toTypedArray()) } } fun bottomSource(vararg sources: RssSource) { - execute { - sources.sortBy { it.customOrder } - val maxOrder = appDb.rssSourceDao.maxOrder + 1 - val array = Array(sources.size) { - sources[it].copy(customOrder = maxOrder + it) + viewModelScope.launch(Dispatchers.IO) { + val maxOrder = dao.maxOrder + 1 + val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source -> + source.copy(customOrder = maxOrder + index) } - appDb.rssSourceDao.update(*array) + dao.update(*updated.toTypedArray()) } } fun del(vararg rssSource: RssSource) { - execute { + viewModelScope.launch(Dispatchers.IO) { SourceHelp.deleteRssSources(rssSource.toList()) } } + fun delSelectionByIds(ids: Set) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getRssSources(*ids.toTypedArray()) + SourceHelp.deleteRssSources(sources) + _selectedIds.update { it - ids } + } + } + fun update(vararg rssSource: RssSource) { - execute { appDb.rssSourceDao.update(*rssSource) } + viewModelScope.launch(Dispatchers.IO) { dao.update(*rssSource) } } fun upOrder() { - execute { - val sources = appDb.rssSourceDao.all + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.all for ((index: Int, source: RssSource) in sources.withIndex()) { source.customOrder = index + 1 } - appDb.rssSourceDao.update(*sources.toTypedArray()) + dao.update(*sources.toTypedArray()) } } - fun enableSelection(sources: List) { - execute { - val array = Array(sources.size) { - sources[it].copy(enabled = true) - } - appDb.rssSourceDao.update(*array) + fun enableSelectionByIds(ids: Set) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getRssSources(*ids.toTypedArray()) + val updated = sources.map { it.copy(enabled = true) } + dao.update(*updated.toTypedArray()) } } - fun disableSelection(sources: List) { - execute { - val array = Array(sources.size) { - sources[it].copy(enabled = false) - } - appDb.rssSourceDao.update(*array) + fun disableSelectionByIds(ids: Set) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getRssSources(*ids.toTypedArray()) + val updated = sources.map { it.copy(enabled = false) } + dao.update(*updated.toTypedArray()) } } @@ -94,75 +292,64 @@ class RssSourceViewModel(application: Application) : BaseViewModel(application) } } - fun selectionAddToGroups(sources: List, groups: String) { - execute { - val array = Array(sources.size) { - sources[it].copy().addGroup(groups) - } - appDb.rssSourceDao.update(*array) + fun selectionAddToGroups(ids: Set, groups: String) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getRssSources(*ids.toTypedArray()) + val updated = sources.map { it.copy().addGroup(groups) } + dao.update(*updated.toTypedArray()) } } - fun selectionRemoveFromGroups(sources: List, groups: String) { - execute { - val array = Array(sources.size) { - sources[it].copy().removeGroup(groups) - } - appDb.rssSourceDao.update(*array) + fun selectionRemoveFromGroups(ids: Set, groups: String) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getRssSources(*ids.toTypedArray()) + val updated = sources.map { it.copy().removeGroup(groups) } + dao.update(*updated.toTypedArray()) } } - fun addGroup(group: String) { - execute { - val sources = appDb.rssSourceDao.noGroup + fun upGroup(oldGroup: String, newGroup: String) { + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getByGroup(oldGroup) sources.forEach { source -> - source.sourceGroup = group - } - appDb.rssSourceDao.update(*sources.toTypedArray()) - } - } - - fun upGroup(oldGroup: String, newGroup: String?) { - execute { - val sources = appDb.rssSourceDao.getByGroup(oldGroup) - sources.forEach { source -> - source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { + source.sourceGroup?.split(",")?.toHashSet()?.let { it.remove(oldGroup) - if (!newGroup.isNullOrEmpty()) - it.add(newGroup) - source.sourceGroup = TextUtils.join(",", it) + if (newGroup.isNotEmpty()) it.add(newGroup) + source.sourceGroup = it.joinToString(",") } } - appDb.rssSourceDao.update(*sources.toTypedArray()) + dao.update(*sources.toTypedArray()) } } fun delGroup(group: String) { - execute { - execute { - val sources = appDb.rssSourceDao.getByGroup(group) - sources.forEach { source -> - source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let { - it.remove(group) - source.sourceGroup = TextUtils.join(",", it) - } + viewModelScope.launch(Dispatchers.IO) { + val sources = dao.getByGroup(group) + sources.forEach { source -> + source.sourceGroup?.split(",")?.toHashSet()?.let { + it.remove(group) + source.sourceGroup = it.joinToString(",") } - appDb.rssSourceDao.update(*sources.toTypedArray()) } + dao.update(*sources.toTypedArray()) } } fun importDefault() { - execute { + viewModelScope.launch(Dispatchers.IO) { DefaultData.importDefaultRssSources() } } - fun disable(rssSource: RssSource) { - execute { - rssSource.enabled = false - appDb.rssSourceDao.update(rssSource) + fun checkSelectedInterval(selectedIds: Set, allItems: List) { + if (selectedIds.isEmpty()) return + val indices = allItems.mapIndexedNotNull { index, item -> + if (selectedIds.contains(item.id)) index else null } + val min = indices.minOrNull() ?: return + val max = indices.maxOrNull() ?: return + val newSelection = allItems.subList(min, max + 1).map { it.id }.toSet() + _selectedIds.value = newSelection } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt index 82d20dea3..500b0edb9 100644 --- a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt @@ -1,146 +1,23 @@ package io.legado.app.ui.rss.subscription import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.core.view.isGone -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import io.legado.app.R -import io.legado.app.base.BaseActivity -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.data.entities.RuleSub -import io.legado.app.databinding.ActivityRuleSubBinding -import io.legado.app.databinding.DialogRuleSubEditBinding -import io.legado.app.lib.dialogs.alert -import io.legado.app.ui.association.ImportBookSourceDialog -import io.legado.app.ui.association.ImportReplaceRuleDialog -import io.legado.app.ui.association.ImportRssSourceDialog -import io.legado.app.ui.widget.recycler.ItemTouchCallback -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import androidx.compose.runtime.Composable +import io.legado.app.base.BaseComposeActivity /** * 规则订阅界面 */ -class RuleSubActivity : BaseActivity(), - RuleSubAdapter.Callback { - - override val binding by viewBinding(ActivityRuleSubBinding::inflate) - private val adapter by lazy { RuleSubAdapter(this, this) } +class RuleSubActivity : BaseComposeActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - initView() - initData() } - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.source_subscription, menu) - return super.onCompatCreateOptionsMenu(menu) + @Composable + override fun Content() { + RuleSubScreen( + onBackClick = { finish() } + ) } - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_add -> { - val order = appDb.ruleSubDao.maxOrder + 1 - editSubscription(RuleSub(customOrder = order)) - } - } - return super.onCompatOptionsItemSelected(item) - } - - private fun initView() { - binding.recyclerView.adapter = adapter - binding.recyclerView.applyNavigationBarPadding() - val itemTouchCallback = ItemTouchCallback(adapter) - itemTouchCallback.isCanDrag = true - ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView) - } - - private fun initData() { - lifecycleScope.launch { - appDb.ruleSubDao.flowAll().catch { - AppLog.put("规则订阅界面获取数据失败\n${it.localizedMessage}", it) - }.flowOn(IO).conflate().collect { - binding.tvEmptyMsg.isGone = it.isNotEmpty() - adapter.setItems(it) - } - } - } - - override fun openSubscription(ruleSub: RuleSub) { - when (ruleSub.type) { - 0 -> showDialogFragment( - ImportBookSourceDialog(ruleSub.url) - ) - 1 -> showDialogFragment( - ImportRssSourceDialog(ruleSub.url) - ) - 2 -> showDialogFragment( - ImportReplaceRuleDialog(ruleSub.url) - ) - } - } - - override fun editSubscription(ruleSub: RuleSub) { - alert(R.string.rule_subscription) { - val alertBinding = DialogRuleSubEditBinding.inflate(layoutInflater).apply { - spType.setSelection(ruleSub.type) - etName.setText(ruleSub.name) - etUrl.setText(ruleSub.url) - } - customView { alertBinding.root } - okButton { - lifecycleScope.launch { - ruleSub.type = alertBinding.spType.selectedItemPosition - ruleSub.name = alertBinding.etName.text?.toString() ?: "" - ruleSub.url = alertBinding.etUrl.text?.toString() ?: "" - val rs = withContext(IO) { - appDb.ruleSubDao.findByUrl(ruleSub.url) - } - if (rs != null && rs.id != ruleSub.id) { - toastOnUi("${getString(R.string.url_already)}(${rs.name})") - return@launch - } - withContext(IO) { - appDb.ruleSubDao.insert(ruleSub) - } - } - } - cancelButton() - } - } - - override fun delSubscription(ruleSub: RuleSub) { - lifecycleScope.launch(IO) { - appDb.ruleSubDao.delete(ruleSub) - } - } - - override fun updateSourceSub(vararg ruleSub: RuleSub) { - lifecycleScope.launch(IO) { - appDb.ruleSubDao.update(*ruleSub) - } - } - - override fun upOrder() { - lifecycleScope.launch(IO) { - val sourceSubs = appDb.ruleSubDao.all - for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) { - ruleSub.customOrder = index + 1 - } - appDb.ruleSubDao.update(*sourceSubs.toTypedArray()) - } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt new file mode 100644 index 000000000..4c1d76c55 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt @@ -0,0 +1,266 @@ +package io.legado.app.ui.rss.subscription + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.legado.app.R +import io.legado.app.data.entities.RuleSub +import io.legado.app.ui.association.ImportBookSourceDialog +import io.legado.app.ui.association.ImportReplaceRuleDialog +import io.legado.app.ui.association.ImportRssSourceDialog +import io.legado.app.ui.widget.components.EmptyMessageView +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer +import io.legado.app.ui.widget.components.checkBox.CheckboxItem +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.rules.RuleListScaffold +import io.legado.app.utils.showDialogFragment +import io.legado.app.utils.toastOnUi + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RuleSubScreen( + onBackClick: () -> Unit, + viewModel: RuleSubViewModel = viewModel() +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var showEditDialog by remember { mutableStateOf(null) } + + RuleListScaffold( + title = stringResource(R.string.rule_subscription), + state = state, + onBackClick = onBackClick, + onSearchToggle = viewModel::onSearchToggle, + onSearchQueryChange = viewModel::onSearchQueryChange, + onClearSelection = viewModel::clearSelection, + onSelectAll = viewModel::selectAll, + onSelectInvert = viewModel::selectInvert, + onDeleteSelected = { viewModel.deleteSelected() }, + snackbarHostState = snackbarHostState, + selectionSecondaryActions = emptyList(), + topBarActions = {}, + dropDownMenuContent = { dismiss -> + RoundDropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(12.dp)) + Text(stringResource(R.string.sort)) + } + }, + onClick = { + viewModel.resetOrder() + dismiss() + } + ) + }, + floatingActionButton = { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Localized description") } }, + state = rememberTooltipState(), + ) { + FloatingActionButton( + modifier = Modifier + .animateFloatingActionButton( + visible = true, + alignment = Alignment.BottomEnd, + ), + onClick = { + showEditDialog = RuleSub(customOrder = state.items.size + 1) + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Rule") + } + } + } + ) { paddingValues -> + if (state.items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + EmptyMessageView( + message = stringResource(R.string.rule_sub_empty_msg), + isLoading = state.isLoading + ) + } + } else { + val typeArray = stringArrayResource(R.array.rule_type) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { it.id }) { ruleSub -> + val isSelected = state.selectedIds.contains(ruleSub.id) + SelectionItemCard( + title = ruleSub.name, + subtitle = "${typeArray.getOrElse(ruleSub.type) { "" }}\n${ruleSub.url}", + isSelected = isSelected, + inSelectionMode = state.selectedIds.isNotEmpty(), + onToggleSelection = { + if (state.selectedIds.isNotEmpty()) { + viewModel.toggleSelection(ruleSub) + } else { + when (ruleSub.type) { + 0 -> (context as? AppCompatActivity)?.showDialogFragment( + ImportBookSourceDialog(ruleSub.url) + ) + + 1 -> (context as? AppCompatActivity)?.showDialogFragment( + ImportRssSourceDialog(ruleSub.url) + ) + + 2 -> (context as? AppCompatActivity)?.showDialogFragment( + ImportReplaceRuleDialog(ruleSub.url) + ) + } + } + }, + trailingAction = { + IconButton(onClick = { showEditDialog = ruleSub }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + }, + dropdownContent = { dismiss -> + RoundDropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + onClick = { + viewModel.delete(ruleSub) + dismiss() + } + ) + } + ) + } + } + } + } + + showEditDialog?.let { ruleSub -> + RuleSubEditDialog( + ruleSub = ruleSub, + onDismiss = { showEditDialog = null }, + onConfirm = { updatedRuleSub -> + viewModel.save( + updatedRuleSub, + onSuccess = { showEditDialog = null }, + onError = { context.toastOnUi(it) } + ) + } + ) + } +} + +@Composable +fun RuleSubEditDialog( + ruleSub: RuleSub, + onDismiss: () -> Unit, + onConfirm: (RuleSub) -> Unit +) { + var name by remember { mutableStateOf(ruleSub.name) } + var url by remember { mutableStateOf(ruleSub.url) } + var type by remember { mutableIntStateOf(ruleSub.type) } + val typeArray = stringArrayResource(R.array.rule_type) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.rule_subscription)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.name)) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL") }, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "订阅类型", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 8.dp) + ) + CheckboxGroupContainer(columns = 2) { + typeArray.forEachIndexed { index, text -> + item { + CheckboxItem( + title = text, + checked = (index == type), + onCheckedChange = { + if (it) type = index + } + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm(ruleSub.copy(name = name, url = url, type = type)) + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt new file mode 100644 index 000000000..44b6a9b6a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt @@ -0,0 +1,136 @@ +package io.legado.app.ui.rss.subscription + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.base.BaseViewModel +import io.legado.app.data.appDb +import io.legado.app.data.entities.RuleSub +import io.legado.app.ui.widget.components.rules.ListUiState +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RuleSubViewModel(application: Application) : BaseViewModel(application) { + + private val _searchKey = MutableStateFlow("") + private val _isSearch = MutableStateFlow(false) + private val _selectedIds = MutableStateFlow>(emptySet()) + + val state: StateFlow = combine( + _searchKey, + _isSearch, + _selectedIds, + appDb.ruleSubDao.flowAll() + ) { searchKey, isSearch, selectedIds, items -> + val filteredItems = if (isSearch && searchKey.isNotBlank()) { + items.filter { + it.name.contains(searchKey, ignoreCase = true) || it.url.contains( + searchKey, + ignoreCase = true + ) + } + } else { + items + } + RuleSubUiState( + items = filteredItems, + selectedIds = selectedIds, + searchKey = searchKey, + isSearch = isSearch + ) + }.flowOn(IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RuleSubUiState()) + + fun onSearchToggle(isSearch: Boolean) { + _isSearch.value = isSearch + if (!isSearch) _searchKey.value = "" + } + + fun onSearchQueryChange(query: String) { + _searchKey.value = query + } + + fun toggleSelection(ruleSub: RuleSub) { + _selectedIds.update { + if (it.contains(ruleSub.id)) it - ruleSub.id else it + ruleSub.id + } + } + + fun selectAll() { + _selectedIds.value = state.value.items.map { it.id }.toSet() + } + + fun selectInvert() { + val allIds = state.value.items.map { it.id }.toSet() + _selectedIds.update { current -> + allIds - current + } + } + + fun clearSelection() { + _selectedIds.value = emptySet() + } + + fun deleteSelected() { + val selected = _selectedIds.value + viewModelScope.launch(IO) { + state.value.items.filter { selected.contains(it.id) }.forEach { + appDb.ruleSubDao.delete(it) + } + clearSelection() + } + } + + fun delete(ruleSub: RuleSub) { + viewModelScope.launch(IO) { + appDb.ruleSubDao.delete(ruleSub) + } + } + + fun save(ruleSub: RuleSub, onSuccess: () -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + val rs = withContext(IO) { + appDb.ruleSubDao.findByUrl(ruleSub.url) + } + if (rs != null && rs.id != ruleSub.id) { + onError("${getApplication().getString(R.string.url_already)}(${rs.name})") + return@launch + } + withContext(IO) { + appDb.ruleSubDao.insert(ruleSub) + } + onSuccess() + } + } + + fun updateOrder(vararg ruleSubs: RuleSub) { + viewModelScope.launch(IO) { + appDb.ruleSubDao.update(*ruleSubs) + } + } + + fun resetOrder() { + viewModelScope.launch(IO) { + val sourceSubs = appDb.ruleSubDao.all + for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) { + ruleSub.customOrder = index + 1 + } + appDb.ruleSubDao.update(*sourceSubs.toTypedArray()) + } + } + + data class RuleSubUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + override val isSearch: Boolean = false, + override val isLoading: Boolean = false, + ) : ListUiState +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt new file mode 100644 index 000000000..120e28b43 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt @@ -0,0 +1,146 @@ +package io.legado.app.ui.widget.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.widget.components.button.SmallTextButton +import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.SettingItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupManageBottomSheet( + groups: List, + onDismissRequest: () -> Unit, + onUpdateGroup: (oldGroup: String, newGroup: String) -> Unit, + onDeleteGroup: (group: String) -> Unit +) { + GlassModalBottomSheet( + onDismissRequest = onDismissRequest + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(R.string.group_manage), + style = MaterialTheme.typography.titleMedium + ) + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(groups, key = { it }) { group -> + GroupItem( + group = group, + onUpdateGroup = onUpdateGroup, + onDeleteGroup = onDeleteGroup + ) + } + } + } + } +} + +@Composable +private fun GroupItem( + group: String, + onUpdateGroup: (oldGroup: String, newGroup: String) -> Unit, + onDeleteGroup: (group: String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val state = rememberTextFieldState(initialText = group) + + LaunchedEffect(expanded) { + if (expanded) { + state.edit { + replace(0, length, group) + } + } + } + + SettingItem( + title = group, + expanded = expanded, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + onExpandChange = { expanded = it }, + trailingContent = { + Row { + IconButton(onClick = { expanded = !expanded }) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(id = R.string.edit) + ) + } + IconButton(onClick = { onDeleteGroup(group) }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(id = R.string.delete) + ) + } + } + }, + expandContent = { + TextField( + state = state, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + label = { Text(stringResource(R.string.edit)) }, + contentPadding = PaddingValues( + top = 4.dp, + bottom = 4.dp, + start = 12.dp, + end = 12.dp + ), + onKeyboardAction = { + onUpdateGroup(group, state.text.toString()) + expanded = false + } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + SmallTextButton( + text = stringResource(id = R.string.ok), + icon = Icons.Default.Check, + onClick = { + onUpdateGroup(group, state.text.toString()) + expanded = false + } + ) + } + } + ) +} 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 new file mode 100644 index 000000000..9e45bfc28 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt @@ -0,0 +1,64 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RssFeed +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import io.legado.app.model.BookCover.coverImageLoader + +/** + * 专门用于显示源图标的组件 + * 1. 默认正方形比例 + * 2. ContentScale.Fit (不裁切) + * 3. 无强制背景色,适合在已有背景的容器中使用 + */ +@Composable +fun SourceIcon( + path: Any?, + modifier: Modifier = Modifier.size(32.dp), + sourceOrigin: String? = null, + loadOnlyWifi: Boolean = false, + placeholderIcon: @Composable () -> Unit = { + Icon( + Icons.Default.RssFeed, + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.fillMaxSize(0.7f) + ) + } +) { + val context = LocalContext.current + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + if (path == null || (path is String && path.isEmpty())) { + placeholderIcon() + } else { + AsyncImage( + model = ImageRequest.Builder(context) + .data(path) + .crossfade(true) + .setParameter("sourceOrigin", sourceOrigin) + .setParameter("loadOnlyWifi", loadOnlyWifi) + .build(), + imageLoader = coverImageLoader, + contentDescription = null, + contentScale = ContentScale.Fit, // 不裁切 + modifier = Modifier.fillMaxSize() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/dialog/TextListInputDialog.kt b/app/src/main/java/io/legado/app/ui/widget/components/dialog/TextListInputDialog.kt new file mode 100644 index 000000000..1444facbf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/dialog/TextListInputDialog.kt @@ -0,0 +1,84 @@ +package io.legado.app.ui.widget.components.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@Composable +fun TextListInputDialog( + title: String, + hint: String, + initialValue: String = "", + suggestions: List = emptyList(), + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit +) { + var text by remember { mutableStateOf(initialValue) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(hint) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + if (suggestions.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "建议:", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(suggestions) { suggestion -> + AssistChip( + onClick = { text = suggestion }, + label = { Text(suggestion) } + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(text) } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} \ No newline at end of file