mirror of
https://github.com/gkd-kit/gkd.git
synced 2024-11-16 03:32:38 +08:00
feat: support multi-select
This commit is contained in:
parent
9f0f026459
commit
f34b1ca1ea
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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, "分享数据文件")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?,
|
||||
|
|
Loading…
Reference in New Issue
Block a user