Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e402283
feat(db): add chat_archive table via auto-migration 40 to 41
bmc08gt Oct 2, 2026
41e26f8
feat(db): add ChatArchiveDataSource
bmc08gt Oct 2, 2026
03c26fe
feat(chat): add archive list projection and notify rule with fixture …
bmc08gt Oct 2, 2026
78a250f
feat(chat): add ChatArchiveStore with a Room-backed implementation
bmc08gt Oct 2, 2026
f342655
feat(chat): filter archived chats out of the feed and add archivedFeed
bmc08gt Oct 2, 2026
f4f41f3
fix(messenger): seed the mute sheet from the archived list for archiv…
bmc08gt Oct 2, 2026
9cbdeb8
feat(chat): clear archive on leave and on account erasure
bmc08gt Oct 2, 2026
7c14a77
refactor(chat): move mention and link detection into shared:chat
bmc08gt Oct 2, 2026
e7aed6e
feat(notifications): keep archived chats silent unless the viewer is …
bmc08gt Oct 2, 2026
c293c0a
fix(notifications): decide archived pushes after sign-in and open enc…
bmc08gt Oct 2, 2026
20608bb
fix(notifications): post an archived push silently when opening its D…
bmc08gt Oct 2, 2026
b9b7093
feat(ui): add a selectable FilterChip
bmc08gt Oct 2, 2026
a73d6ea
fix(ui): match the iOS filter chip's padding and count colour
bmc08gt Oct 2, 2026
f28174a
feat(chat): project the Chats list into filters and an Archived row
bmc08gt Oct 2, 2026
0b6b8f0
feat(chat): filter chips, Archived row and archive swipe on the Chats…
bmc08gt Oct 2, 2026
802cf03
fix(chat): keep hidden chips out of reach and mark a reveal once they…
bmc08gt Oct 2, 2026
9b9d772
feat(tipping): add the Archived chats screen and route
bmc08gt Oct 2, 2026
3419544
fix(chat): park the chips on a list shorter than the screen
bmc08gt Oct 2, 2026
1b9f89d
fix(chat): read the main and archived chat lists from one feed state
bmc08gt Oct 2, 2026
2f43f58
fix(notifications): keep a push when archive handling fails
bmc08gt Oct 2, 2026
1817f2a
docs(chat): state the archive rules instead of citing rule numbers
bmc08gt Oct 2, 2026
776392d
style(chat): keep the FeedOperations KDoc on its interface
bmc08gt Oct 2, 2026
6ad1821
fix(ui): draw the snackbar action bold in the message colour
bmc08gt Oct 2, 2026
cd3c50f
feat(chat): animate chat rows leaving and returning in the chat lists
bmc08gt Oct 3, 2026
e8b32d7
fix(ui): mark the selected filter chip with a white tint
bmc08gt Oct 3, 2026
b376d98
feat(ui): draw snackbars as the shared floating pill
bmc08gt Oct 3, 2026
1b68a81
fix(ui): give the snackbar action a 48dp touch area and set the messa…
bmc08gt Oct 3, 2026
3d4a8c6
fix(ui): outline both filter chip states in white at 10%
bmc08gt Oct 3, 2026
709695c
refactor(ui): share the navigation bar's glass and inset as floating …
bmc08gt Oct 3, 2026
94a04a0
feat(ui): add FloatingToast on the navigation bar's glass and inset
bmc08gt Oct 3, 2026
163e482
feat(chat): raise the archive undo toast out of the tab bar
bmc08gt Oct 3, 2026
f57fa13
feat(menu): show the developer-mode countdown on the floating toast
bmc08gt Oct 3, 2026
8881dd4
fix(ui): dismiss the floating toast while a sheet, the bill or a prom…
bmc08gt Oct 3, 2026
e9dbd72
chore(ui): remove the unused snackbar host
bmc08gt Oct 3, 2026
0a8b59d
chore(nav): drop the orphaned navigation bar KDoc
bmc08gt Oct 3, 2026
3c74457
fix(chat): centre the Archived screen's title like the other back-but…
bmc08gt Oct 3, 2026
df88214
fix(ui): set the toast message in Avenir Next Medium to match iOS
bmc08gt Oct 3, 2026
dcf8132
fix(ui): slide and fade the toast in, and reverse it on the way out
bmc08gt Oct 3, 2026
7200198
Merge remote-tracking branch 'origin/code/cash' into feat/chat-archive
bmc08gt Oct 3, 2026
945f237
chore(ui): drop CodeScaffold's snackbarHost slot
bmc08gt Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.cardexpand.CardExpansionController
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.extensions.openAsSheet
import dev.chrisbanes.haze.HazeState
import com.flipcash.app.core.navigation.NavBarButton
import com.flipcash.app.core.navigation.TabBarVisibilityController
import com.flipcash.app.core.navigation.asNavBarTab
import com.flipcash.app.core.navigation.destinationRoute
import com.flipcash.app.core.ui.NavigationBar
Expand All @@ -33,103 +35,156 @@ import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.user.AuthState
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.manager.BottomBarManager
import com.getcode.navigation.Sheet
import com.getcode.navigation.core.CodeNavigator
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.glass.FloatingChrome
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map

