Merge pull request #1887 from davotoula/ui-polish-feed-filter

UI polish: feed filter
This commit is contained in:
Vitor Pamplona
2026-03-20 10:00:38 -04:00
committed by GitHub
4 changed files with 289 additions and 96 deletions
+4
View File
@@ -33,6 +33,10 @@
/captures
.cxx
# superpowers skill
.superpowers
docs/brainstorms
docs/superpowers
# Built application files
*.apk
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -61,7 +59,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import kotlinx.collections.immutable.ImmutableList
@@ -159,13 +156,11 @@ private fun BaseTextSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
if (optionsShowing && options.isNotEmpty()) {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
}
}
@@ -211,14 +206,14 @@ fun <T> SpinnerSelectionDialog(
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
LazyColumn {
title?.let {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp),
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
@@ -227,7 +222,6 @@ fun <T> SpinnerSelectionDialog(
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
itemsIndexed(options) { index, item ->
@@ -237,7 +231,7 @@ fun <T> SpinnerSelectionDialog(
Modifier
.fillMaxWidth()
.clickable { onSelect(index) }
.padding(16.dp, 16.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
role = Role.Button
contentDescription = optionsOfLabel
@@ -245,9 +239,6 @@ fun <T> SpinnerSelectionDialog(
) {
Column { onRenderItem(item) }
}
if (index < options.lastIndex) {
HorizontalDivider(color = Color.LightGray, thickness = DividerThickness)
}
}
}
}
@@ -21,20 +21,36 @@
package com.vitorpamplona.amethyst.ui.navigation.topbars
import android.Manifest
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewList
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -43,14 +59,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@@ -62,7 +81,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
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.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -74,6 +92,8 @@ import com.vitorpamplona.amethyst.ui.screen.RelayName
import com.vitorpamplona.amethyst.ui.screen.ResourceName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -120,6 +140,8 @@ fun FeedFilterSpinner(
stringRes(R.string.feed_filter_select_an_option, selectAnOption)
}
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -137,7 +159,7 @@ fun FeedFilterSpinner(
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
} else {
@@ -152,7 +174,7 @@ fun FeedFilterSpinner(
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
Spacer(modifier = StdHorzSpacer)
@@ -162,7 +184,7 @@ fun FeedFilterSpinner(
) { cityName ->
Text(
text = "($cityName)",
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -171,7 +193,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -179,7 +201,7 @@ fun FeedFilterSpinner(
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = 12.sp,
fontSize = Font12SP,
lineHeight = 12.sp,
)
}
@@ -207,7 +229,7 @@ fun FeedFilterSpinner(
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = "Open feed filter menu") {
onClick(label = openDropdownLabel) {
optionsShowing = true
return@onClick true
}
@@ -215,20 +237,18 @@ fun FeedFilterSpinner(
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
if (optionsShowing && options.isNotEmpty()) {
GroupedFeedFilterDialog(
title = explainer,
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
selected = options[it]
optionsShowing = false
onSelect(it)
},
) {
RenderOption(it.name, accountViewModel)
}
}
}
@@ -241,84 +261,258 @@ fun RenderOption(
when (option) {
is GeoHashName -> {
LoadCityName(option.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
Text(text = "/g/$it", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
}
is HashtagName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = option.name(), color = MaterialTheme.colorScheme.onSurface)
}
Text(text = option.name(), fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is ResourceName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(id = option.resourceId),
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringRes(id = option.resourceId),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
is PeopleListName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val noteState by observeNote(option.note, accountViewModel)
val noteState by observeNote(option.note, accountViewModel)
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: option.note.dTag()
}
is FollowListEvent -> {
noteEvent.title() ?: option.note.dTag()
}
else -> {
option.note.dTag()
}
val noteEvent = noteState.note.event
val name =
when (noteEvent) {
is PeopleListEvent -> {
noteEvent.titleOrName() ?: 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 = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is CommunityName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
val it by observeNote(option.note, accountViewModel)
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 = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is RelayName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
Text(
text = option.name(),
fontSize = Font14SP,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Immutable
private data class IndexedFeedDefinition(
val originalIndex: Int,
val item: FeedDefinition,
)
private enum class FeedGroup(
@param:androidx.annotation.StringRes val labelRes: Int,
) {
FEEDS(R.string.feed_group_feeds),
HASHTAGS(R.string.feed_group_hashtags),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_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(
text = option.name(),
color = MaterialTheme.colorScheme.onSurface,
)
item {
Text(
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 = stringRes(group.labelRes),
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 = Font12SP,
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 = Size20Modifier,
)
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.AutoMirrored.Outlined.VolumeOff
}
is TopFilter.Chess -> {
Icons.Outlined.Groups
}
is TopFilter.PeopleList -> {
Icons.AutoMirrored.Outlined.ViewList
}
else -> {
when (item.name) {
is GeoHashName -> Icons.Outlined.LocationOn
is RelayName -> Icons.Outlined.SensorDoor
is CommunityName -> Icons.Outlined.Groups
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
else -> Icons.Outlined.Person
}
}
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+5 -1
View File
@@ -1545,7 +1545,11 @@
<string name="my_lists_and_sets">My Lists/Sets</string>
<string name="my_lists">My Lists</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="feed_group_feeds">Feeds</string>
<string name="feed_group_hashtags">Hashtags</string>
<string name="feed_group_communities">Communities</string>
<string name="feed_group_lists">Lists</string>
<string name="temporary_account">Log off on device lock</string>
<string name="private_message">Private Message</string>