diff --git a/.gitignore b/.gitignore index b5af05773e..de1c06cf0b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ /captures .cxx +# superpowers skill +.superpowers +docs/brainstorms +docs/superpowers # Built application files *.apk diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index f8522fe3fc..8d704df193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -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 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 SpinnerSelectionDialog( fontWeight = FontWeight.Bold, ) } - HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } itemsIndexed(options) { index, item -> @@ -237,7 +231,7 @@ fun 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 SpinnerSelectionDialog( ) { Column { onRenderItem(item) } } - if (index < options.lastIndex) { - HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) - } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 62f7217670..be00e48457 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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): Map> { + 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, + 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, + 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, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a935341290..a506604f92 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1545,7 +1545,11 @@ My Lists/Sets My Lists Users - Select a list to filter the feed + Select an option to filter the feed + Feeds + Hashtags + Communities + Lists Log off on device lock Private Message