add grouped feed filter dialog with Material 3 styling

add icons, reduce text size, center group headers in filter dialog
This commit is contained in:
davotoula
2026-03-20 12:09:07 +01:00
parent 460ccc2d02
commit 3a195c4648
2 changed files with 252 additions and 59 deletions
@@ -21,20 +21,37 @@
package com.vitorpamplona.amethyst.ui.navigation.topbars package com.vitorpamplona.amethyst.ui.navigation.topbars
import android.Manifest import android.Manifest
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.SensorDoor
import androidx.compose.material.icons.outlined.ViewList
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -43,14 +60,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
@@ -62,7 +82,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -217,7 +236,7 @@ fun FeedFilterSpinner(
if (optionsShowing) { if (optionsShowing) {
options.isNotEmpty().also { options.isNotEmpty().also {
SpinnerSelectionDialog( GroupedFeedFilterDialog(
title = explainer, title = explainer,
options = options, options = options,
onDismiss = { optionsShowing = false }, onDismiss = { optionsShowing = false },
@@ -241,84 +260,258 @@ fun RenderOption(
when (option) { when (option) {
is GeoHashName -> { is GeoHashName -> {
LoadCityName(option.geoHashTag) { LoadCityName(option.geoHashTag) {
Row( Text(text = "/g/$it", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
} }
} }
is HashtagName -> { is HashtagName -> {
Row( Text(text = option.name(), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface)
}
} }
is ResourceName -> { is ResourceName -> {
Row( Text(
horizontalArrangement = Arrangement.Center, text = stringRes(id = option.resourceId),
modifier = Modifier.fillMaxWidth(), fontSize = 14.sp,
) { color = MaterialTheme.colorScheme.onSurface,
Text( )
text = stringRes(id = option.resourceId),
color = MaterialTheme.colorScheme.onSurface,
)
}
} }
is PeopleListName -> { is PeopleListName -> {
Row( val noteState by observeNote(option.note, accountViewModel)
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val noteState by observeNote(option.note, accountViewModel)
val noteEvent = noteState.note.event val noteEvent = noteState.note.event
val name = val name =
when (noteEvent) { when (noteEvent) {
is PeopleListEvent -> { is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag() noteEvent.titleOrName() ?: option.note.dTag()
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
} }
Text(text = name, color = MaterialTheme.colorScheme.onSurface) is FollowListEvent -> {
} noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
}
Text(text = name, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
} }
is CommunityName -> { is CommunityName -> {
Row( val it by observeNote(option.note, accountViewModel)
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface) Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
}
} }
is RelayName -> { is RelayName -> {
Row( Text(
horizontalArrangement = Arrangement.Center, text = option.name(),
modifier = Modifier.fillMaxWidth(), fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Immutable
private data class IndexedFeedDefinition(
val originalIndex: Int,
val item: FeedDefinition,
)
private enum class FeedGroup(
val label: String,
) {
FEEDS("Feeds"),
HASHTAGS("Hashtags"),
COMMUNITIES("Communities"),
LISTS("Lists"),
}
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
val indexed = options.mapIndexed { index, item -> IndexedFeedDefinition(index, item) }
return indexed.groupBy { entry ->
when (entry.item.name) {
is HashtagName -> FeedGroup.HASHTAGS
is CommunityName -> FeedGroup.COMMUNITIES
is PeopleListName -> FeedGroup.LISTS
else -> FeedGroup.FEEDS
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupedFeedFilterDialog(
title: String,
options: ImmutableList<FeedDefinition>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
val grouped = remember(options) { groupFeedDefinitions(options) }
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn(
modifier = Modifier.padding(vertical = 20.dp),
) { ) {
Text( item {
text = option.name(), Text(
color = MaterialTheme.colorScheme.onSurface, text = title,
) style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
)
}
FeedGroup.entries.forEach { group ->
val items = grouped[group]
if (!items.isNullOrEmpty()) {
item {
GroupSection(
label = group.label,
items = items,
isChipLayout = group == FeedGroup.HASHTAGS,
onSelect = onSelect,
onRenderItem = onRenderItem,
)
}
}
}
} }
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupSection(
label: String,
items: List<IndexedFeedDefinition>,
isChipLayout: Boolean,
onSelect: (Int) -> Unit,
onRenderItem: @Composable (FeedDefinition) -> Unit,
) {
Surface(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column {
Text(
text = label.uppercase(),
fontSize = 12.sp,
letterSpacing = 0.8.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp),
)
if (isChipLayout) {
FlowRow(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items.forEach { entry ->
Surface(
modifier = Modifier.clickable { onSelect(entry.originalIndex) },
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = Color.Transparent,
) {
Text(
text = entry.item.name.name(),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
} else {
items.forEach { entry ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelect(entry.originalIndex) }
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
FeedIcon(
item = entry.item,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.padding(start = 12.dp))
Column(modifier = Modifier.weight(1f)) { onRenderItem(entry.item) }
}
}
}
}
}
}
@Composable
private fun FeedIcon(
item: FeedDefinition,
modifier: Modifier = Modifier,
) {
val icon =
when (item.code) {
is TopFilter.Global -> {
Icons.Outlined.Public
}
is TopFilter.AroundMe -> {
Icons.Outlined.LocationOn
}
is TopFilter.AllFollows -> {
Icons.Outlined.Groups
}
is TopFilter.AllUserFollows -> {
Icons.Outlined.Person
}
is TopFilter.DefaultFollows -> {
Icons.Outlined.Groups
}
is TopFilter.MuteList -> {
Icons.Outlined.VolumeOff
}
is TopFilter.Chess -> {
Icons.Outlined.Groups
}
is TopFilter.PeopleList -> {
Icons.Outlined.ViewList
}
else -> {
when (item.name) {
is GeoHashName -> Icons.Outlined.LocationOn
is RelayName -> Icons.Outlined.SensorDoor
is CommunityName -> Icons.Outlined.Groups
is PeopleListName -> Icons.Outlined.ViewList
else -> Icons.Outlined.Person
}
}
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+1 -1
View File
@@ -1545,7 +1545,7 @@
<string name="my_lists_and_sets">My Lists/Sets</string> <string name="my_lists_and_sets">My Lists/Sets</string>
<string name="my_lists">My Lists</string> <string name="my_lists">My Lists</string>
<string name="people_list_label">Users</string> <string name="people_list_label">Users</string>
<string name="select_list_to_filter">Select a list to filter the feed</string> <string name="select_list_to_filter">Select an option to filter the feed</string>
<string name="temporary_account">Log off on device lock</string> <string name="temporary_account">Log off on device lock</string>
<string name="private_message">Private Message</string> <string name="private_message">Private Message</string>