/**
* Whether the hoisted navigation bar is on screen, read by the bar itself and by the toast that rises
* out of it, so both answer the same way.
*
* Every value is derived lazily, so only the composables that read one recompose when it changes.
*/
@Stable
internal class AppNavigationBarVisibility(
private val navigator: CodeNavigator,
private val bottomBarMessages: State<List<*>>,
private val billUp: State<Boolean>,
private val tabBarVisibility: TabBarVisibilityController,
private val cardExpansion: CardExpansionController?,
) {
/**
* The tab the bar highlights. Selection follows the base of the backstack (the tab "home"), so it
* stays correct while a sheet/modal sits on top and is right on launch.
*/
val selectedTab: NavBarButton? by derivedStateOf {
navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() }
}

/**
* The route, modal and bill gate, which the bar slides in and out on. Only the top route gates
* visibility. A BottomBar modal (e.g. Add Money) renders in the nav content, above the bar, and a
* bill/tip card renders at the app root above everything, so the bar hides under either. A tab
* home can also hide it without leaving its route (the You tab's tip card expanding in place).
*/
val shown: Boolean by derivedStateOf {
(navigator.currentRouteKey as? AppRoute)?.asNavBarTab() != null &&
bottomBarMessages.value.isEmpty() &&
!billUp.value &&
!tabBarVisibility.isHidden
}

/**
* The wallet's card expansion, which fades the bar out as a card opens over the deck. The
* expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so the
* fade is scoped to that tab: a route change that leaves the wallet mid-expansion would otherwise
* strand the bar faded out with nothing left to bring it back.
*/
val fadeProgress: Float
get() = cardExpansion
?.takeIf { selectedTab == NavBarButton.Wallet }
?.progress?.value
?: 0f

/**
* Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the
* bar hit-testable, so an invisible bar still took taps and switched tabs. Derived, so a reader
* recomposes on the two frames the boolean flips rather than once per frame of the expansion.
*/
val fadedOut: Boolean by derivedStateOf { fadeProgress >= 1f }

/**
* A sheet, the bill or a bottom-bar prompt is up. Each renders inside the nav content, below the
* root toast host in z, so a toast is dismissed while one is up rather than drawn over it. An
* ordinary push hides the bar but covers nothing, so it is not counted.
*/
val coversToast: Boolean by derivedStateOf {
navigator.currentRouteKey is Sheet ||
bottomBarMessages.value.isNotEmpty() ||
billUp.value
}

/** On screen and not faded out by a card expansion. */
val isVisible: Boolean get() = shown && !fadedOut
}

@Composable
internal fun rememberAppNavigationBarVisibility(
navigator: CodeNavigator,
tabBarVisibility: TabBarVisibilityController,
cardExpansion: CardExpansionController?,
): AppNavigationBarVisibility {
val bottomBarMessages = BottomBarManager.messages.collectAsStateWithLifecycle()
val session = LocalSessionController.current
val billUp = remember(session) {
session?.billState?.map { it.bill != null } ?: flowOf(false)
}.collectAsStateWithLifecycle(initialValue = false)
return remember(navigator, tabBarVisibility, cardExpansion, bottomBarMessages, billUp) {
AppNavigationBarVisibility(navigator, bottomBarMessages, billUp, tabBarVisibility, cardExpansion)
}
}

