From c53b592309d70ed60ef68db30305845266b4f585 Mon Sep 17 00:00:00 2001 From: mlkgrnt Date: Thu, 3 Sep 2026 23:35:05 +0800 Subject: [PATCH 1/3] feat: support manual ordering of groups and feeds - draggable reordering of groups and feeds (Reorderable 3.1.0) - create empty group via the + button in the feeds page title row - data layer: position column with SQL DEFAULT on Feed and Group, AutoMigration 7 -> 8, secondary constructor updated to keep Room constructor matching working --- app/build.gradle.kts | 1 + .../8.json | 428 ++++++++++++++++++ .../me/ash/reader/domain/model/feed/Feed.kt | 16 +- .../me/ash/reader/domain/model/group/Group.kt | 2 + .../ash/reader/domain/repository/FeedDao.kt | 23 +- .../ash/reader/domain/repository/GroupDao.kt | 29 +- .../domain/service/AbstractRssRepository.kt | 76 +++- .../infrastructure/db/AndroidDatabase.kt | 3 +- .../ash/reader/ui/page/home/feeds/FeedItem.kt | 11 +- .../reader/ui/page/home/feeds/FeedsPage.kt | 97 +++- .../ui/page/home/feeds/FeedsReorderMode.kt | 206 +++++++++ .../ui/page/home/feeds/FeedsViewModel.kt | 22 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 3 + 15 files changed, 887 insertions(+), 34 deletions(-) create mode 100644 app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json create mode 100644 app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9551c106..489d9eaf0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -137,6 +137,7 @@ dependencies { implementation(libs.compose.ui.tooling.preview) androidTestImplementation(libs.compose.ui.test.junit4) implementation(libs.compose.material3) + implementation(libs.reorderable) // Coil implementation(libs.coil.base) diff --git a/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json new file mode 100644 index 000000000..36e0a7e6d --- /dev/null +++ b/app/schemas/me.ash.reader.infrastructure.db.AndroidDatabase/8.json @@ -0,0 +1,428 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "605e179975e7ba58a568f1e31dbc8fbb", + "entities": [ + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `lastArticleId` TEXT, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastArticleId", + "columnName": "lastArticleId", + "affinity": "TEXT" + }, + { + "fieldPath": "syncInterval", + "columnName": "syncInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "syncOnStart", + "columnName": "syncOnStart", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyOnWiFi", + "columnName": "syncOnlyOnWiFi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncOnlyWhenCharging", + "columnName": "syncOnlyWhenCharging", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepArchived", + "columnName": "keepArchived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2592000000" + }, + { + "fieldPath": "syncBlockList", + "columnName": "syncBlockList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "securityKey", + "columnName": "securityKey", + "affinity": "TEXT", + "defaultValue": "'CvJ1PKM8EW8='" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, `isBrowser` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotification", + "columnName": "isNotification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullContent", + "columnName": "isFullContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBrowser", + "columnName": "isBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_feed_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_feed_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "rawDescription", + "columnName": "rawDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContent", + "columnName": "fullContent", + "affinity": "TEXT" + }, + { + "fieldPath": "img", + "columnName": "img", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnread", + "columnName": "isUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadLater", + "columnName": "isReadLater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateAt", + "columnName": "updateAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_article_feedId", + "unique": false, + "columnNames": [ + "feedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)" + }, + { + "name": "index_article_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `position` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ] + }, + { + "tableName": "archived_article", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` TEXT NOT NULL, `link` TEXT NOT NULL, FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feedId", + "columnName": "feedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "feed", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "feedId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '605e179975e7ba58a568f1e31dbc8fbb')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt index 7405e3313..49094b977 100644 --- a/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt +++ b/app/src/main/java/me/ash/reader/domain/model/feed/Feed.kt @@ -1,6 +1,10 @@ package me.ash.reader.domain.model.feed -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.PrimaryKey import me.ash.reader.domain.model.group.Group /** @@ -35,7 +39,9 @@ data class Feed( val isFullContent: Boolean = false, @ColumnInfo(defaultValue = "0") val isBrowser: Boolean = false, - @Ignore val important: Int = 0 + @Ignore val important: Int = 0, + @ColumnInfo(defaultValue = "0") + val position: Int = 0, ) { constructor( id: String, @@ -46,7 +52,8 @@ data class Feed( accountId: Int, isNotification: Boolean, isFullContent: Boolean, - isBrowser: Boolean + isBrowser: Boolean, + position: Int = 0, ) : this( id = id, name = name, @@ -57,6 +64,7 @@ data class Feed( isNotification = isNotification, isFullContent = isFullContent, isBrowser = isBrowser, - important = 0 + important = 0, + position = position, ) } diff --git a/app/src/main/java/me/ash/reader/domain/model/group/Group.kt b/app/src/main/java/me/ash/reader/domain/model/group/Group.kt index e3fb9d06d..27ce546d6 100644 --- a/app/src/main/java/me/ash/reader/domain/model/group/Group.kt +++ b/app/src/main/java/me/ash/reader/domain/model/group/Group.kt @@ -16,4 +16,6 @@ data class Group( val name: String, @ColumnInfo(index = true) val accountId: Int, + @ColumnInfo(defaultValue = "0") + val position: Int = 0, ) diff --git a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt index 48edce034..69e78419f 100644 --- a/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt +++ b/app/src/main/java/me/ash/reader/domain/repository/FeedDao.kt @@ -178,7 +178,27 @@ interface FeedDao { val localFeeds = queryByIds(feeds.map { it.id }).associateBy { it.id } val (newFeeds, feedsToUpdate) = feeds.partition { !localFeeds.contains(it.id) } - if (newFeeds.isNotEmpty()) insertAll(newFeeds) + if (newFeeds.isNotEmpty()) { + val accountId = newFeeds.first().accountId + val groupMaxPositions = + queryAll(accountId) + .groupBy { it.groupId } + .mapValues { (_, feeds) -> feeds.maxOf { it.position } } + .toMutableMap() + val groupCursors = mutableMapOf() + insertAll( + newFeeds.map { feed -> + if (feed.position == 0) { + val base = groupMaxPositions[feed.groupId] ?: -1 + val cursor = groupCursors.getOrPut(feed.groupId) { base } + groupCursors[feed.groupId] = cursor + 1 + feed.copy(position = cursor + 1) + } else { + feed + } + } + ) + } feedsToUpdate .mapNotNull { new -> @@ -189,6 +209,7 @@ interface FeedDao { isNotification = existing.isNotification, isFullContent = existing.isFullContent, isBrowser = existing.isBrowser, + position = existing.position, ) if (updated == existing) { null diff --git a/app/src/main/java/me/ash/reader/domain/repository/GroupDao.kt b/app/src/main/java/me/ash/reader/domain/repository/GroupDao.kt index e992bb279..931e12dc9 100644 --- a/app/src/main/java/me/ash/reader/domain/repository/GroupDao.kt +++ b/app/src/main/java/me/ash/reader/domain/repository/GroupDao.kt @@ -80,9 +80,30 @@ interface GroupDao { @Transaction suspend fun insertOrUpdate(groups: List) { - val localGroupIds = queryByIds(groups.map { it.id }).map { it.id } - val (newGroups, groupsToUpdate) = groups.partition { it.id !in localGroupIds } - insertAll(newGroups) - updateAll(groupsToUpdate) + val localGroups = queryByIds(groups.map { it.id }).associateBy { it.id } + val (newGroups, groupsToUpdate) = groups.partition { !localGroups.contains(it.id) } + + if (newGroups.isNotEmpty()) { + val accountId = newGroups.first().accountId + val basePosition = queryAll(accountId).maxOfOrNull { it.position } ?: -1 + var cursor = basePosition + insertAll( + newGroups.map { group -> + if (group.position == 0) { + cursor += 1 + group.copy(position = cursor) + } else { + group + } + } + ) + } + + updateAll( + groupsToUpdate.mapNotNull { new -> + val existing = localGroups[new.id] ?: return@mapNotNull null + if (existing == new) null else new.copy(position = existing.position) + } + ) } } diff --git a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt index 32f66c7ba..61ef30088 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AbstractRssRepository.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import me.ash.reader.domain.model.account.Account import me.ash.reader.domain.model.article.ArchivedArticle import me.ash.reader.domain.model.article.Article @@ -71,6 +72,7 @@ abstract class AbstractRssRepository( isBrowser = isBrowser, isNotification = isNotification, isFullContent = isFullContent, + position = nextFeedPosition(accountId, groupId), ) val articles = searchedFeed.entries.map { rssHelper.buildArticleFromSyndEntry(feed, accountId, it) } @@ -81,11 +83,53 @@ abstract class AbstractRssRepository( open suspend fun addGroup(destFeed: Feed?, newGroupName: String): String { accountService.getCurrentAccountId().let { accountId -> return accountId.spacerDollar(UUID.randomUUID().toString()).also { - groupDao.insert(Group(id = it, name = newGroupName, accountId = accountId)) + groupDao.insert( + Group( + id = it, + name = newGroupName, + accountId = accountId, + position = nextGroupPosition(accountId), + ) + ) } } } + internal suspend fun nextFeedPosition(accountId: Int, groupId: String): Int = + (feedDao.queryAll(accountId).filter { it.groupId == groupId }.maxOfOrNull { it.position } + ?: -1) + 1 + + internal suspend fun nextGroupPosition(accountId: Int): Int = + (groupDao.queryAll(accountId).maxOfOrNull { it.position } ?: -1) + 1 + + /** + * Reorders groups so their list order matches [orderedGroupIds]. + */ + open suspend fun reorderGroups(orderedGroupIds: List) { + if (orderedGroupIds.isEmpty()) return + val accountId = accountService.getCurrentAccountId() + val localGroups = groupDao.queryAll(accountId).associateBy { it.id } + groupDao.updateAll( + orderedGroupIds.mapIndexedNotNull { index, id -> + localGroups[id]?.copy(position = index) + } + ) + } + + /** + * Reorders feeds inside [groupId] so their order matches [orderedFeedIds]. + */ + open suspend fun reorderFeeds(groupId: String, orderedFeedIds: List) { + if (orderedFeedIds.isEmpty()) return + val accountId = accountService.getCurrentAccountId() + val localFeeds = feedDao.queryAll(accountId).associateBy { it.id } + feedDao.updateAll( + orderedFeedIds.mapIndexedNotNull { index, id -> + localFeeds[id]?.takeIf { it.groupId == groupId }?.copy(position = index) + } + ) + } + abstract suspend fun sync( accountId: Int, feedId: String?, @@ -207,11 +251,22 @@ abstract class AbstractRssRepository( } fun pullGroups(): Flow> = - groupDao.queryAllGroup(accountService.getCurrentAccountId()).flowOn(dispatcherIO) + groupDao + .queryAllGroup(accountService.getCurrentAccountId()) + .map { it.apply { sortBy { group -> group.position } } } + .flowOn(dispatcherIO) fun pullFeeds(): Flow> = groupDao .queryAllGroupWithFeedAsFlow(accountService.getCurrentAccountId()) + .map { groupWithFeeds -> + groupWithFeeds.apply { + sortBy { it.group.position } + forEach { groupWithFeed -> + groupWithFeed.feeds.sortBy { feed -> feed.position } + } + } + } .flowOn(dispatcherIO) fun pullArticles( @@ -306,7 +361,14 @@ abstract class AbstractRssRepository( feedDao.queryByLink(accountService.getCurrentAccountId(), url).isNotEmpty() suspend fun queryAllGroupWithFeeds(): List = - groupDao.queryAllGroupWithFeed(accountService.getCurrentAccountId()) + groupDao + .queryAllGroupWithFeed(accountService.getCurrentAccountId()) + .sortedBy { it.group.position } + .map { groupWithFeed -> + groupWithFeed.copy( + feeds = groupWithFeed.feeds.sortedBy { it.position }.toMutableList() + ) + } open suspend fun renameGroup(group: Group) { groupDao.update(group) @@ -317,7 +379,13 @@ abstract class AbstractRssRepository( } open suspend fun moveFeed(originGroupId: String, feed: Feed) { - updateFeed(feed) + val movedFeed = + if (originGroupId != feed.groupId) { + feed.copy(position = nextFeedPosition(feed.accountId, feed.groupId)) + } else { + feed + } + updateFeed(movedFeed) } open suspend fun changeFeedUrl(feed: Feed) { diff --git a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt index 925b345b7..a8fc64613 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/db/AndroidDatabase.kt @@ -20,11 +20,12 @@ import java.util.* @Database( entities = [Account::class, Feed::class, Article::class, Group::class, ArchivedArticle::class], - version = 7, + version = 8, autoMigrations = [ AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 7), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ] ) @TypeConverters( diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt index b029ca498..69c60cf22 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedItem.kt @@ -42,7 +42,8 @@ private fun FeedItemImpl( isLastItem: () -> Boolean = { false }, onLongClickCallback: (String) -> Unit = {}, onClick: () -> Unit = {}, - onLongClick: () -> Unit = {} + onLongClick: () -> Unit = {}, + trailing: @Composable () -> Unit = {}, ) { val scope = rememberCoroutineScope() Row( @@ -96,6 +97,7 @@ private fun FeedItemImpl( }, ) } + trailing() } } } @@ -110,7 +112,8 @@ fun FeedItem( isExpanded: () -> Boolean, feedOptionViewModel: FeedOptionViewModel = hiltViewModel(), onClick: () -> Unit = {}, - onLongClick: () -> Unit = {} + onLongClick: () -> Unit = {}, + trailing: @Composable () -> Unit = {}, ) { val scope = rememberCoroutineScope() RYExtensibleVisibility(visible = isExpanded()) { @@ -123,7 +126,9 @@ fun FeedItem( scope.launch { feedOptionViewModel.fetchFeed(feedId = feedId) } - }) + }, + trailing = trailing, + ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 312230861..52be4757f 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -22,8 +22,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material.icons.rounded.UnfoldLess import androidx.compose.material.icons.rounded.UnfoldMore import androidx.compose.material.rememberModalBottomSheetState @@ -73,6 +75,7 @@ import me.ash.reader.ui.component.FilterBar import me.ash.reader.ui.component.base.DisplayText import me.ash.reader.ui.component.base.FeedbackIconButton import me.ash.reader.ui.component.base.RYScaffold +import me.ash.reader.ui.component.base.TextFieldDialog import me.ash.reader.ui.component.scrollbar.drawVerticalScrollIndicator import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.currentAccountId @@ -102,6 +105,9 @@ fun FeedsPage( navigateToAccountDetail: (Int) -> Unit, ) { var accountTabVisible by remember { mutableStateOf(false) } + var reorderMode by remember { mutableStateOf(false) } + var newGroupDialogVisible by remember { mutableStateOf(false) } + var newGroupContent by remember { mutableStateOf("") } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -228,7 +234,21 @@ fun FeedsPage( ) }, content = { - PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { + if (reorderMode) { + FeedsReorderMode( + groupWithFeedList = groupWithFeedList, + onDone = { orderedGroups -> + if (orderedGroups.isNotEmpty()) { + feedsViewModel.reorderGroups(orderedGroups.map { it.group.id }) + orderedGroups.forEach { gwf -> + feedsViewModel.reorderFeeds(gwf.group.id, gwf.feeds.map { it.id }) + } + } + reorderMode = false + }, + ) + } else { + PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { LazyColumn(modifier = Modifier.fillMaxSize().drawVerticalScrollIndicator(listState), state = listState) { item { DisplayText(text = feedsUiState.account?.name ?: "", desc = "") { @@ -260,21 +280,43 @@ fun FeedsPage( color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, ) - IconButton( - onClick = { - if (hasGroupVisible) collapseAllGroups() else expandAllGroups() - }, - modifier = Modifier - .padding(end = 8.dp) - .size(28.dp), - ) { - Icon( - imageVector = - if (hasGroupVisible) Icons.Rounded.UnfoldLess - else Icons.Rounded.UnfoldMore, - contentDescription = stringResource(R.string.unfold_less), - tint = MaterialTheme.colorScheme.primary, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { newGroupDialogVisible = true }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.create_new_group), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = { reorderMode = true }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Rounded.SwapVert, + contentDescription = stringResource(R.string.reorder), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = { + if (hasGroupVisible) collapseAllGroups() else expandAllGroups() + }, + modifier = Modifier + .padding(end = 8.dp) + .size(28.dp), + ) { + Icon( + imageVector = + if (hasGroupVisible) Icons.Rounded.UnfoldLess + else Icons.Rounded.UnfoldMore, + contentDescription = stringResource(R.string.unfold_less), + tint = MaterialTheme.colorScheme.primary, + ) + } } } Spacer(modifier = Modifier.height(8.dp)) @@ -329,9 +371,11 @@ fun FeedsPage( } } } + } }, bottomBar = { - FilterBar( + if (!reorderMode) { + FilterBar( modifier = with(sharedTransitionScope) { Modifier.sharedElement( @@ -347,11 +391,30 @@ fun FeedsPage( ) { feedsViewModel.changeFilter(filterState.copy(filter = it)) } + } }, ) SubscribeDialog(subscribeViewModel = subscribeViewModel) + TextFieldDialog( + visible = newGroupDialogVisible, + title = stringResource(R.string.create_new_group), + icon = Icons.Outlined.CreateNewFolder, + value = newGroupContent, + placeholder = stringResource(R.string.name), + onValueChange = { newGroupContent = it }, + onDismissRequest = { + newGroupDialogVisible = false + newGroupContent = "" + }, + onConfirm = { name -> + feedsViewModel.addGroup(name) + newGroupDialogVisible = false + newGroupContent = "" + }, + ) + GroupOptionDrawer(drawerState = groupDrawerState) FeedOptionDrawer(drawerState = feedDrawerState) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt new file mode 100644 index 000000000..093bc4eb8 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt @@ -0,0 +1,206 @@ +package me.ash.reader.ui.page.home.feeds + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.shadow +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import me.ash.reader.R +import me.ash.reader.domain.model.group.GroupWithFeed +import me.ash.reader.ui.theme.Shape32 +import sh.calvin.reorderable.ReorderableColumn +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +/** + * 排序编辑模式:分组可整体拖拽排序,展开的分组内订阅源也可拖拽排序。 + * 拖拽结果由 [onDone] 一次性带回(group id 顺序 + 各组 feed id 顺序),由调用方持久化。 + */ +@Composable +fun FeedsReorderMode( + groupWithFeedList: List, + onDone: (List) -> Unit, +) { + var groups by remember { + mutableStateOf(groupWithFeedList.map { it.copy(feeds = it.feeds.toMutableList()) }) + } + val hapticFeedback = LocalHapticFeedback.current + + fun moveFeed(groupId: String, fromIndex: Int, toIndex: Int) { + if (fromIndex == toIndex) return + groups = + groups.map { gwf -> + if (gwf.group.id == groupId) { + val feeds = gwf.feeds.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + gwf.copy(feeds = feeds) + } else { + gwf + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // 顶部工具条:提示 + 完成 + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.reorder_tips), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = { onDone(groups) }) { + Text( + text = stringResource(R.string.done), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + + val lazyListState = rememberLazyListState() + val reorderableLazyListState = + rememberReorderableLazyListState(lazyListState) { from, to -> + if (from.index != to.index) { + groups = + groups.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + ) { + itemsIndexed(groups, key = { _, gwf -> gwf.group.id }) { _, gwf -> + ReorderableItem(reorderableLazyListState, key = gwf.group.id) { isDragging -> + val elevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + label = "reorderGroupElevation", + ) + GroupWithFeedsContainer( + modifier = Modifier.shadow(elevation, shape = Shape32, clip = false), + ) { + // 组头(可拖拽整组) + Column(modifier = Modifier.padding(top = 22.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 28.dp), + text = gwf.group.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val groupHandleModifier = Modifier.draggableHandle() + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.reorder), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = + groupHandleModifier + .padding(end = 20.dp) + .size(24.dp), + ) + } + Spacer(modifier = Modifier.height(22.dp)) + } + + // 组内订阅源(展开态拖拽排序) + ReorderableColumn( + list = gwf.feeds, + onSettle = { fromIndex, toIndex -> + moveFeed(gwf.group.id, fromIndex, toIndex) + hapticFeedback.performHapticFeedback( + HapticFeedbackType.SegmentFrequentTick + ) + }, + ) { feedIndex, feed, _ -> + key(feed.id) { + ReorderableItem { + val feedHandleModifier = Modifier.draggableHandle() + FeedItem( + feed = feed, + isLastItem = { feedIndex == gwf.feeds.lastIndex }, + isExpanded = { true }, + onClick = {}, + onLongClick = {}, + trailing = { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = + stringResource(R.string.reorder), + tint = MaterialTheme.colorScheme.outline, + modifier = + feedHandleModifier + .padding(start = 4.dp) + .size(18.dp), + ) + }, + ) + } + } + } + } + } + } + item { + Spacer(modifier = Modifier.height(128.dp)) + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt index e151e33f3..0dbe21796 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsViewModel.kt @@ -76,6 +76,28 @@ class FeedsViewModel @Inject constructor( } } + fun addGroup(newGroupName: String) { + val name = newGroupName.trim() + if (name.isEmpty()) return + applicationScope.launch(ioDispatcher) { + rssService.get().addGroup(null, name) + } + } + + fun reorderGroups(orderedGroupIds: List) { + if (orderedGroupIds.isEmpty()) return + applicationScope.launch(ioDispatcher) { + rssService.get().reorderGroups(orderedGroupIds) + } + } + + fun reorderFeeds(groupId: String, orderedFeedIds: List) { + if (orderedFeedIds.isEmpty()) return + applicationScope.launch(ioDispatcher) { + rssService.get().reorderFeeds(groupId, orderedFeedIds) + } + } + fun commitDiffs() = diffMapHolder.commitDiffsToDb() fun changeFilter(filterState: FilterState) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 439703cb8..38da2f620 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -59,6 +59,8 @@ 更改链接 已重命名为 \"%1$s\" 新建分组 + 排序 + 拖动手柄调整顺序 名称 打开 %1$s 选项 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..e51cb2b93 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,8 @@ http://example.com/feed.rss Renamed to \"%1$s\" Create new group + Reorder + Drag the handle to reorder Name Open %1$s Options diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d78154f4a..92fd70212 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,11 +60,14 @@ material3Adaptive = "1.3.0-alpha06" navigationevent = "1.0.0-beta01" composeBomStable = "2025.10.01" composeBomAlpha = "2025.10.01" +reorderable = "3.1.0" [libraries] # AboutLibraries aboutlibraries-core = { group = "com.mikepenz", name = "aboutlibraries-core", version.ref = "aboutLibsRelease" } aboutlibraries-compose = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibsRelease" } +# Reorderable (drag & drop lists) +reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } # Compose compose-html = { group = "com.github.ireward", name = "compose-html", version.ref = "composeHtml" } From a8892004f08bca7938f1b0a86bd2dbfd9b08563d Mon Sep 17 00:00:00 2001 From: mlkgrnt Date: Fri, 4 Sep 2026 13:01:31 +0800 Subject: [PATCH 2/3] fix: confirm unsaved changes on back in reorder mode and use a back-arrow top bar - intercept system back in reorder mode: if the order was changed show a save/discard dialog, otherwise exit reorder mode instead of moving the app to background - replace the feeds-page top bar (settings and add-subscription buttons) with a back arrow and Reorder title while reorder mode is active - drop the redundant drag-handle icon from the reorder toolbar --- .../reader/ui/page/home/feeds/FeedsPage.kt | 112 ++++++++++++------ .../ui/page/home/feeds/FeedsReorderMode.kt | 73 ++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 4 files changed, 145 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 52be4757f..fe05aaecc 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -1,6 +1,7 @@ package me.ash.reader.ui.page.home.feeds import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -22,6 +23,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Add @@ -184,54 +186,85 @@ fun FeedsPage( BackHandler(true) { context.findActivity()?.moveTaskToBack(false) } + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + RYScaffold( topBarTonalElevation = topBarTonalElevation.value.dp, // containerTonalElevation = groupListTonalElevation.value.dp, topBar = { - TopAppBar( - modifier = - Modifier.clickable( - onClick = { - scope.launch { - if (listState.firstVisibleItemIndex != 0) { - listState.animateScrollToItem(0) + if (reorderMode) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.reorder), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + FeedbackIconButton( + modifier = Modifier.size(20.dp), + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface, + ) { + backDispatcher?.onBackPressed() + } + }, + actions = {}, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation( + topBarTonalElevation.value.dp + ) + ), + ) + } else { + TopAppBar( + modifier = + Modifier.clickable( + onClick = { + scope.launch { + if (listState.firstVisibleItemIndex != 0) { + listState.animateScrollToItem(0) + } } - } - }, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ), - title = {}, - navigationIcon = { - FeedbackIconButton( - modifier = Modifier.size(20.dp), - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.settings), - tint = MaterialTheme.colorScheme.onSurface, - showBadge = newVersion.whetherNeedUpdate(currentVersion, skipVersion), - ) { - navigateToSettings() - } - }, - actions = { - if (subscribeViewModel.rssService.get().addSubscription) { + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + title = {}, + navigationIcon = { FeedbackIconButton( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(R.string.subscribe), + modifier = Modifier.size(20.dp), + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.settings), tint = MaterialTheme.colorScheme.onSurface, + showBadge = newVersion.whetherNeedUpdate(currentVersion, skipVersion), ) { - subscribeViewModel.showDrawer() + navigateToSettings() } - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation( - topBarTonalElevation.value.dp - ) - ), - ) + }, + actions = { + if (subscribeViewModel.rssService.get().addSubscription) { + FeedbackIconButton( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.subscribe), + tint = MaterialTheme.colorScheme.onSurface, + ) { + subscribeViewModel.showDrawer() + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation( + topBarTonalElevation.value.dp + ) + ), + ) + } }, content = { if (reorderMode) { @@ -246,6 +279,7 @@ fun FeedsPage( } reorderMode = false }, + onExit = { reorderMode = false }, ) } else { PullToRefreshBox(state = syncingState, isRefreshing = isSyncing, onRefresh = doSync) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt index 093bc4eb8..3705683ed 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsReorderMode.kt @@ -1,5 +1,6 @@ package me.ash.reader.ui.page.home.feeds +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import me.ash.reader.R import me.ash.reader.domain.model.group.GroupWithFeed +import me.ash.reader.ui.component.base.RYDialog import me.ash.reader.ui.theme.Shape32 import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableItem @@ -44,17 +46,42 @@ import sh.calvin.reorderable.rememberReorderableLazyListState /** * 排序编辑模式:分组可整体拖拽排序,展开的分组内订阅源也可拖拽排序。 * 拖拽结果由 [onDone] 一次性带回(group id 顺序 + 各组 feed id 顺序),由调用方持久化。 + * 按下系统返回键时:若存在未保存的改动则弹出确认框(保存 / 不保存),否则直接退出排序模式。 */ @Composable fun FeedsReorderMode( groupWithFeedList: List, onDone: (List) -> Unit, + onExit: () -> Unit, ) { + val originalGroupWithFeedList = remember { groupWithFeedList } var groups by remember { mutableStateOf(groupWithFeedList.map { it.copy(feeds = it.feeds.toMutableList()) }) } + var showExitDialog by remember { mutableStateOf(false) } val hapticFeedback = LocalHapticFeedback.current + val isDirty = + remember(groups) { + groups.map { it.group.id } != + originalGroupWithFeedList.map { it.group.id } || + groups.any { gwf -> + gwf.feeds.map { it.id } != + originalGroupWithFeedList + .first { it.group.id == gwf.group.id } + .feeds + .map { it.id } + } + } + + BackHandler { + if (isDirty) { + showExitDialog = true + } else { + onExit() + } + } + fun moveFeed(groupId: String, fromIndex: Int, toIndex: Int) { if (fromIndex == toIndex) return groups = @@ -82,13 +109,6 @@ fun FeedsReorderMode( verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.DragHandle, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.reorder_tips), style = MaterialTheme.typography.labelMedium, @@ -203,4 +223,43 @@ fun FeedsReorderMode( } } } + + if (showExitDialog) { + RYDialog( + visible = showExitDialog, + onDismissRequest = { showExitDialog = false }, + title = { + Text( + text = stringResource(R.string.reorder_exit_title), + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Text( + text = stringResource(R.string.reorder_exit_message), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton( + onClick = { + showExitDialog = false + onDone(groups) + }, + ) { + Text(text = stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton( + onClick = { + showExitDialog = false + onExit() + }, + ) { + Text(text = stringResource(R.string.discard)) + } + }, + ) + } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 38da2f620..f31245363 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -61,6 +61,9 @@ 新建分组 排序 拖动手柄调整顺序 + 未保存的更改 + 顺序调整尚未保存,离开前要保存吗? + 不保存 名称 打开 %1$s 选项 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e51cb2b93..3224bb268 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,9 @@ Create new group Reorder Drag the handle to reorder + Unsaved changes + You have unsaved changes. Do you want to save them before leaving? + Discard Name Open %1$s Options From dc552f6b3e19e5f62f039a2bbb3ed19d41d67172 Mon Sep 17 00:00:00 2001 From: mlkgrnt Date: Fri, 4 Sep 2026 14:25:43 +0800 Subject: [PATCH 3/3] fix: hide the empty default group from move-target lists The feeds page hides the special default group (the catch-all for uncategorized feeds) once it becomes empty, but the move-subscription target lists in the feed and group option drawers still listed every group via pullGroups(), so the invisible default group could be picked as a move target and would then reappear on the feeds page. Align the two drawers with the feeds-page rule by deriving the group list from pullFeeds() and dropping the default group while it has no feeds. --- .../feeds/drawer/feed/FeedOptionViewModel.kt | 18 ++++++++++++++++-- .../feeds/drawer/group/GroupOptionViewModel.kt | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt index 370f71e42..ecb47daad 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt @@ -17,11 +17,13 @@ import kotlinx.coroutines.withContext import me.ash.reader.domain.model.feed.Feed import me.ash.reader.domain.model.group.Group import me.ash.reader.domain.repository.FeedDao +import me.ash.reader.domain.service.AccountService import me.ash.reader.domain.service.RssService import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.MainDispatcher import me.ash.reader.infrastructure.rss.RssHelper +import me.ash.reader.ui.ext.getDefaultGroupId @OptIn(ExperimentalMaterialApi::class) @HiltViewModel @@ -29,6 +31,7 @@ class FeedOptionViewModel @Inject constructor( val rssService: RssService, + private val accountService: AccountService, @MainDispatcher private val mainDispatcher: CoroutineDispatcher, @IODispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val applicationScope: CoroutineScope, @@ -42,8 +45,19 @@ constructor( init { viewModelScope.launch(ioDispatcher) { rssService.flow().collectLatest { - it.pullGroups().collectLatest { groups -> - _feedOptionUiState.update { it.copy(groups = groups) } + it.pullFeeds().collectLatest { groupWithFeeds -> + // 与主列表一致:空的默认分组不作为移动目标出现 + val defaultGroupId = accountService.getCurrentAccountId().getDefaultGroupId() + _feedOptionUiState.update { state -> + state.copy( + groups = + groupWithFeeds + .filterNot { gwf -> + gwf.group.id == defaultGroupId && gwf.feeds.isEmpty() + } + .map { gwf -> gwf.group }, + ) + } } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt index a147a50c0..eb416f611 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/group/GroupOptionViewModel.kt @@ -16,16 +16,19 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.ash.reader.domain.model.group.Group +import me.ash.reader.domain.service.AccountService import me.ash.reader.domain.service.RssService import me.ash.reader.infrastructure.di.ApplicationScope import me.ash.reader.infrastructure.di.IODispatcher import me.ash.reader.infrastructure.di.MainDispatcher +import me.ash.reader.ui.ext.getDefaultGroupId import javax.inject.Inject @OptIn(ExperimentalMaterialApi::class) @HiltViewModel class GroupOptionViewModel @Inject constructor( val rssService: RssService, + private val accountService: AccountService, @MainDispatcher private val mainDispatcher: CoroutineDispatcher, @IODispatcher @@ -39,8 +42,19 @@ class GroupOptionViewModel @Inject constructor( init { viewModelScope.launch(ioDispatcher) { - rssService.get().pullGroups().collect { groups -> - _groupOptionUiState.update { it.copy(groups = groups) } + rssService.get().pullFeeds().collect { groupWithFeeds -> + // 与主列表一致:空的默认分组不作为移动目标出现 + val defaultGroupId = accountService.getCurrentAccountId().getDefaultGroupId() + _groupOptionUiState.update { state -> + state.copy( + groups = + groupWithFeeds + .filterNot { gwf -> + gwf.group.id == defaultGroupId && gwf.feeds.isEmpty() + } + .map { gwf -> gwf.group }, + ) + } } } }