Skip to content

Commit

Permalink
[Feature] Use tabs for built-in and custom rules.
Browse files Browse the repository at this point in the history
  • Loading branch information
zhanghai committed Dec 30, 2023
1 parent f53d128 commit 04f86a0
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 104 deletions.
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
169 changes: 119 additions & 50 deletions app/src/main/java/me/zhanghai/android/untracker/ui/rule/RulesPane.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -91,7 +104,7 @@ fun RulesPane(
RuleListRepository.getBuiltinRuleListFlow()
.map { Stateful.Success(it) as Stateful<RuleList> }
.catch { emit(Stateful.Failure(null, it)) }
.stateInUi(coroutineScope, Stateful.Loading(null))
.stateInUi(coroutineScope, Stateful.Loading<RuleList>(null))
}
val builtinRuleListStateful by builtinRuleListStatefulFlow.collectAsStateWithLifecycle()
val setBuiltinRuleList: (RuleList) -> Unit = { ruleList ->
Expand All @@ -103,70 +116,126 @@ fun RulesPane(
RuleListRepository.getCustomRuleListFlow()
.map { Stateful.Success(it) as Stateful<RuleList> }
.catch { emit(Stateful.Failure(null, it)) }
.stateInUi(coroutineScope, Stateful.Loading(null))
.stateInUi(coroutineScope, Stateful.Loading<RuleList>(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<RuleList>,
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")
}
}
}
Expand Down
Loading

0 comments on commit 04f86a0

Please sign in to comment.