From d43b9c121f3bc7a82c1334cda3bd5beb16bfc6bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:32:45 +0000 Subject: [PATCH] feat: dedicated Location Channels list screen in the drawer Brings geohash location channels to full parity with Public Chats (NIP-28), Relay Groups (NIP-29) and Concord: a top-level list screen reached from the drawer's "Feeds" section (and pinnable as a bottom-bar tab), rather than the joined-cells list living inside the "New location channel" builder. - Route.GeohashChats (plural) + AppNavigation wiring - GeohashChatsScreen: DisappearingScaffold + AppBottomBar + FAB, a gradient hero banner, rich per-cell cards (gradient location pin, "#cell", precision level + resolved city, overflow menu with Open/Leave), and an inviting empty state - GEOHASH_CHATS catalog entry now resolves to the list screen; the "+" FAB opens the builder (near me / manual / teleport) - GEOHASH_CHATS added to the drawer's Feeds section alongside the other chat-group types Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV --- .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../ui/navigation/bottombars/NavBarItem.kt | 3 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../chats/geohashChat/GeohashChatsScreen.kt | 364 ++++++++++++++++++ 4 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 29320ff438..78b2b0d7d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalenda import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.NewGeohashChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen @@ -638,6 +639,8 @@ fun BuildNavigation( ) } + composableFromEnd { GeohashChatsScreen(accountViewModel, nav) } + composableFromBottomArgs { NewGeohashChatScreen(accountViewModel, nav) } composableFromBottomArgs { GeohashTeleportScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index cc0033011d..78cd5f8f7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -340,7 +340,7 @@ val NavBarCatalog: Map = id = NavBarItem.GEOHASH_CHATS, labelRes = R.string.location_channels, icon = MaterialSymbols.LocationOn, - resolveRoute = { Route.NewGeohashChat }, + resolveRoute = { Route.GeohashChats }, ), NavBarItem.FOLLOW_PACKS to NavBarItemDef( @@ -560,6 +560,7 @@ val DrawerFeedsItems: List = NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, + NavBarItem.GEOHASH_CHATS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SOFTWARE_APPS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index a525259a3f..e7c0a69b71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -650,6 +650,8 @@ sealed class Route { @Serializable object NewGeohashChat : Route() + @Serializable object GeohashChats : Route() + @Serializable object GeohashTeleport : Route() @Serializable data class RelayGroup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt new file mode 100644 index 0000000000..f2ef04394b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatsScreen.kt @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The dedicated top-level list of the user's joined Bitchat-interoperable location channels — the + * geohash-chat analogue of [com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen] + * (NIP-28) and the Relay Groups / Concord home screens. Reached from the drawer's "Feeds" section and + * pinnable as a bottom-bar tab. Each row opens the cell's chat; the "+" opens [NewGeohashChatScreen] to + * join more (near me / manual / teleport). + */ +@Composable +fun GeohashChatsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + UserDrawerSearchTopBar(accountViewModel, nav) { + Text(stringRes(R.string.location_channels), fontWeight = FontWeight.Bold) + } + }, + bottomBar = { + AppBottomBar(Route.GeohashChats, nav, accountViewModel) { route -> + nav.navBottomBar(route) + } + }, + floatingButton = { + FabBottomBarPadded(nav) { + FloatingActionButton( + onClick = { nav.nav(Route.NewGeohashChat) }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + SymbolIcon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.location_channels), + tint = Color.White, + modifier = Modifier.size(26.dp), + ) + } + } + }, + accountViewModel = accountViewModel, + ) { pad -> + val joined by accountViewModel.account.geohashList.flow + .collectAsStateWithLifecycle() + val cells = remember(joined) { joined.sorted() } + + if (cells.isEmpty()) { + EmptyLocationChannels( + modifier = + Modifier + .fillMaxSize() + .padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()), + onExplore = { nav.nav(Route.NewGeohashChat) }, + ) + } else { + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + top = pad.calculateTopPadding() + 12.dp, + bottom = pad.calculateBottomPadding() + 88.dp, + start = 16.dp, + end = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(key = "hero") { LocationChannelsHero() } + items(cells, key = { it }) { cell -> + LocationChannelRow( + cell = cell, + onOpen = { nav.nav(Route.GeohashChat(cell)) }, + onLeave = { accountViewModel.unfollowGeohash(cell) }, + ) + } + } + } + } +} + +/** A gradient banner that gives the screen its identity: what location channels are, in one glance. */ +@Composable +private fun LocationChannelsHero() { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ).padding(20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = Color.White.copy(alpha = 0.22f), + modifier = Modifier.size(52.dp), + ) { + Box(contentAlignment = Alignment.Center) { + SymbolIcon( + symbol = MaterialSymbols.Public, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(30.dp), + ) + } + } + Column(Modifier.padding(start = 16.dp)) { + Text( + "Chat by location", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + "Anonymous, ephemeral rooms shared with everyone in the same map cell. Interoperable with Bitchat.", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.9f), + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + +/** One joined cell: gradient location pin, "#cell", its precision level + resolved city, and an overflow menu. */ +@Composable +private fun LocationChannelRow( + cell: String, + onOpen: () -> Unit, + onLeave: () -> Unit, +) { + ElevatedCard( + onClick = onOpen, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LocationPin() + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text( + "#$cell", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + LevelAndCity(cell) + } + LocationChannelMenu(onOpen = onOpen, onLeave = onLeave) + } + } +} + +/** The circular gradient avatar with a location pin — the visual signature of a location channel. */ +@Composable +private fun LocationPin() { + Box( + Modifier + .size(48.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + SymbolIcon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp), + ) + } +} + +/** A subtle "City · Palo Alto" subtitle: the named precision level (from the cell length) then the resolved place. */ +@Composable +private fun LevelAndCity(cell: String) { + val level = remember(cell) { GeohashChannelLevel.forChars(cell.length)?.label() } + LoadCityName(geohashStr = cell) { city -> + val text = if (level != null) "$level · $city" else city + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +@Composable +private fun LocationChannelMenu( + onOpen: () -> Unit, + onLeave: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + SymbolIcon( + symbol = MaterialSymbols.MoreVert, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Open") }, + onClick = { + expanded = false + onOpen() + }, + ) + DropdownMenuItem( + text = { Text("Leave") }, + onClick = { + expanded = false + onLeave() + }, + ) + } + } +} + +/** Shown before the user has joined any cell: an inviting call to explore nearby. */ +@Composable +private fun EmptyLocationChannels( + modifier: Modifier, + onExplore: () -> Unit, +) { + Column( + modifier = modifier.padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(96.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + SymbolIcon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(52.dp), + ) + } + Spacer(Modifier.height(20.dp)) + Text( + "No location channels yet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Join a room for your city, neighborhood, or block — or teleport anywhere on the map.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + FilledTonalButton(onClick = onExplore) { + SymbolIcon(symbol = MaterialSymbols.Explore, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(" Explore nearby") + } + } +}