feat: support multi-select

This commit is contained in:
lisonge 2024-06-09 17:09:10 +08:00
parent 9f0f026459
commit f34b1ca1ea
13 changed files with 303 additions and 138 deletions

View File

@ -114,6 +114,7 @@ android {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "GKD-debug")
resValue("string", "capture_label", "捕获快照-debug")
resValue("string", "import_desc", "GKD-debug-导入数据")
}
}
compileOptions {

View File

@ -13,6 +13,7 @@ import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.db.DbSet
import li.songe.gkd.permission.authReasonFlow
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.clearCache
import li.songe.gkd.util.launchTry
@ -24,7 +25,7 @@ class MainViewModel : ViewModel() {
init {
val localSubsItem = SubsItem(
id = -2, order = -2, mtime = System.currentTimeMillis()
id = LOCAL_SUBS_ID, order = -2, mtime = System.currentTimeMillis()
)
viewModelScope.launchTry(Dispatchers.IO) {
val subsItems = DbSet.subsItemDao.queryAll()

View File

@ -40,6 +40,9 @@ data class CategoryConfig(
@Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId")
suspend fun deleteBySubsItemId(subsItemId: Long): Int
@Query("DELETE FROM category_config WHERE subs_item_id IN (:subsIds)")
suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId AND category_key=:categoryKey")
suspend fun deleteByCategoryKey(subsItemId: Long, categoryKey: Int): Int

View File

@ -58,8 +58,8 @@ data class ClickLog(
suspend fun delete(vararg objects: ClickLog): Int
@Query("DELETE FROM click_log WHERE subs_id=:subsItemId")
suspend fun deleteBySubsId(subsItemId: Long): Int
@Query("DELETE FROM click_log WHERE subs_id IN (:subsIds)")
suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM click_log")
suspend fun deleteAll()

View File

@ -53,6 +53,9 @@ data class SubsConfig(
@Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId")
suspend fun delete(subsItemId: Long): Int
@Query("DELETE FROM subs_config WHERE subs_item_id IN (:subsIds)")
suspend fun deleteBySubsId(vararg subsIds: Long): Int
@Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId AND app_id=:appId")
suspend fun delete(subsItemId: Long, appId: String): Int

View File

@ -10,11 +10,16 @@ import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import li.songe.gkd.appScope
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.deleteSubscription
import li.songe.gkd.util.isSafeUrl
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.subsFolder
import li.songe.gkd.util.subsIdToRawFlow
@Serializable
@Entity(
@ -50,14 +55,6 @@ data class SubsItem(
}
}
suspend fun removeAssets() {
deleteSubscription(id)
DbSet.subsItemDao.delete(this)
DbSet.subsConfigDao.delete(id)
DbSet.clickLogDao.deleteBySubsId(id)
DbSet.categoryConfigDao.deleteBySubsItemId(id)
}
@Dao
interface SubsItemDao {
@Update
@ -94,8 +91,28 @@ data class SubsItem(
@Query("SELECT * FROM subs_item ORDER BY `order`")
fun queryAll(): List<SubsItem>
@Query("SELECT * FROM subs_item WHERE id=:id")
fun queryById(id: Long): Flow<SubsItem?>
@Query("DELETE FROM subs_item WHERE id IN (:ids)")
suspend fun deleteById(vararg ids: Long)
}
}
fun deleteSubscription(vararg subsIds: Long) {
appScope.launchTry(Dispatchers.IO) {
DbSet.subsItemDao.deleteById(*subsIds)
DbSet.subsConfigDao.deleteBySubsId(*subsIds)
DbSet.clickLogDao.deleteBySubsId(*subsIds)
DbSet.categoryConfigDao.deleteBySubsId(*subsIds)
val newMap = subsIdToRawFlow.value.toMutableMap()
subsIds.forEach { id ->
newMap.remove(id)
subsFolder.resolve("$id.json").apply {
if (exists()) {
delete()
}
}
}
subsIdToRawFlow.value = newMap.toImmutableMap()
}
}

View File

@ -2,6 +2,7 @@ package li.songe.gkd.data
import kotlinx.serialization.Serializable
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.LOCAL_SUBS_IDS
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.updateSubscription
@ -21,30 +22,30 @@ data class TransferData(
}
}
suspend fun exportTransferData(subsItemIds: List<Long>): TransferData {
suspend fun exportTransferData(subsItemIds: Collection<Long>): TransferData {
return TransferData(
subsItems = subsItemsFlow.value.filter { subsItemIds.contains(it.id) },
subscriptions = subsIdToRawFlow.value.values.filter { it.id < 0 && subsItemIds.contains(it.id) },
subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds),
categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds),
subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds.toList()),
categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds.toList()),
)
}
suspend fun importTransferData(transferData: TransferData): Boolean {
// TODO transaction
val localIds = arrayOf(-1L, -2L)
val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1
val subsItems = transferData.subsItems.filter { s -> s.id >= 0 || localIds.contains(s.id) }
.mapIndexed { i, s ->
s.copy(order = maxOrder + i)
}
val subsItems =
transferData.subsItems.filter { s -> s.id >= 0 || LOCAL_SUBS_IDS.contains(s.id) }
.mapIndexed { i, s ->
s.copy(order = maxOrder + i)
}
val hasNewSubsItem =
subsItems.any { newSubs -> newSubs.id >= 0 && subsItemsFlow.value.all { oldSubs -> oldSubs.id != newSubs.id } }
DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray())
DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray())
DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray())
transferData.subscriptions.forEach { subscription ->
if (localIds.contains(subscription.id)) {
if (LOCAL_SUBS_IDS.contains(subscription.id)) {
updateSubscription(subscription)
}
}

View File

@ -35,12 +35,14 @@ import li.songe.gkd.data.GkdAction
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.httpChannel
import li.songe.gkd.notif.httpNotif
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID
import li.songe.gkd.util.SERVER_SCRIPT_URL
import li.songe.gkd.util.getIpAddressInLocalNetwork
import li.songe.gkd.util.keepNullJson
@ -58,7 +60,7 @@ class HttpService : CompositionService({
val scope = CoroutineScope(Dispatchers.IO)
val httpSubsItem = SubsItem(
id = -1L,
id = LOCAL_HTTP_SUBS_ID,
order = -1,
enableUpdate = false,
)
@ -101,7 +103,7 @@ class HttpService : CompositionService({
val subscription =
RawSubscription.parse(call.receiveText(), json5 = false)
.copy(
id = -1,
id = LOCAL_HTTP_SUBS_ID,
name = "内存订阅",
version = 0,
author = "@gkd-kit/inspect"
@ -155,7 +157,7 @@ class HttpService : CompositionService({
scope.launchTry(Dispatchers.IO) {
server?.stop()
if (storeFlow.value.autoClearMemorySubs) {
httpSubsItem.removeAssets()
deleteSubscription(LOCAL_HTTP_SUBS_ID)
}
delay(3000)
scope.cancel()
@ -194,11 +196,7 @@ fun clearHttpSubs() {
appScope.launchTry(Dispatchers.IO) {
delay(1000)
if (storeFlow.value.autoClearMemorySubs) {
SubsItem(
id = -1L,
order = -1,
enableUpdate = false,
).removeAssets()
deleteSubscription(LOCAL_HTTP_SUBS_ID)
}
}
}

View File

@ -58,6 +58,7 @@ import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.style.itemPadding
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.RuleSortOption
@ -150,7 +151,7 @@ fun AppConfigPage(appId: String) {
floatingActionButton = {
FloatingActionButton(
onClick = {
navController.navigate(AppItemPageDestination(-2, appId))
navController.navigate(AppItemPageDestination(LOCAL_SUBS_ID, appId))
},
content = {
Icon(

View File

@ -1,6 +1,8 @@
package li.songe.gkd.ui.component
import android.content.Context
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalContentColor
@ -36,10 +39,12 @@ import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.exportTransferData
import li.songe.gkd.ui.destinations.CategoryPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.exportZipDir
import li.songe.gkd.util.formatTimeAgo
@ -63,7 +68,10 @@ fun SubsItemCard(
subscription: RawSubscription?,
index: Int,
vm: ViewModel,
isSelectedMode: Boolean,
isSelected: Boolean,
onCheckedChange: ((Boolean) -> Unit)? = null,
onSelectedChange: (() -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
val subsLoadError by remember(subsItem.id) {
@ -74,15 +82,28 @@ fun SubsItemCard(
}.collectAsState()
val subsRefreshing by subsRefreshingFlow.collectAsState()
var expanded by remember { mutableStateOf(false) }
Card(
onClick = {
if (!subsRefreshingFlow.value) {
val dragged by interactionSource.collectIsDraggedAsState()
val onClick = {
if (!dragged) {
if (isSelectedMode) {
onSelectedChange?.invoke()
} else if (!subsRefreshingFlow.value) {
expanded = true
}
},
}
}
Card(
onClick = onClick,
modifier = modifier.padding(16.dp, 2.dp),
shape = MaterialTheme.shapes.small,
interactionSource = interactionSource,
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Unspecified
}
),
) {
SubsMenuItem(
expanded = expanded,
@ -165,7 +186,8 @@ fun SubsItemCard(
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = subsItem.enable,
onCheckedChange = onCheckedChange,
enabled = !isSelectedMode,
onCheckedChange = if (isSelectedMode) null else onCheckedChange,
)
}
}
@ -222,28 +244,12 @@ private fun SubsMenuItem(
}
DropdownMenuItem(
text = {
Text(text = "导出数据")
Text(text = "分享数据")
},
onClick = {
onExpandedChange(false)
vm.viewModelScope.launchTry(Dispatchers.IO) {
val transferDataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
transferDataFile.writeText(
json.encodeToString(
exportTransferData(
listOf(
subItem.id
)
)
)
)
val file = exportZipDir.resolve("backup-${subItem.id}.zip")
if (file.exists()) {
file.delete()
}
ZipUtils.zipFiles(listOf(transferDataFile), file)
transferDataFile.delete()
context.shareFile(file, "分享数据文件")
context.shareSubs(subItem.id)
}
}
)
@ -270,7 +276,7 @@ private fun SubsMenuItem(
}
)
}
if (subItem.id != -2L) {
if (subItem.id != LOCAL_SUBS_ID) {
DropdownMenuItem(
text = {
Text(text = "删除订阅", color = MaterialTheme.colorScheme.error)
@ -283,10 +289,21 @@ private fun SubsMenuItem(
"是否删除订阅 ${subscription?.name ?: subItem.id} ?",
)
if (!result) return@launchTry
subItem.removeAssets()
deleteSubscription(subItem.id)
}
}
)
}
}
}
suspend fun Context.shareSubs(vararg subsIds: Long) {
val transferDataFile = exportZipDir.resolve("${TransferData.TYPE}.json")
transferDataFile.writeText(
json.encodeToString(exportTransferData(subsIds.toList()))
)
val file = exportZipDir.resolve("backup-${System.currentTimeMillis()}.zip")
ZipUtils.zipFiles(listOf(transferDataFile), file)
transferDataFile.delete()
this.shareFile(file, "分享数据文件")
}

View File

@ -2,6 +2,7 @@ package li.songe.gkd.ui.home
import android.content.Intent
import android.webkit.URLUtil
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -18,7 +19,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -44,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
@ -54,10 +59,14 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import li.songe.gkd.data.TransferData
import li.songe.gkd.data.Value
import li.songe.gkd.data.deleteSubscription
import li.songe.gkd.data.importTransferData
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.ui.component.getDialogResult
import li.songe.gkd.ui.component.shareSubs
import li.songe.gkd.util.LOCAL_SUBS_ID
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.checkSubsUpdate
import li.songe.gkd.util.isSafeUrl
@ -79,6 +88,7 @@ val subsNav = BottomNavItem(
@Composable
fun useSubsManagePage(): ScaffoldExt {
val launcher = LocalLauncher.current
val context = LocalContext.current
val vm = hiltViewModel<HomeVm>()
val subItems by subsItemsFlow.collectAsState()
@ -96,6 +106,24 @@ fun useSubsManagePage(): ScaffoldExt {
val refreshing by subsRefreshingFlow.collectAsState()
val pullRefreshState = rememberPullRefreshState(refreshing, { checkSubsUpdate(true) })
var isSelectedMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf(emptySet<Long>()) }
val draggedFlag = remember { Value(false) }
LaunchedEffect(key1 = isSelectedMode) {
if (!isSelectedMode && selectedIds.isNotEmpty()) {
selectedIds = emptySet()
}
}
if (isSelectedMode) {
BackHandler {
isSelectedMode = false
}
}
LaunchedEffect(key1 = subItems.size) {
if (subItems.size <= 1) {
isSelectedMode = false
}
}
LaunchedEffect(showAddLinkDialog) {
if (!showAddLinkDialog) {
@ -119,53 +147,92 @@ fun useSubsManagePage(): ScaffoldExt {
isError = link.isNotEmpty() && !URLUtil.isNetworkUrl(link),
)
}, onDismissRequest = { showAddLinkDialog = false }, confirmButton = {
TextButton(
enabled = link.isNotBlank(),
onClick = {
if (!URLUtil.isNetworkUrl(link)) {
toast("非法链接")
return@TextButton
TextButton(enabled = link.isNotBlank(), onClick = {
if (!URLUtil.isNetworkUrl(link)) {
toast("非法链接")
return@TextButton
}
if (subItems.any { s -> s.updateUrl == link }) {
toast("链接已存在")
return@TextButton
}
vm.viewModelScope.launchTry {
if (!isSafeUrl(link)) {
val result = getDialogResult(
"未知来源",
"你正在添加一个未验证的远程订阅\n\n这可能含有恶意的规则\n\n是否仍然确认添加?"
)
if (!result) return@launchTry
}
if (subItems.any { s -> s.updateUrl == link }) {
toast("链接已存在")
return@TextButton
}
vm.viewModelScope.launchTry {
if (!isSafeUrl(link)) {
val result = getDialogResult(
"未知来源",
"你正在添加一个未验证的远程订阅\n\n这可能含有恶意的规则\n\n是否仍然确认添加?"
)
if (!result) return@launchTry
}
showAddLinkDialog = false
vm.addSubsFromUrl(url = link)
}
}) {
showAddLinkDialog = false
vm.addSubsFromUrl(url = link)
}
}) {
Text(text = "添加")
}
})
}
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
return ScaffoldExt(
navItem = subsNav,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
scrollBehavior = scrollBehavior,
title = {
TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = {
if (isSelectedMode) {
IconButton(onClick = { isSelectedMode = false }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
)
}
}
}, title = {
if (isSelectedMode) {
Text(
text = if (selectedIds.isNotEmpty()) selectedIds.size.toString() else "",
)
} else {
Text(
text = subsNav.label,
)
},
actions = {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = {
if (subsRefreshingFlow.value) {
toast("正在刷新订阅,请稍后操作")
return@IconButton
}
}, actions = {
var expanded by remember { mutableStateOf(false) }
if (isSelectedMode) {
val canDeleteIds = if (selectedIds.contains(LOCAL_SUBS_ID)) {
selectedIds - LOCAL_SUBS_ID
} else {
selectedIds
}
if (canDeleteIds.isNotEmpty()) {
IconButton(onClick = vm.viewModelScope.launchAsFn {
val result = getDialogResult(
"删除订阅",
"是否删除所选 ${canDeleteIds.size} 个订阅?\n\n注: 不包含本地订阅",
)
if (!result) return@launchAsFn
deleteSubscription(*canDeleteIds.toLongArray())
selectedIds = selectedIds - canDeleteIds
if (selectedIds.size == canDeleteIds.size) {
isSelectedMode = false
}
}) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null,
)
}
}
IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) {
context.shareSubs(*selectedIds.toLongArray())
}) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
)
}
IconButton(onClick = {
expanded = true
}) {
Icon(
@ -173,14 +240,49 @@ fun useSubsManagePage(): ScaffoldExt {
contentDescription = null,
)
}
Box(
modifier = Modifier
.wrapContentSize(Alignment.TopStart)
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
} else {
IconButton(onClick = {
if (subsRefreshingFlow.value) {
toast("正在刷新订阅,请稍后操作")
} else {
expanded = true
}
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
}
Box(
modifier = Modifier.wrapContentSize(Alignment.TopStart)
) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (isSelectedMode) {
DropdownMenuItem(
text = {
Text(text = "全选")
},
onClick = {
expanded = false
selectedIds = subItems.map { it.id }.toSet()
}
)
DropdownMenuItem(
text = {
Text(text = "反选")
},
onClick = {
expanded = false
val newSelectedIds =
subItems.map { it.id }.toSet() - selectedIds
if (newSelectedIds.isEmpty()) {
isSelectedMode = false
}
selectedIds = newSelectedIds
}
)
} else {
DropdownMenuItem(
leadingIcon = {
Icon(
@ -204,8 +306,7 @@ fun useSubsManagePage(): ScaffoldExt {
return@launchAsFn
}
val string = readFileZipByteArray(
UriUtils.uri2Bytes(uri),
"${TransferData.TYPE}.json"
UriUtils.uri2Bytes(uri), "${TransferData.TYPE}.json"
)
if (string != null) {
val transferData =
@ -220,20 +321,22 @@ fun useSubsManagePage(): ScaffoldExt {
}
}
}
)
})
},
floatingActionButton = {
FloatingActionButton(onClick = {
if (subsRefreshingFlow.value) {
toast("正在刷新订阅,请稍后操作")
return@FloatingActionButton
if (!isSelectedMode) {
FloatingActionButton(onClick = {
if (subsRefreshingFlow.value) {
toast("正在刷新订阅,请稍后操作")
return@FloatingActionButton
}
showAddLinkDialog = true
}) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "info",
)
}
showAddLinkDialog = true
}) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "info",
)
}
},
) { padding ->
@ -248,6 +351,7 @@ fun useSubsManagePage(): ScaffoldExt {
}
}
}.toImmutableList()
draggedFlag.value = true
}
Box(
modifier = Modifier
@ -261,38 +365,64 @@ fun useSubsManagePage(): ScaffoldExt {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem ->
val canDrag = !refreshing && orderSubItems.size > 1
ReorderableItem(
reorderableLazyColumnState,
key = subItem.id,
enabled = !refreshing,
enabled = canDrag,
) {
val interactionSource = remember { MutableInteractionSource() }
SubsItemCard(
modifier = Modifier
.longPressDraggableHandle(
enabled = !refreshing,
interactionSource = interactionSource,
onDragStopped = {
val changeItems = orderSubItems.filter { newItem ->
subItems.find { oldItem -> oldItem.id == newItem.id }?.order != newItem.order
modifier = Modifier.longPressDraggableHandle(
enabled = canDrag,
interactionSource = interactionSource,
onDragStarted = {
if (orderSubItems.size > 1 && !isSelectedMode) {
isSelectedMode = true
selectedIds = setOf(subItem.id)
}
},
onDragStopped = {
if (draggedFlag.value) {
draggedFlag.value = false
isSelectedMode = false
selectedIds = emptySet()
}
val changeItems = orderSubItems.filter { newItem ->
subItems.find { oldItem -> oldItem.id == newItem.id }?.order != newItem.order
}
if (changeItems.isNotEmpty()) {
vm.viewModelScope.launchTry {
DbSet.subsItemDao.batchUpdateOrder(changeItems)
}
if (changeItems.isNotEmpty()) {
vm.viewModelScope.launchTry {
DbSet.subsItemDao.batchUpdateOrder(changeItems)
}
}
},
),
}
},
),
interactionSource = interactionSource,
subsItem = subItem,
subscription = subsIdToRaw[subItem.id],
index = index + 1,
vm = vm,
isSelectedMode = isSelectedMode,
isSelected = selectedIds.contains(subItem.id),
onCheckedChange = { checked ->
vm.viewModelScope.launch {
DbSet.subsItemDao.updateEnable(subItem.id, checked)
}
},
onSelectedChange = {
val newSelectedIds = if (selectedIds.contains(subItem.id)) {
selectedIds.toMutableSet().apply {
remove(subItem.id)
}
} else {
selectedIds + subItem.id
}
selectedIds = newSelectedIds
if (newSelectedIds.isEmpty()) {
isSelectedMode = false
}
},
)
}
}

View File

@ -38,4 +38,8 @@ private val safeRemoteBaseUrls = arrayOf(
fun isSafeUrl(url: String): Boolean {
if (!URLUtil.isHttpsUrl(url)) return false
return safeRemoteBaseUrls.any { u -> url.startsWith(u) }
}
}
const val LOCAL_SUBS_ID = -2L
const val LOCAL_HTTP_SUBS_ID = -1L
val LOCAL_SUBS_IDS = arrayOf(LOCAL_SUBS_ID, LOCAL_HTTP_SUBS_ID)

View File

@ -102,17 +102,6 @@ fun updateSubscription(subscription: RawSubscription) {
}
}
fun deleteSubscription(subsId: Long) {
val newMap = subsIdToRawFlow.value.toMutableMap()
newMap.remove(subsId)
subsIdToRawFlow.value = newMap.toImmutableMap()
subsFolder.resolve("$subsId.json").apply {
if (exists()) {
delete()
}
}
}
fun getGroupRawEnable(
group: RawSubscription.RawGroupProps,
subsConfig: SubsConfig?,