diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70aa773..34d75ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,7 +76,8 @@ dependencies { implementation(platform("androidx.compose:compose-bom:2023.10.01")) implementation("androidx.compose.animation:animation-graphics") implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.compose.material3:material3") + // Using 1.2.0-beta01 for PrimaryTabRow + implementation("androidx.compose.material3:material3:1.2.0-beta01") implementation("androidx.compose.material3:material3-window-size-class") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") diff --git a/app/src/main/java/me/zhanghai/android/untracker/ui/component/TopAppBarContainer.kt b/app/src/main/java/me/zhanghai/android/untracker/ui/component/TopAppBarContainer.kt new file mode 100644 index 0000000..36e70d2 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/untracker/ui/component/TopAppBarContainer.kt @@ -0,0 +1,38 @@ +package me.zhanghai.android.untracker.ui.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun TopAppBarContainer(scrollBehavior: TopAppBarScrollBehavior, content: @Composable () -> Unit) { + val heightOffsetLimit = with(LocalDensity.current) { -64.0.dp.toPx() } + SideEffect { + if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { + scrollBehavior.state.heightOffsetLimit = heightOffsetLimit + } + } + val targetColor = + if (scrollBehavior.state.overlappedFraction > 0.01f) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + MaterialTheme.colorScheme.surface + } + val color by + animateColorAsState( + targetValue = targetColor, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + Surface(color = color, content = content) +} diff --git a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleList.kt b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleList.kt index af66a4c..fb77afd 100644 --- a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleList.kt +++ b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleList.kt @@ -23,38 +23,27 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import me.zhanghai.android.untracker.R import me.zhanghai.android.untracker.model.Rule import me.zhanghai.android.untracker.model.RuleList -import me.zhanghai.compose.preference.PreferenceCategory import me.zhanghai.compose.preference.TwoTargetSwitchPreference @Composable fun RuleList( - builtinRuleList: RuleList, - onBuiltinRuleListChange: (RuleList) -> Unit, - customRuleList: RuleList, - onCustomRuleListChange: (RuleList) -> Unit, + ruleList: RuleList, + onRuleListChange: (RuleList) -> Unit, onRuleClick: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues() ) { LazyColumn(modifier = modifier, contentPadding = contentPadding) { - item(key = "BuiltinRuleCategory", contentType = RuleListContentType.CATEGORY) { - RuleCategory( - title = stringResource(R.string.main_rules_builtin), - modifier = Modifier.fillMaxWidth() - ) - } - items(builtinRuleList.rules, { it.id }, { RuleListContentType.ITEM }) { rule -> + items(ruleList.rules, { it.id }) { rule -> RuleItem( rule = rule, onRuleChange = { newRule -> - onBuiltinRuleListChange( - builtinRuleList.copy( + onRuleListChange( + ruleList.copy( rules = - builtinRuleList.rules.map { oldRule -> + ruleList.rules.map { oldRule -> if (oldRule.id == newRule.id) newRule else oldRule } ) @@ -64,44 +53,9 @@ fun RuleList( modifier = Modifier.fillMaxWidth() ) } - if (customRuleList.rules.isNotEmpty()) { - item(key = "CustomRuleCategory", contentType = RuleListContentType.CATEGORY) { - RuleCategory( - title = stringResource(R.string.main_rules_custom), - modifier = Modifier.fillMaxWidth() - ) - } - items(customRuleList.rules, { it.id }, { RuleListContentType.ITEM }) { rule -> - RuleItem( - rule = rule, - onRuleChange = { newRule -> - onCustomRuleListChange( - customRuleList.copy( - rules = - customRuleList.rules.map { oldRule -> - if (oldRule.id == newRule.id) newRule else oldRule - } - ) - ) - }, - onRuleClick = onRuleClick, - modifier = Modifier.fillMaxWidth() - ) - } - } } } -private enum class RuleListContentType { - CATEGORY, - ITEM -} - -@Composable -private fun RuleCategory(title: String, modifier: Modifier = Modifier) { - PreferenceCategory(title = { Text(text = title) }, modifier = modifier) -} - @Composable private fun RuleItem( rule: Rule, diff --git a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleScreen.kt b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleScreen.kt index d20aa56..a526a33 100644 --- a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleScreen.kt +++ b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RuleScreen.kt @@ -211,7 +211,7 @@ fun RuleScreen(ruleId: String, onPopBackStack: () -> Unit) { text = ruleListsStateful.throwable.toString(), modifier = Modifier.align(Alignment.Center).padding(16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyLarge ) } } diff --git a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RulesPane.kt b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RulesPane.kt index 05e9ed3..3c7decb 100644 --- a/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RulesPane.kt +++ b/app/src/main/java/me/zhanghai/android/untracker/ui/rule/RulesPane.kt @@ -16,30 +16,40 @@ package me.zhanghai.android.untracker.ui.rule +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder @@ -51,6 +61,7 @@ import me.zhanghai.android.untracker.R import me.zhanghai.android.untracker.model.RuleList import me.zhanghai.android.untracker.repository.RuleListRepository import me.zhanghai.android.untracker.ui.component.NavigationItemInfo +import me.zhanghai.android.untracker.ui.component.TopAppBarContainer import me.zhanghai.android.untracker.util.Stateful import me.zhanghai.android.untracker.util.asInsets import me.zhanghai.android.untracker.util.copy @@ -77,8 +88,10 @@ fun NavGraphBuilder.rulesPane( } } +private val tabTextResourceIds = listOf(R.string.main_rules_builtin, R.string.main_rules_custom) + @Composable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) fun RulesPane( contentPadding: PaddingValues, navigateToRuleScreen: (String) -> Unit, @@ -91,7 +104,7 @@ fun RulesPane( RuleListRepository.getBuiltinRuleListFlow() .map { Stateful.Success(it) as Stateful } .catch { emit(Stateful.Failure(null, it)) } - .stateInUi(coroutineScope, Stateful.Loading(null)) + .stateInUi(coroutineScope, Stateful.Loading(null)) } val builtinRuleListStateful by builtinRuleListStatefulFlow.collectAsStateWithLifecycle() val setBuiltinRuleList: (RuleList) -> Unit = { ruleList -> @@ -103,70 +116,126 @@ fun RulesPane( RuleListRepository.getCustomRuleListFlow() .map { Stateful.Success(it) as Stateful } .catch { emit(Stateful.Failure(null, it)) } - .stateInUi(coroutineScope, Stateful.Loading(null)) + .stateInUi(coroutineScope, Stateful.Loading(null)) } val customRuleListStateful by customRuleListStatefulFlow.collectAsStateWithLifecycle() val setCustomRuleList: (RuleList) -> Unit = { ruleList -> coroutineScope.launch { RuleListRepository.setCustomRuleList(ruleList) } } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - Column(modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection)) { - TopAppBar( - title = { Text(text = stringResource(RulesPaneInfo.labelResourceId)) }, - modifier = Modifier.fillMaxWidth(), - actions = { - IconButton(onClick = navigateToAddRuleScreen) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.add) - ) + Column(modifier = Modifier.fillMaxSize()) { + val pagerState = rememberPagerState { tabTextResourceIds.size } + val scrollBehaviors = + List(tabTextResourceIds.size) { TopAppBarDefaults.pinnedScrollBehavior() } + TopAppBarContainer(scrollBehavior = scrollBehaviors[pagerState.currentPage]) { + Column { + TopAppBar( + title = { Text(text = stringResource(RulesPaneInfo.labelResourceId)) }, + modifier = Modifier.fillMaxWidth(), + actions = { + IconButton( + onClick = { + navigateToAddRuleScreen() + coroutineScope.launch { pagerState.scrollToPage(1) } + } + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.add) + ) + } + }, + windowInsets = contentPadding.copy(bottom = 0.dp).asInsets(), + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Unspecified) + ) + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = Color.Unspecified, + contentColor = LocalContentColor.current, + divider = {} + ) { + tabTextResourceIds.forEachIndexed { index, textResourceId -> + Tab( + selected = index == pagerState.currentPage, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + text = { + Text( + text = stringResource(textResourceId), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + selectedContentColor = TabRowDefaults.primaryContentColor, + unselectedContentColor = LocalContentColor.current + ) + } } - }, - windowInsets = contentPadding.copy(bottom = 0.dp).asInsets(), - scrollBehavior = scrollBehavior - ) - val builtinRuleList = builtinRuleListStateful.value - val customRuleList = customRuleListStateful.value - val viewPadding = contentPadding.copy(top = 0.dp) - if (builtinRuleList != null && customRuleList != null) { - RuleList( - builtinRuleList = builtinRuleList, - onBuiltinRuleListChange = setBuiltinRuleList, - customRuleList = customRuleList, - onCustomRuleListChange = setCustomRuleList, + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondBoundsPageCount = 1 + ) { page -> + val (ruleListStateful, onRuleListChange) = + when (page) { + 0 -> builtinRuleListStateful to setBuiltinRuleList + 1 -> customRuleListStateful to setCustomRuleList + else -> error(page) + } + RulesTab( + ruleListStateful = ruleListStateful, + onRuleListChange = onRuleListChange, onRuleClick = { navigateToRuleScreen(it) }, + contentPadding = contentPadding.copy(top = 0.dp), + scrollBehavior = scrollBehaviors[page] + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun RulesTab( + ruleListStateful: Stateful, + onRuleListChange: (RuleList) -> Unit, + onRuleClick: (String) -> Unit, + contentPadding: PaddingValues, + scrollBehavior: TopAppBarScrollBehavior +) { + Column(modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection)) { + val ruleList = ruleListStateful.value + if (ruleList?.rules?.isNotEmpty() == true) { + RuleList( + ruleList = ruleList, + onRuleListChange = onRuleListChange, + onRuleClick = onRuleClick, modifier = Modifier.fillMaxSize(), - contentPadding = viewPadding + contentPadding = contentPadding ) } else { - Box(modifier = Modifier.fillMaxSize().padding(viewPadding)) { - @Suppress("NAME_SHADOWING") val builtinRuleListStateful = builtinRuleListStateful - @Suppress("NAME_SHADOWING") val customRuleListStateful = customRuleListStateful - when { - builtinRuleListStateful is Stateful.Failure || - customRuleListStateful is Stateful.Failure -> { - val throwable = - if (builtinRuleListStateful is Stateful.Failure) { - builtinRuleListStateful.throwable - } else { - customRuleListStateful as Stateful.Failure - customRuleListStateful.throwable - } + Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) { + when (ruleListStateful) { + is Stateful.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).padding(16.dp) + ) + } + else -> { Text( - text = throwable.toString(), + text = + if (ruleListStateful is Stateful.Failure) { + ruleListStateful.throwable.toString() + } else { + stringResource(R.string.main_rules_empty) + }, modifier = Modifier.align(Alignment.Center).padding(16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - } - builtinRuleListStateful is Stateful.Loading || - customRuleListStateful is Stateful.Loading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center).padding(16.dp) + style = MaterialTheme.typography.bodyLarge ) } - else -> error("$builtinRuleListStateful $customRuleListStateful") } } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 02cacd9..497fcde 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -36,6 +36,7 @@ 规则 内置 自定义 + 按“+”以添加规则 关于 版本 %1$s(%2$d) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01620ea..fd64ebf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Rules Built-in Custom + Press “+” to add a rule About Version %1$s (%2$d)