/**
* The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever
* top-level route is a tab home and switches tabs by **swapping the current screen** (single
* backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet).
*
* Only visible when the current route maps to a tab.
* Visible while [visibility] says so; it fades out with the wallet's card expansion, like iOS's tab
* bar, and drops out of the tree at the end of the fade.
*
* Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be
* dropped into any container (it does not require a BoxScope from its caller).
*/
@Composable
internal fun AppNavigationBar(
navigator: CodeNavigator,
visibility: AppNavigationBarVisibility,
modifier: Modifier = Modifier,
hazeState: HazeState? = null,
// A tab home taking over the whole screen without leaving its route (the You tab's tip card
// expanding in place). See TabBarVisibilityController.
forceHidden: Boolean = false,
// The wallet's card expansion, which fades this bar out as a card opens over the deck. Passed as
// the controller rather than a Float so the progress is read inside a graphicsLayer and a frame of
// the expansion doesn't recompose the bar.
cardExpansion: CardExpansionController? = null,
// The same gate as Switch Accounts in Advanced Features: beta flags unlocked, or staff.
canSwitchAccounts: Boolean = false,
) {
// Selection follows the base of the backstack (the tab "home"), so it stays correct while a
// sheet/modal sits on top and is right on launch. The top route only gates visibility.
val selectedTab = navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() }
val topTab = (navigator.currentRouteKey as? AppRoute)?.asNavBarTab()

// A BottomBar modal (e.g. Add Money) renders in the nav content, above this bar; hide the bar so
// it doesn't draw over the modal. Safe because the bar is a bottom overlay (not a scaffold
// bottomBar), so hiding it doesn't resize the content beneath (see AppContent).
val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle()

// A bill/tip card renders at the app root above everything; hide the bar so it doesn't show
// beneath the presented bill.
val session = LocalSessionController.current
val billUp by remember(session) {
session?.billState?.map { it.bill != null } ?: flowOf(false)
}.collectAsStateWithLifecycle(initialValue = false)
val selectedTab = visibility.selectedTab

// Unread chats in the Chats list (tip DMs and groups), badged onto the Chats tab, so the badge
// appears, updates and clears as conversations are read.
val session = LocalSessionController.current
val chatListUnreadCount by remember(session) {
session?.state?.map { it.chatListUnreadCount } ?: flowOf(0)
}.collectAsStateWithLifecycle(initialValue = 0)

val avatar = rememberProfileAvatar()

// The expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so
// scope the fade to that tab. A route change that leaves the wallet mid-expansion would otherwise
// strand the bar faded out with nothing left to bring it back.
val expansion = cardExpansion?.takeIf { selectedTab == NavBarButton.Wallet }
val fadeProgress = { expansion?.progress?.value ?: 0f }

// Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the
// bar hit-testable, so an invisible bar still took taps and switched tabs. Drop it from the tree at
// the end of the fade instead. derivedStateOf keeps that to the two frames the boolean flips on,
// rather than one recomposition per frame of the expansion.
val fadedOut by remember(expansion) {
derivedStateOf { fadeProgress() >= 1f }
}

