From e740ac09cd0dadb704c3c8db2b52aa06a919bdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 24 Jan 2025 12:08:04 +0800 Subject: [PATCH] perf: SubsCategoryPage --- .../kotlin/li/songe/gkd/ui/CategoryPage.kt | 385 ----------------- .../main/kotlin/li/songe/gkd/ui/CategoryVm.kt | 24 -- .../li/songe/gkd/ui/SubsCategoryPage.kt | 397 ++++++++++++++++++ .../kotlin/li/songe/gkd/ui/SubsCategoryVm.kt | 29 ++ .../gkd/ui/component/InputSubsLinkOption.kt | 11 + .../songe/gkd/ui/component/RuleGroupCard.kt | 2 +- .../li/songe/gkd/ui/component/SubsSheet.kt | 35 +- .../li/songe/gkd/ui/home/SubsManagePage.kt | 6 +- .../main/kotlin/li/songe/gkd/util/Option.kt | 14 +- .../kotlin/li/songe/gkd/util/SubsState.kt | 38 +- .../main/kotlin/li/songe/gkd/util/TimeExt.kt | 22 +- 11 files changed, 502 insertions(+), 461 deletions(-) delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/CategoryVm.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt deleted file mode 100644 index 54bb5733b..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt +++ /dev/null @@ -1,385 +0,0 @@ -package li.songe.gkd.ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import kotlinx.coroutines.Dispatchers -import li.songe.gkd.MainActivity -import li.songe.gkd.data.CategoryConfig -import li.songe.gkd.data.RawSubscription -import li.songe.gkd.db.DbSet -import li.songe.gkd.ui.component.EmptyText -import li.songe.gkd.ui.component.TowLineText -import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.style.EmptyHeight -import li.songe.gkd.ui.style.itemPadding -import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.EnableGroupOption -import li.songe.gkd.util.LocalNavController -import li.songe.gkd.util.ProfileTransitions -import li.songe.gkd.util.findOption -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast -import li.songe.gkd.util.updateSubscription - -@Destination(style = ProfileTransitions::class) -@Composable -fun CategoryPage(subsItemId: Long) { - val context = LocalContext.current as MainActivity - val navController = LocalNavController.current - - val vm = viewModel() - val subsItem by vm.subsItemFlow.collectAsState() - val subsRaw by vm.subsRawFlow.collectAsState() - val categoryConfigs by vm.categoryConfigsFlow.collectAsState() - val editable = subsItem != null && subsItemId < 0 - - var showAddDlg by remember { - mutableStateOf(false) - } - val (editNameCategory, setEditNameCategory) = remember { - mutableStateOf(null) - } - - val categories = subsRaw?.categories ?: emptyList() - val categoriesGroups = subsRaw?.categoryToGroupsMap ?: emptyMap() - val categoriesApps = subsRaw?.categoryToAppMap ?: emptyMap() - - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, title = { - TowLineText( - title = subsRaw?.name ?: subsItemId.toString(), - subtitle = "规则类别" - ) - }, actions = { - IconButton(onClick = throttle { - context.mainVm.dialogFlow.updateDialogOptions( - title = "开关优先级", - text = "规则手动配置 > 分类手动配置 > 分类默认 > 规则默认\n\n重置开关: 移除规则手动配置", - ) - }) { - Icon(Icons.Outlined.Info, contentDescription = null) - } - }) - }, floatingActionButton = { - if (editable) { - FloatingActionButton(onClick = { showAddDlg = true }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "add", - ) - } - } - }) { contentPadding -> - LazyColumn( - modifier = Modifier.scaffoldPadding(contentPadding) - ) { - items(categories, { it.key }) { category -> - var selectedExpanded by remember { mutableStateOf(false) } - Row(modifier = Modifier - .clickable { selectedExpanded = true } - .itemPadding(), - verticalAlignment = Alignment.CenterVertically - ) { - - val groups = categoriesGroups[category] ?: emptyList() - val size = groups.size - Column(modifier = Modifier.weight(1f)) { - Text( - text = category.name, - style = MaterialTheme.typography.bodyLarge, - ) - if (size > 0) { - val appSize = categoriesApps[category]?.size ?: 0 - Text( - text = "${appSize}应用/${size}规则组", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = "暂无规则", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - } - var expanded by remember { mutableStateOf(false) } - Box( - modifier = Modifier.wrapContentSize(Alignment.TopStart) - ) { - IconButton(onClick = { - expanded = true - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem(text = { - Text(text = "重置开关") - }, onClick = { - expanded = false - vm.viewModelScope.launchTry(Dispatchers.IO) { - val updatedList = DbSet.subsConfigDao.batchResetAppGroupEnable( - subsItemId, - groups - ) - if (updatedList.isNotEmpty()) { - toast("成功重置 ${updatedList.size} 规则组开关") - } else { - toast("无可重置规则组") - } - } - }) - if (editable) { - DropdownMenuItem(text = { - Text(text = "编辑") - }, onClick = { - expanded = false - setEditNameCategory(category) - }) - DropdownMenuItem(text = { - Text(text = "删除", color = MaterialTheme.colorScheme.error) - }, onClick = { - expanded = false - vm.viewModelScope.launchTry { - context.mainVm.dialogFlow.waitResult( - title = "删除类别", - text = "确定删除 ${category.name} ?", - error = true, - ) - subsItem?.apply { - updateSubscription(subsRaw!!.copy(categories = subsRaw!!.categories.filter { c -> c.key != category.key })) - DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis())) - } - DbSet.categoryConfigDao.deleteByCategoryKey( - subsItemId, category.key - ) - toast("删除成功") - } - }) - } - } - - Spacer(modifier = Modifier.width(10.dp)) - } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - val categoryConfig = - categoryConfigs.find { c -> c.categoryKey == category.key } - val enable = - if (categoryConfig != null) categoryConfig.enable else category.enable - Text( - text = EnableGroupOption.allSubObject.findOption(enable).label, - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - imageVector = Icons.Default.UnfoldMore, contentDescription = null - ) - DropdownMenu(expanded = selectedExpanded, - onDismissRequest = { selectedExpanded = false }) { - EnableGroupOption.allSubObject.forEach { option -> - DropdownMenuItem( - text = { - Text(text = option.label) - }, - onClick = { - selectedExpanded = false - if (option.value != enable) { - vm.viewModelScope.launchTry(Dispatchers.IO) { - DbSet.categoryConfigDao.insert( - (categoryConfig ?: CategoryConfig( - enable = option.value, - subsItemId = subsItemId, - categoryKey = category.key - )).copy(enable = option.value) - ) - } - } - }, - ) - } - } - } - } - } - item { - Spacer(modifier = Modifier.height(EmptyHeight)) - if (categories.isEmpty()) { - EmptyText(text = "暂无类别") - } else if (editable) { - Spacer(modifier = Modifier.height(EmptyHeight)) - } - } - } - } - - val subsRawVal = subsRaw - if (editNameCategory != null && subsRawVal != null) { - var source by remember { - mutableStateOf(editNameCategory.name) - } - val inputFocused = rememberSaveable { mutableStateOf(false) } - AlertDialog(title = { Text(text = "编辑类别") }, text = { - OutlinedTextField( - value = source, - onValueChange = { source = it.trim() }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) { - inputFocused.value = true - } - }, - placeholder = { Text(text = "请输入类别名称") }, - singleLine = true - ) - }, onDismissRequest = { - if (!inputFocused.value) { - setEditNameCategory(null) - } - }, dismissButton = { - TextButton(onClick = { setEditNameCategory(null) }) { - Text(text = "取消") - } - }, confirmButton = { - TextButton(enabled = source.isNotBlank() && source != editNameCategory.name, onClick = { - if (categories.any { c -> c.key != editNameCategory.key && c.name == source }) { - toast("不可添加同名类别") - return@TextButton - } - vm.viewModelScope.launchTry(Dispatchers.IO) { - subsItem?.apply { - updateSubscription( - subsRawVal.copy(categories = categories.toMutableList().apply { - val i = - categories.indexOfFirst { c -> c.key == editNameCategory.key } - if (i >= 0) { - set(i, editNameCategory.copy(name = source)) - } - }) - ) - DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis())) - } - toast("修改成功") - setEditNameCategory(null) - } - }) { - Text(text = "确认") - } - }) - } - if (showAddDlg && subsRawVal != null) { - var source by remember { - mutableStateOf("") - } - val inputFocused = rememberSaveable { mutableStateOf(false) } - AlertDialog(title = { Text(text = "添加类别") }, text = { - OutlinedTextField( - value = source, - onValueChange = { source = it.trim() }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) { - inputFocused.value = true - } - }, - placeholder = { Text(text = "请输入类别名称") }, - singleLine = true - ) - }, onDismissRequest = { - if (!inputFocused.value) { - showAddDlg = false - } - }, dismissButton = { - TextButton(onClick = { showAddDlg = false }) { - Text(text = "取消") - } - }, confirmButton = { - TextButton(enabled = source.isNotEmpty(), onClick = { - if (categories.any { c -> c.name == source }) { - toast("不可添加同名类别") - return@TextButton - } - showAddDlg = false - vm.viewModelScope.launchTry(Dispatchers.IO) { - subsItem?.apply { - updateSubscription( - subsRawVal.copy(categories = categories.toMutableList().apply { - add(RawSubscription.RawCategory(key = (categories.maxOfOrNull { c -> c.key } - ?: -1) + 1, name = source, enable = null)) - }) - ) - DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis())) - toast("添加成功") - } - } - }) { - Text(text = "确认") - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CategoryVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/CategoryVm.kt deleted file mode 100644 index 4de27089e..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/CategoryVm.kt +++ /dev/null @@ -1,24 +0,0 @@ -package li.songe.gkd.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.destinations.CategoryPageDestination -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import li.songe.gkd.db.DbSet -import li.songe.gkd.util.map -import li.songe.gkd.util.subsIdToRawFlow -import li.songe.gkd.util.subsItemsFlow - -class CategoryVm (stateHandle: SavedStateHandle) : ViewModel() { - private val args = CategoryPageDestination.argsFrom(stateHandle) - - val subsItemFlow = - subsItemsFlow.map(viewModelScope) { subsItems -> subsItems.find { s -> s.id == args.subsItemId } } - - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { m -> m[args.subsItemId] } - - val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt new file mode 100644 index 000000000..1c785cad7 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -0,0 +1,397 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import li.songe.gkd.MainActivity +import li.songe.gkd.appScope +import li.songe.gkd.data.CategoryConfig +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.component.CardFlagBar +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.TowLineText +import li.songe.gkd.ui.component.updateDialogOptions +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.icon.ResetSettings +import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.itemFlagPadding +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.EnableGroupOption +import li.songe.gkd.util.LocalNavController +import li.songe.gkd.util.ProfileTransitions +import li.songe.gkd.util.findOption +import li.songe.gkd.util.getCategoryEnable +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toToggleableState +import li.songe.gkd.util.toast +import li.songe.gkd.util.updateSubscription + +@Destination(style = ProfileTransitions::class) +@Composable +fun SubsCategoryPage(subsItemId: Long) { + val context = LocalActivity.current as MainActivity + val navController = LocalNavController.current + + val vm = viewModel() + val subs = vm.subsRawFlow.collectAsState().value + val categoryConfigMap = vm.categoryConfigMapFlow.collectAsState().value + + val categories = subs?.categories ?: emptyList() + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, title = { + TowLineText( + title = subs?.name ?: subsItemId.toString(), + subtitle = "规则类别" + ) + }, actions = { + IconButton(onClick = throttle { + context.mainVm.dialogFlow.updateDialogOptions( + title = "类别说明", + text = arrayOf( + "类别会捕获以当前类别开头的所有应用规则组, 因此可调整类别开关(分类手动配置)来批量开关规则组", + "规则组开关优先级为:\n规则手动配置 > 分类手动配置 > 分类默认 > 规则默认", + "因此如果手动开关了规则组(规则手动配置), 则该规则组不会被批量开关, 可通过点击类别-重置开关, 来移除类别下所有规则手动配置", + ).joinToString("\n\n"), + ) + }) { + Icon(Icons.Outlined.Info, contentDescription = null) + } + }) + }, floatingActionButton = { + if (subs != null && subs.isLocal) { + FloatingActionButton(onClick = { vm.showAddCategoryFlow.value = true }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "add", + ) + } + } + }) { contentPadding -> + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding) + ) { + items(categories, { it.key }) { category -> + CategoryItemCard( + vm = vm, + subs = subs!!, + category = category, + categoryConfig = categoryConfigMap[category.key], + ) + } + item { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (categories.isEmpty()) { + EmptyText(text = "暂无类别") + } else if (subs != null && subs.isLocal) { + Spacer(modifier = Modifier.height(EmptyHeight)) + } + } + } + } + + val editCategory by vm.editCategoryFlow.collectAsState() + if (subs != null && editCategory != null) { + AddOrEditCategoryDialog( + subs = subs, + category = editCategory, + ) { + vm.editCategoryFlow.value = null + } + } + val showAddCategory by vm.showAddCategoryFlow.collectAsState() + if (subs != null && showAddCategory) { + AddOrEditCategoryDialog( + subs = subs, + category = null, + ) { + vm.showAddCategoryFlow.value = false + } + } +} + +@Composable +private fun CategoryItemCard( + vm: SubsCategoryVm, + subs: RawSubscription, + category: RawSubscription.RawCategory, + categoryConfig: CategoryConfig?, +) { + val groups = subs.categoryToGroupsMap[category] ?: emptyList() + var expanded by remember { mutableStateOf(false) } + val onClick = { + if (groups.isNotEmpty() || subs.isLocal) { + expanded = true + } + } + Row( + modifier = Modifier + .clickable(onClick = onClick) + .itemFlagPadding(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = category.name, + style = MaterialTheme.typography.bodyLarge, + ) + if (groups.isNotEmpty()) { + val appSize = subs.categoryToAppMap[category]?.size ?: 0 + Text( + text = "${appSize}应用/${groups.size}规则组", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "暂无规则", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + CategoryMenu( + vm = vm, + subs = subs, + category = category, + expanded = expanded, + onCheckedChange = { expanded = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + val enable = getCategoryEnable(category, categoryConfig) + TriStateCheckbox( + state = EnableGroupOption.allSubObject.findOption(enable).toToggleableState(), + onClick = throttle(appScope.launchAsFn { + val option = when (enable) { + false -> EnableGroupOption.FollowSubs + null -> EnableGroupOption.AllEnable + true -> EnableGroupOption.AllDisable + } + DbSet.categoryConfigDao.insert( + (categoryConfig ?: CategoryConfig( + enable = option.value, + subsItemId = subs.id, + categoryKey = category.key + )).copy(enable = option.value) + ) + toast(option.label) + }) + ) + CardFlagBar(visible = enable != null) + } +} + +@Composable +private fun CategoryMenu( + vm: SubsCategoryVm, + subs: RawSubscription, + category: RawSubscription.RawCategory, + expanded: Boolean, + onCheckedChange: ((Boolean) -> Unit), +) { + val context = LocalActivity.current as MainActivity + val groups = subs.categoryToGroupsMap[category] ?: emptyList() + Box( + modifier = Modifier.wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { onCheckedChange(false) } + ) { + if (groups.isNotEmpty()) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = ResetSettings, + contentDescription = null + ) + }, + text = { Text(text = "重置开关") }, + onClick = throttle(vm.viewModelScope.launchAsFn { + onCheckedChange(false) + val updatedList = DbSet.subsConfigDao.batchResetAppGroupEnable( + subs.id, + subs.categoryToGroupsMap[category] ?: emptyList(), + ) + if (updatedList.isNotEmpty()) { + toast("成功重置 ${updatedList.size} 规则组开关") + } else { + toast("无可重置规则组") + } + }) + ) + } + if (subs.isLocal) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null + ) + }, + text = { Text(text = "编辑") }, + onClick = { + onCheckedChange(false) + vm.editCategoryFlow.value = category + } + ) + DropdownMenuItem( + text = { Text(text = "删除", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = throttle(vm.viewModelScope.launchAsFn { + onCheckedChange(false) + context.mainVm.dialogFlow.waitResult( + title = "删除类别", + text = "确定删除 ${category.name} ?", + error = true, + ) + updateSubscription( + subs.copy(categories = subs.categories.toMutableList().apply { + removeIf { it.key == category.key } + }) + ) + DbSet.categoryConfigDao.deleteByCategoryKey( + subs.id, + category.key + ) + toast("删除成功") + }) + ) + } + } + } +} + +@Composable +private fun AddOrEditCategoryDialog( + subs: RawSubscription, + category: RawSubscription.RawCategory?, + onDismissRequest: () -> Unit, +) { + var value by remember { + mutableStateOf(category?.name ?: "") + } + val onClick = appScope.launchAsFn { + if (category != null) { + onDismissRequest() + updateSubscription( + subs.copy(categories = subs.categories.toMutableList().apply { + set( + indexOfFirst { c -> c.key == category.key }, + category.copy(name = value) + ) + }) + ) + toast("更新成功") + } else { + if (subs.categories.any { c -> c.name == value }) { + error("不可添加同名类别") + } + onDismissRequest() + updateSubscription( + subs.copy(categories = subs.categories.toMutableList().apply { + add(RawSubscription.RawCategory(key = (subs.categories.maxOfOrNull { c -> c.key } + ?: -1) + 1, name = value, enable = null)) + }) + ) + toast("添加成功") + } + } + val focusRequester = remember { FocusRequester() } + AlertDialog( + title = { Text(text = if (category == null) "添加类别" else "编辑类别") }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it.trim() }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text(text = "请输入类别名称") }, + singleLine = true + ) + LaunchedEffect(null) { + focusRequester.requestFocus() + } + }, + onDismissRequest = {}, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = "取消") + } + }, + confirmButton = { + TextButton( + enabled = value.isNotEmpty(), + onClick = throttle(onClick) + ) { + Text(text = "确认") + } + } + ) + +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt new file mode 100644 index 000000000..d1412c00c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt @@ -0,0 +1,29 @@ +package li.songe.gkd.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.SubsCategoryPageDestination +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.db.DbSet +import li.songe.gkd.util.map +import li.songe.gkd.util.subsIdToRawFlow + +class SubsCategoryVm(stateHandle: SavedStateHandle) : ViewModel() { + private val args = SubsCategoryPageDestination.argsFrom(stateHandle) + + val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { m -> m[args.subsItemId] } + + val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { it.categoryKey } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + + val editCategoryFlow = MutableStateFlow(null) + val showAddCategoryFlow = MutableStateFlow(false) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt index d91bbaee4..4e9519607 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt @@ -13,10 +13,14 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.data.Value @@ -101,6 +105,7 @@ class InputSubsLinkOption { } }, text = { + val focusRequester = remember { FocusRequester() } OutlinedTextField( value = value, onValueChange = { @@ -109,6 +114,7 @@ class InputSubsLinkOption { maxLines = 8, modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) { inputFocused.value = true @@ -119,6 +125,11 @@ class InputSubsLinkOption { }, isError = value.isNotEmpty() && !URLUtil.isNetworkUrl(value), ) + LaunchedEffect(null) { + if (initValue.isNotEmpty()) { + focusRequester.requestFocus() + } + } }, onDismissRequest = { if (!inputFocused.value) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index b4ca0615d..8e72d0778 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -221,7 +221,7 @@ fun CardFlagBar(visible: Boolean) { Row( modifier = Modifier .width(itemHorizontalPadding) - .height(16.dp), + .height(20.dp), horizontalArrangement = Arrangement.End, ) { AnimatedVisibility( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt index 94c55783b..824d4b8dc 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt @@ -44,10 +44,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.ActionLogPageDestination -import com.ramcosta.composedestinations.generated.destinations.CategoryPageDestination -import com.ramcosta.composedestinations.generated.destinations.CategoryPageDestination.invoke import com.ramcosta.composedestinations.generated.destinations.GlobalGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsAppListPageDestination +import com.ramcosta.composedestinations.generated.destinations.SubsCategoryPageDestination import com.ramcosta.composedestinations.utils.toDestinationsNavigator import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -60,6 +59,7 @@ import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.copyText +import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.subsIdToRawFlow @@ -280,7 +280,7 @@ fun SubsSheet( sheetSubsIdFlow.value = null navController .toDestinationsNavigator() - .navigate(CategoryPageDestination(subsItem.id)) + .navigate(SubsCategoryPageDestination(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, @@ -382,11 +382,14 @@ fun SubsSheet( modifier = childModifier, horizontalArrangement = Arrangement.End ) { - if (!subsItem.isLocal && subscription?.supportUri != null) { + if (!subsItem.isLocal && subscription?.supportUri != null) { IconButton(onClick = throttle { openUri(subscription.supportUri) }) { - Icon(imageVector = Icons.AutoMirrored.Outlined.HelpOutline, contentDescription = null) + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = null + ) } } IconButton(onClick = throttle { @@ -405,18 +408,16 @@ fun SubsSheet( } } if (subsItem.id != LOCAL_SUBS_ID) { - IconButton(onClick = throttle { - vm.viewModelScope.launchTry { - context.mainVm.dialogFlow.waitResult( - title = "删除订阅", - text = "确定删除 ${subscription?.name ?: subsItem.id} ?", - error = true, - ) - sheetSubsIdFlow.value = null - setSubsId(null) - deleteSubscription(subsItem.id) - } - }) { + IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { + context.mainVm.dialogFlow.waitResult( + title = "删除订阅", + text = "确定删除 ${subscription?.name ?: subsItem.id} ?", + error = true, + ) + sheetSubsIdFlow.value = null + setSubsId(null) + deleteSubscription(subsItem.id) + })) { Icon( imageVector = Icons.Outlined.Delete, contentDescription = null, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 4cc040aff..d370107f3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -153,10 +153,8 @@ fun useSubsManagePage(): ScaffoldExt { storeFlow.update { s -> s.copy(updateSubsInterval = it.value) } } - val updateValue = remember { - throttle(fn = { - storeFlow.update { it.copy(subsPowerWarn = !it.subsPowerWarn) } - }) + val updateValue = throttle { + storeFlow.update { it.copy(subsPowerWarn = !it.subsPowerWarn) } } Row( modifier = Modifier diff --git a/app/src/main/kotlin/li/songe/gkd/util/Option.kt b/app/src/main/kotlin/li/songe/gkd/util/Option.kt index 6c7ec96a5..bbcad4538 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Option.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Option.kt @@ -1,5 +1,7 @@ package li.songe.gkd.util +import androidx.compose.ui.state.ToggleableState + sealed interface Option { val value: T val label: String @@ -62,15 +64,21 @@ sealed class EnableGroupOption( override val value: Boolean?, override val label: String ) : Option { - data object FollowSubs : DarkThemeOption(null, "跟随订阅") - data object AllEnable : DarkThemeOption(true, "全部启用") - data object AllDisable : DarkThemeOption(false, "全部关闭") + data object FollowSubs : EnableGroupOption(null, "跟随订阅") + data object AllEnable : EnableGroupOption(true, "全部启用") + data object AllDisable : EnableGroupOption(false, "全部关闭") companion object { val allSubObject by lazy { arrayOf(FollowSubs, AllEnable, AllDisable) } } } +fun Option.toToggleableState() = when (value) { + true -> ToggleableState.On + false -> ToggleableState.Off + null -> ToggleableState.Indeterminate +} + sealed class RuleSortOption(override val value: Int, override val label: String) : Option { data object Default : RuleSortOption(0, "按订阅顺序") data object ByTime : RuleSortOption(1, "按触发时间") diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 026134db0..3ec75a8af 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -114,32 +114,30 @@ fun updateSubscription(subscription: RawSubscription) { } } +fun getCategoryEnable( + category: RawSubscription.RawCategory?, + categoryConfig: CategoryConfig?, +): Boolean? = if (categoryConfig != null) { + // 批量配置 + categoryConfig.enable +} else { + // 批量默认 + category?.enable +} + fun getGroupEnable( group: RawSubscription.RawGroupProps, subsConfig: SubsConfig?, category: RawSubscription.RawCategory? = null, categoryConfig: CategoryConfig? = null, -): Boolean { - return when (group) { - // 优先级: 规则用户配置 > 批量配置 > 批量默认 > 规则默认 - is RawSubscription.RawAppGroup -> { - subsConfig?.enable ?: if (category != null) {// 这个规则被批量配置捕获 - val enable = if (categoryConfig != null) { - // 2.批量配置 - categoryConfig.enable - } else { - // 3.批量默认 - category.enable - } - enable - } else { - null - } ?: group.enable ?: true - } +): Boolean = when (group) { + // 优先级: 规则用户配置 > 批量配置 > 批量默认 > 规则默认 + is RawSubscription.RawAppGroup -> { + subsConfig?.enable ?: getCategoryEnable(category, categoryConfig) ?: group.enable ?: true + } - is RawSubscription.RawGlobalGroup -> { - subsConfig?.enable ?: group.enable ?: true - } + is RawSubscription.RawGlobalGroup -> { + subsConfig?.enable ?: group.enable ?: true } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt index 77d4e5755..310bc09b4 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt @@ -1,5 +1,7 @@ package li.songe.gkd.util +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.TimeUnit @@ -37,7 +39,7 @@ fun Long.format(formatStr: String): String { return df.format(this) } -data class ThrottleTimer( +private data class ThrottleTimer( private val interval: Long = 500L, private var value: Long = 0L ) { @@ -53,22 +55,28 @@ data class ThrottleTimer( private val defaultThrottleTimer by lazy { ThrottleTimer() } +@Composable fun throttle( fn: (() -> Unit), ): (() -> Unit) { - return { - if (defaultThrottleTimer.expired()) { - fn.invoke() + return remember(fn) { + { + if (defaultThrottleTimer.expired()) { + fn.invoke() + } } } } +@Composable fun throttle( fn: ((T) -> Unit), ): ((T) -> Unit) { - return { - if (defaultThrottleTimer.expired()) { - fn.invoke(it) + return remember(fn) { + { + if (defaultThrottleTimer.expired()) { + fn.invoke(it) + } } } }