Box(
modifier = Modifier
.then(modifier)
// Fades out with the expansion and back in with the collapse (no abrupt snap on return),
// like iOS's tab bar. At rest progress is 0, so it's fully shown.
.graphicsLayer { alpha = 1f - fadeProgress() },
// Read in a graphicsLayer so a frame of the expansion doesn't recompose the bar. At rest
// progress is 0, so it's fully shown.
.graphicsLayer { alpha = 1f - visibility.fadeProgress },
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
visible = topTab != null && bottomBarMessages.isEmpty() && !billUp && !forceHidden,
visible = visibility.shown,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
) {
// Inside the AnimatedVisibility, not part of its `visible`: the bar is already at alpha 0
// by the time this drops it, so it must not also play the slide-out — and on the way back
// it reappears where it stood and fades up, as before.
if (!fadedOut) {
if (!visibility.fadedOut) {
val state = rememberNavigationBarState(
selectedTab = selectedTab ?: NavBarButton.Wallet,
chatListUnreadCount = chatListUnreadCount,
)
NavigationBar(
modifier = Modifier
.navigationBarsPadding()
// The design insets the bar 24pt from each edge (node 10642:1325), which
// keeps each tab's pill wider than tall; 25 is the nearest fixed step.
.padding(horizontal = CodeTheme.dimens.staticGrid.x5)
// Shared with the toast that rises out of the bar, so it is never wider.
.padding(horizontal = FloatingChrome.horizontalInset)
.padding(bottom = CodeTheme.dimens.grid.x3),
state = state,
onButtonClick = { button ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
package com.flipcash.app.internal.ui.navigation

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import com.flipcash.app.internal.ui.AppNavigationBarVisibility
import com.flipcash.app.internal.ui.rememberAppNavigationBarVisibility
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.toast.FloatingToastHost
import com.getcode.ui.components.toast.FloatingToastHostState
import com.getcode.ui.components.toast.LocalFloatingToastHost
import dev.chrisbanes.haze.HazeState
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
Expand Down Expand Up @@ -98,6 +113,17 @@ internal fun AppContent(
// Lets a tab home hide the bar without leaving its route — the You tab's tip card expands to
// full screen in place, so there's no route change for the visibility rule below to notice.
val tabBarVisibility = remember { TabBarVisibilityController() }
val barVisibility = rememberAppNavigationBarVisibility(codeNavigator, tabBarVisibility, cardExpansion)

// Toasts float with the bar, so they are hosted here rather than per screen: outside the haze
// source, so their glass has the nav content to blur, and under the bar in z, so a toast can grow
// out from behind it.
val toasts = remember { FloatingToastHostState() }
// The host draws above the nav content, so it must stand down while a sheet, the bill or a
// bottom-bar prompt is up there rather than float over it and take its taps.
LaunchedEffect(toasts, barVisibility) {
snapshotFlow { barVisibility.coversToast }.collect { toasts.isCovered = it }
}

// A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every
// tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the
Expand All @@ -124,6 +150,7 @@ internal fun AppContent(
CompositionLocalProvider(
LocalCardExpansion provides cardExpansion,
LocalTabBarVisibility provides tabBarVisibility,
LocalFloatingToastHost provides toasts,
) {
Box(modifier = Modifier.fillMaxSize()) {
// Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it.
Expand Down Expand Up @@ -222,15 +249,23 @@ internal fun AppContent(
)
}

// Composed before the bar, so the bar draws over a toast rising out of it.
AppToastHost(
hostState = toasts,
barVisibility = barVisibility,
barHeight = { tabBarHeight.value },
hazeState = hazeState,
modifier = Modifier.align(Alignment.BottomCenter),
)

// Single persistent bottom overlay. Latch the tallest measured height so the reserved inset
// stays stable when the bar hides for a modal/bill.
AppNavigationBar(
navigator = codeNavigator,
hazeState = hazeState,
forceHidden = tabBarVisibility.isHidden,
// The bar fades itself out with the wallet's card expansion — and drops out of the tree at
// the end of the fade, so an invisible bar can't be tapped. See AppNavigationBar.
cardExpansion = cardExpansion,
visibility = barVisibility,
hazeState = hazeState,
canSwitchAccounts = canSwitchAccounts,
modifier = Modifier
.align(Alignment.BottomCenter)
Expand All @@ -243,4 +278,29 @@ internal fun AppContent(
// wallet entry's own composition churn on push/pop.
}
}
}
}

/**
* The root toast host. Over a visible navigation bar a toast rests a little above it; with no bar it
* sits where the bar would, above the system navigation bar.
*/
@Composable
private fun AppToastHost(
hostState: FloatingToastHostState,
barVisibility: AppNavigationBarVisibility,
barHeight: () -> Dp,
hazeState: HazeState,
modifier: Modifier = Modifier,
) {
val gap = CodeTheme.dimens.grid.x2
val systemBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val bottom by animateDpAsState(
targetValue = if (barVisibility.isVisible) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3,
label = "toastBottom",
)
FloatingToastHost(
hostState = hostState,
hazeState = hazeState,
modifier = modifier.offset { IntOffset(0, -bottom.roundToPx()) },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.flipcash.app.cash.CashScreen
import com.flipcash.app.contact.verification.VerificationFlowScreen
import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen
import com.flipcash.app.tipping.FindByUsernameScreen
import com.flipcash.app.tipping.ArchivedChatsScreen
import com.flipcash.app.tipping.NewChatScreen
import com.flipcash.app.tipping.NewGroupFlowScreen
import com.flipcash.app.tipping.ChatsScreen
Expand Down Expand Up @@ -121,6 +122,7 @@ fun appEntryProvider(
}
annotatedEntry<AppRoute.Messaging.Profile> { key -> ProfileScreen(key.address) }
annotatedEntry<AppRoute.Messaging.NewChat> { NewChatScreen() }
annotatedEntry<AppRoute.Messaging.ArchivedChats> { ArchivedChatsScreen() }
annotatedEntry<AppRoute.Messaging.FindByUsername> { FindByUsernameScreen() }
annotatedEntry<AppRoute.Messaging.NewGroup> { key ->
NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
Expand Down
Loading
Loading