From e2589149d752fa3086f3171f74dd51117a0420c0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 11:02:53 -0400 Subject: [PATCH 01/18] refactor(core-ui): move the tab bar to commonMain NavigationBar and NavBarGlyph only needed Android for R strings and drawables; they now read the same resources through Res, so desktop can draw the phone's tab bar. A tabs parameter lets desktop drop Scan. Previews stay in androidMain. --- ...WalletContent.kt => DesktopMainContent.kt} | 0 .../app/internal/ui/AppNavigationBar.kt | 3 +- .../app/core/ui/NavigationBarPreviews.kt | 61 ++++++++++++ .../com/flipcash/app/core/ui/NavigationBar.kt | 93 ++++++------------- 4 files changed, 90 insertions(+), 67 deletions(-) rename apps/desktop/src/main/kotlin/com/flipcash/desktop/{DesktopWalletContent.kt => DesktopMainContent.kt} (100%) create mode 100644 apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt rename apps/flipcash/core-ui/src/{androidMain => commonMain}/kotlin/com/flipcash/app/core/ui/NavigationBar.kt (85%) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopWalletContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt similarity index 100% rename from apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopWalletContent.kt rename to apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 875a4cd253..91ba70a973 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.stringResource as composeStringResource import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.getcode.ui.components.glass.floatingGlass @@ -310,7 +311,7 @@ internal fun SearchOriginTabButton( } val unread by rememberChatListUnreadCount() val state = rememberNavigationBarState(selectedTab = tab, chatListUnreadCount = unread) - val label = stringResource(tab.label) + val label = composeStringResource(tab.label) // The glyph sits over the button rather than in it: the button clips to its circle, and the // badge has to overhang the corner the way it does on the tab. Box(contentAlignment = Alignment.Center) { diff --git a/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt b/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt new file mode 100644 index 0000000000..5977fe9470 --- /dev/null +++ b/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.theme.FlipcashThemeWrapper + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarPreview() { + NavigationBar( + state = rememberNavigationBarState(chatListUnreadCount = 100), + onButtonClick = { } + ) +} + +/** + * The You tab's photo slot in both of its states (node 9713:664) — a flat fill stands in for the + * photo, since a preview has no profile to read. + */ +@Preview(name = "Avatar, You tab unselected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarUnselectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} + +@Preview(name = "Avatar, You tab selected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarSelectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} + +/** + * The You tab with no photo to draw (node 10000:111297) — the people-circle fallback, selected, so + * the filled twin gets a preview of its own rather than only appearing in the screenshot test. + */ +@Preview(name = "No photo, You tab selected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarNoAvatarSelectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + onButtonClick = { }, + ) +} diff --git a/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt similarity index 85% rename from apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt rename to apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index e1d36b2f22..f7b43e091d 100644 --- a/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -40,19 +40,28 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.flipcash.app.core.navigation.NavBarButton import dev.chrisbanes.haze.HazeState -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.core.R +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.ic_nav_chat +import com.flipcash.core.resources.ic_nav_chat_selected +import com.flipcash.core.resources.ic_nav_tipcard +import com.flipcash.core.resources.ic_nav_tipcard_selected +import com.flipcash.core.resources.ic_nav_wallet +import com.flipcash.core.resources.ic_nav_wallet_selected +import com.flipcash.core.resources.ic_people_circle +import com.flipcash.core.resources.title_tabChat +import com.flipcash.core.resources.title_tabScan +import com.flipcash.core.resources.title_tabWallet +import com.flipcash.core.resources.title_tabYou import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge import com.getcode.ui.components.glass.floatingGlass @@ -104,8 +113,10 @@ fun NavigationBar( // long-press at all — no haptic, and a long hold still selects it — which is what a caller // passes while the switcher's gate is closed. The other tabs never take a long-press. onYouTabLongClick: (() -> Unit)? = null, + // The tabs to draw, in bar order. Desktop leaves out Scan, having no camera. + tabs: List = NavBarButton.tabs, ) { - val order = NavBarButton.tabs + val order = tabs if (order.isEmpty()) return // The design frames glyph and name with 5pt above and below in an 86.5 x 50pt item (node @@ -302,12 +313,12 @@ internal val NavBarButton.testTag: String } /** The tab's name under its glyph, in tab order Scan, Chat, Wallet, You (node 10642:1325). */ -val NavBarButton.label: Int +val NavBarButton.label: StringResource get() = when (this) { - NavBarButton.Scanner -> R.string.title_tabScan - NavBarButton.Chats -> R.string.title_tabChat - NavBarButton.Wallet -> R.string.title_tabWallet - NavBarButton.TipCard -> R.string.title_tabYou + NavBarButton.Scanner -> Res.string.title_tabScan + NavBarButton.Chats -> Res.string.title_tabChat + NavBarButton.Wallet -> Res.string.title_tabWallet + NavBarButton.TipCard -> Res.string.title_tabYou } /** @@ -328,62 +339,12 @@ val NavBarButton.label: Int @Composable private fun NavBarButton.icon(selected: Boolean): Painter = when (this) { NavBarButton.Scanner -> - painterResource(if (selected) R.drawable.ic_nav_tipcard_selected else R.drawable.ic_nav_tipcard) + painterResource(if (selected) Res.drawable.ic_nav_tipcard_selected else Res.drawable.ic_nav_tipcard) NavBarButton.Wallet -> - painterResource(if (selected) R.drawable.ic_nav_wallet_selected else R.drawable.ic_nav_wallet) + painterResource(if (selected) Res.drawable.ic_nav_wallet_selected else Res.drawable.ic_nav_wallet) NavBarButton.Chats -> - painterResource(if (selected) R.drawable.ic_nav_chat_selected else R.drawable.ic_nav_chat) + painterResource(if (selected) Res.drawable.ic_nav_chat_selected else Res.drawable.ic_nav_chat) NavBarButton.TipCard -> if (selected) rememberVectorPainter(Icons.Filled.AccountCircle) - else painterResource(R.drawable.ic_people_circle) -} - -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarPreview() { - NavigationBar( - state = rememberNavigationBarState(chatListUnreadCount = 100), - onButtonClick = { } - ) -} - -/** - * The You tab's photo slot in both of its states (node 9713:664) — a flat fill stands in for the - * photo, since a preview has no profile to read. - */ -@Preview(name = "Avatar, You tab unselected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarAvatarUnselectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), - onButtonClick = { }, - avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, - ) -} - -@Preview(name = "Avatar, You tab selected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarAvatarSelectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), - onButtonClick = { }, - avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, - ) -} - -/** - * The You tab with no photo to draw (node 10000:111297) — the people-circle fallback, selected, so - * the filled twin gets a preview of its own rather than only appearing in the screenshot test. - */ -@Preview(name = "No photo, You tab selected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarNoAvatarSelectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), - onButtonClick = { }, - ) + else painterResource(Res.drawable.ic_people_circle) } From b7ddaca81fca645d72da99f2c296fed8f4768eae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 11:02:53 -0400 Subject: [PATCH 02/18] feat(desktop): add the window shell from the desktop design Width classes at 600 and 840 (Decisions node 12631:459): the phone's tab bar at compact, a 64-wide icon rail with the avatar pinned at the bottom from 600. Opens to Chat. Chat splits list and conversation from 600 (list 280/360, messages capped at 720); Money is one pane capped at 560 until 840, then list 400 plus a detail pane. Chat, You and currency detail are labelled placeholders until their feature modules build for JVM. On macOS the content runs under a transparent title bar; minimum window 400x640. The snapshot task takes a screen and a width. --- apps/desktop/build.gradle.kts | 12 +- .../kotlin/com/flipcash/desktop/DesktopApp.kt | 4 +- .../flipcash/desktop/DesktopMainContent.kt | 26 ++- .../main/kotlin/com/flipcash/desktop/Main.kt | 30 +++- .../com/flipcash/desktop/SnapshotMain.kt | 24 ++- .../flipcash/desktop/shell/DesktopLayout.kt | 34 ++++ .../desktop/shell/DesktopNavigation.kt | 113 ++++++++++++ .../flipcash/desktop/shell/DesktopPanes.kt | 165 ++++++++++++++++++ .../flipcash/desktop/shell/DesktopShell.kt | 82 +++++++++ .../com/flipcash/desktop/shell/MoneyPane.kt | 120 +++++++++++++ .../desktop/shell/DesktopLayoutTest.kt | 23 +++ 11 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 280c819fd3..7b180d1994 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -25,6 +25,10 @@ dependencies { implementation(project(":services:opencode")) implementation(project(":apps:flipcash:features:balance")) implementation(project(":apps:flipcash:core")) + implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:card-expand")) + implementation(project(":apps:flipcash:shared:common-ui")) + implementation(project(":ui:components")) implementation(project(":apps:flipcash:shared:transaction-history")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:funding")) @@ -78,12 +82,14 @@ compose.desktop { tasks.register("snapshot") { group = "application" - description = "Renders the wallet (or, with -Pscreen=link, the link screen) off-screen to a PNG (-Pout=)." + description = "Renders a screen off-screen to a PNG: -Pout= -Pscreen=chat|money|you|link -Pwidth= -Pheight=." classpath = sourceSets["main"].runtimeClasspath mainClass.set("com.flipcash.desktop.SnapshotMainKt") args( - providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path), - providers.gradleProperty("screen").getOrElse("wallet"), + providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("snapshot.png").get().asFile.path), + providers.gradleProperty("screen").getOrElse("chat"), + providers.gradleProperty("width").getOrElse("1040"), + providers.gradleProperty("height").getOrElse("760"), ) } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt index 41200a7782..9c3d876c25 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt @@ -18,12 +18,12 @@ import com.getcode.theme.CodeTheme import java.net.Inet4Address import java.net.NetworkInterface -/** The wallet when signed in; otherwise the link screen, which stores the phone's seed and signs in. */ +/** The main window when signed in; otherwise the link screen, which stores the phone's seed and signs in. */ @Composable fun DesktopApp(graph: DesktopGraph, signedIn: Boolean) { var linked by remember { mutableStateOf(signedIn) } if (linked) { - DesktopWalletContent(graph) + DesktopMainContent(graph) } else { DesktopLinkContent(graph, onSignedIn = { linked = true }) } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt index b344cab896..501de56d81 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -6,30 +6,40 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.flipcash.app.analytics.LocalAnalytics -import com.flipcash.app.balance.internal.WalletScreen +import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.theme.FlipcashTheme import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.desktop.shell.ChatPane +import com.flipcash.desktop.shell.DesktopShell +import com.flipcash.desktop.shell.DesktopStartTab +import com.flipcash.desktop.shell.MoneyPane +import com.flipcash.desktop.shell.YouPane +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme import com.getcode.util.resources.LocalResources import com.getcode.utils.network.LocalNetworkObserver /** - * The wallet screen with the locals and window background that Android's app root provides + * The signed-in window, with the locals and window background that Android's app root provides * (`App.kt` paints [CodeTheme.colors.background] behind every screen). The graph doesn't scope view * models, so each read of [DesktopGraph.walletViewModel] builds a new one; they're held here. */ @Composable -fun DesktopWalletContent(graph: DesktopGraph) { +fun DesktopMainContent(graph: DesktopGraph, initialTab: NavBarButton = DesktopStartTab) { val walletViewModel = remember { graph.walletViewModel } val tokenViewModel = remember { graph.selectTokenViewModel } LaunchedEffect(tokenViewModel) { tokenViewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Balance)) } + val user by graph.userManager.state.collectAsState() + val displayName = user.userProfile?.displayName.orEmpty() FlipcashTheme { CompositionLocalProvider( LocalExchange provides graph.exchange, @@ -38,7 +48,15 @@ fun DesktopWalletContent(graph: DesktopGraph) { LocalAnalytics provides graph.analytics, ) { Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { - WalletScreen(walletViewModel, tokenViewModel) + DesktopShell( + chat = { ChatPane(it) }, + money = { MoneyPane(it, walletViewModel, tokenViewModel) }, + you = { YouPane() }, + // Initials on the avatar gradient. The photo needs the blob URL resolver the + // Android app provides, which desktop doesn't have yet. + avatar = { modifier -> ContactAvatar(photoUri = null, displayName = displayName, modifier = modifier) }, + initialTab = initialTab, + ) } } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index 8bcda37187..c138f10132 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -1,14 +1,24 @@ package com.flipcash.desktop +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.flipcash.desktop.shell.DesktopDimens +import com.flipcash.desktop.shell.LocalTitleBarInset import dev.zacsweers.metro.createGraph import kotlinx.coroutines.runBlocking +import java.awt.Dimension import java.lang.management.ManagementFactory +private val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") + +// Height of the macOS title bar the content runs under, which the traffic lights sit in. +private val MacTitleBarHeight = 28.dp + fun main() { val graph = createGraph() val signedIn = runBlocking { @@ -19,9 +29,12 @@ fun main() { Window( onCloseRequest = ::exitApplication, title = "Flipcash", - state = rememberWindowState(width = 420.dp, height = 860.dp), + state = rememberWindowState(width = 1040.dp, height = 760.dp), ) { - DesktopApp(graph, signedIn) + LaunchedEffect(Unit) { configureChrome() } + CompositionLocalProvider(LocalTitleBarInset provides if (isMac) MacTitleBarHeight else 0.dp) { + DesktopApp(graph, signedIn) + } LaunchedEffect(Unit) { val sinceJvmStart = System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().startTime @@ -30,3 +43,16 @@ fun main() { } } } + +/** + * The minimum window, and on macOS a unified title bar: the content runs under a transparent title + * bar with the traffic lights over it. Windows and Linux keep their normal decorated window. + */ +private fun FrameWindowScope.configureChrome() { + window.minimumSize = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) + if (isMac) { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + window.rootPane.putClientProperty("apple.awt.windowTitleVisible", true) + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt index 3f30a1de54..8f0248ba5e 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt @@ -3,6 +3,7 @@ package com.flipcash.desktop import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.use +import com.flipcash.app.core.navigation.NavBarButton import dev.zacsweers.metro.createGraph import kotlinx.coroutines.runBlocking import java.io.File @@ -10,20 +11,29 @@ import kotlin.system.exitProcess import org.jetbrains.skia.EncodedImageFormat /** - * Renders the wallet screen off-screen through the same graph as [main] and writes a PNG. - * Usage: `./gradlew :apps:desktop:snapshot -Pout=`; add `-Pscreen=link` for the link screen, - * which skips sign-in. + * Renders a screen off-screen through the same graph as [main] and writes a PNG. + * Usage: `./gradlew :apps:desktop:snapshot -Pout= -Pscreen=chat|money|you|link -Pwidth= -Pheight=`. + * The link screen skips sign-in. Width picks the layout exactly as resizing the window does. */ fun main(args: Array) { - val out = File(args.firstOrNull() ?: "wallet.png") - val linkScreen = args.getOrNull(1) == "link" + val out = File(args.firstOrNull() ?: "snapshot.png") + val screen = args.getOrNull(1) ?: "chat" + val width = args.getOrNull(2)?.toIntOrNull() ?: 1040 + val height = args.getOrNull(3)?.toIntOrNull() ?: 760 + val linkScreen = screen == "link" + val tab = when (screen) { + "money", "wallet" -> NavBarButton.Wallet + "you" -> NavBarButton.TipCard + else -> NavBarButton.Chats + } val graph = createGraph() runBlocking { graph.startup() if (!linkScreen) graph.restoreSession() } - ImageComposeScene(width = 840, height = 1720, density = Density(2f)) { - if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopWalletContent(graph) + val scale = 2f + ImageComposeScene(width = (width * scale).toInt(), height = (height * scale).toInt(), density = Density(scale)) { + if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopMainContent(graph, initialTab = tab) }.use { scene -> var nanos = 0L repeat(80) { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt new file mode 100644 index 0000000000..3d1ca3e633 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -0,0 +1,34 @@ +package com.flipcash.desktop.shell + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.NavBarButton + +/** The window's width class (Decisions node 12631:459, card 1): Material's window size classes. */ +enum class WidthClass { Compact, Medium, Expanded } + +fun widthClassFor(width: Dp): WidthClass = when { + width < 600.dp -> WidthClass.Compact + width < 840.dp -> WidthClass.Medium + else -> WidthClass.Expanded +} + +/** The mobile tabs minus Scan, which needs a camera: Chat, Money, You. Desktop opens to Chat. */ +val DesktopTabs = listOf(NavBarButton.Chats, NavBarButton.Wallet, NavBarButton.TipCard) +val DesktopStartTab = NavBarButton.Chats + +/** The sizes the Figma frames set (Chats 12627:208, Money 12626:56). */ +object DesktopDimens { + val MinWindowWidth = 400.dp + val MinWindowHeight = 640.dp + + val RailWidth = 64.dp + + val ChatListMedium = 280.dp + val ChatListExpanded = 360.dp + val MessagesMaxWidth = 720.dp + + val MoneySinglePaneMaxWidth = 560.dp + val MoneyListExpanded = 400.dp + val DetailMaxWidth = 720.dp +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt new file mode 100644 index 0000000000..945de22597 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt @@ -0,0 +1,113 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.ui.NavBarGlyph +import com.flipcash.app.core.ui.label +import com.getcode.theme.CodeTheme +import org.jetbrains.compose.resources.stringResource + +/** + * Height of the area the window's title bar draws over. On macOS the content runs under a transparent + * title bar, so whatever sits at the top of a pane starts below the traffic lights; elsewhere it is 0. + */ +val LocalTitleBarInset = compositionLocalOf { 0.dp } + +/** + * The 64-wide icon rail that replaces the tab bar from 600 wide (Decisions 12631:459, card 2): Chat + * and Money at the top, the account's avatar (You) pinned at the bottom. The glyphs are the tab bar's + * own; each tab's name moves into a tooltip. + */ +@Composable +fun DesktopRail( + selectedTab: NavBarButton, + onTabClick: (NavBarButton) -> Unit, + avatar: (@Composable (Modifier) -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .width(DesktopDimens.RailWidth) + .fillMaxHeight() + .padding(top = LocalTitleBarInset.current + 12.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + DesktopTabs.filter { it != NavBarButton.TipCard }.forEach { tab -> + RailItem(tab, selected = tab == selectedTab, onClick = { onTabClick(tab) }) { + NavBarGlyph(button = tab, selected = tab == selectedTab, badgeCount = 0) + } + } + Spacer(Modifier.weight(1f)) + val you = NavBarButton.TipCard + RailItem(you, selected = selectedTab == you, onClick = { onTabClick(you) }, highlight = false) { + NavBarGlyph(button = you, selected = selectedTab == you, badgeCount = 0, avatar = avatar) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RailItem( + tab: NavBarButton, + selected: Boolean, + onClick: () -> Unit, + // The avatar carries its own selection ring, so it gets no pill behind it. + highlight: Boolean = true, + glyph: @Composable () -> Unit, +) { + TooltipArea( + tooltip = { + Text( + text = stringResource(tab.label), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(CodeTheme.colors.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + }, + tooltipPlacement = TooltipPlacement.ComponentRect( + anchor = Alignment.CenterEnd, + alignment = Alignment.CenterEnd, + offset = DpOffset(8.dp, 0.dp), + ), + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected && highlight) CodeTheme.colors.surfaceVariant else Color.Transparent) + .clickable(role = Role.Tab, onClick = onClick) + .testTag("rail_${tab.name}"), + contentAlignment = Alignment.Center, + ) { + glyph() + } + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt new file mode 100644 index 0000000000..6a6f8a4198 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt @@ -0,0 +1,165 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +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.platform.testTag +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +/** + * A pane whose real screen does not compile for the JVM yet. It says which screen belongs here and + * which modules hold it back, so the shell can be judged on layout while the screens catch up. + */ +@Composable +fun PlaceholderPane( + title: String, + blockedBy: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + action: Pair Unit>? = null, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = LocalTitleBarInset.current) + .padding(16.dp) + .testTag("placeholder_$title"), + ) { + if (onBack != null) { + TextButton("‹ Back", onBack) + Spacer(Modifier.height(8.dp)) + } + Box( + modifier = Modifier + .fillMaxSize() + .border(1.dp, CodeTheme.colors.divider, RoundedCornerShape(12.dp)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = CodeTheme.typography.screenTitle, color = CodeTheme.colors.textMain) + Text("Placeholder", style = CodeTheme.typography.caption, color = CodeTheme.colors.textTertiary) + Text( + text = blockedBy, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.widthIn(max = 320.dp), + ) + action?.let { (label, onClick) -> + Spacer(Modifier.height(8.dp)) + TextButton(label, onClick) + } + } + } + } +} + +@Composable +private fun TextButton(label: String, onClick: () -> Unit) { + Text( + text = label, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + ) +} + +private const val ChatBlockers = + "The chat list and conversation live in :apps:flipcash:features:messenger and " + + ":apps:flipcash:shared:chat / :chat-ui, which build for Android only." + +/** + * Chat (Chats node 12627:208): list and conversation side by side from 600 wide, the list 280 wide at + * medium and 360 at expanded, messages capped at 720. At compact a conversation opens over the list. + */ +@Composable +fun ChatPane(widthClass: WidthClass, modifier: Modifier = Modifier) { + var conversationOpen by remember { mutableStateOf(false) } + val list: @Composable (Modifier) -> Unit = { m -> + PlaceholderPane( + title = "Chats", + blockedBy = ChatBlockers, + modifier = m, + action = "Open a conversation" to { conversationOpen = true }, + ) + } + val conversation: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> + Box(m, contentAlignment = Alignment.TopCenter) { + PlaceholderPane( + title = "Conversation", + blockedBy = ChatBlockers, + modifier = Modifier.widthIn(max = DesktopDimens.MessagesMaxWidth), + onBack = onBack, + ) + } + } + when (widthClass) { + WidthClass.Compact -> + if (conversationOpen) conversation(modifier.fillMaxSize()) { conversationOpen = false } + else list(modifier.fillMaxSize()) + WidthClass.Medium, WidthClass.Expanded -> Row(modifier.fillMaxSize()) { + val listWidth = + if (widthClass == WidthClass.Medium) DesktopDimens.ChatListMedium else DesktopDimens.ChatListExpanded + list(Modifier.width(listWidth).fillMaxHeight()) + PaneDivider() + conversation(Modifier.weight(1f).fillMaxHeight(), null) + } + } +} + +/** You: the mobile You screen, with Settings behind its gear. */ +@Composable +fun YouPane(modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + PlaceholderPane( + title = "You", + blockedBy = "The You screen (tip card and profile) lives in :apps:flipcash:features:tipping " + + "and :apps:flipcash:features:myaccount, and Settings in :apps:flipcash:features:settings, " + + "all Android-only.", + modifier = Modifier.widthIn(max = DesktopDimens.MoneySinglePaneMaxWidth), + ) + } +} + +@Composable +fun PaneDivider(modifier: Modifier = Modifier) { + Box( + modifier + .width(1.dp) + .fillMaxHeight() + .border(0.5.dp, CodeTheme.colors.divider), + ) +} + +@Composable +internal fun CenteredColumn(maxWidth: androidx.compose.ui.unit.Dp, content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(Modifier.widthIn(max = maxWidth).fillMaxWidth()) { content() } + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt new file mode 100644 index 0000000000..f687ed8224 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -0,0 +1,82 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.ui.NavigationBar +import com.flipcash.app.core.ui.rememberNavigationBarState +import com.getcode.ui.components.glass.FloatingChrome + +/** + * The signed-in window: the mobile tabs minus Scan, as a tab bar under 600 wide and a 64-wide rail + * from there up, around the pane for the selected tab. The width class comes from the space this is + * given, not the window, so an off-screen render at a fixed width lays out exactly as the window does. + */ +@Composable +fun DesktopShell( + chat: @Composable (WidthClass) -> Unit, + money: @Composable (WidthClass) -> Unit, + you: @Composable (WidthClass) -> Unit, + avatar: (@Composable (Modifier) -> Unit)?, + modifier: Modifier = Modifier, + initialTab: NavBarButton = DesktopStartTab, +) { + var selectedTab by rememberSaveable { mutableStateOf(initialTab) } + BoxWithConstraints(modifier.fillMaxSize()) { + val widthClass = widthClassFor(maxWidth) + val pane: @Composable () -> Unit = { + when (selectedTab) { + NavBarButton.Wallet -> money(widthClass) + NavBarButton.TipCard -> you(widthClass) + else -> chat(widthClass) + } + } + if (widthClass == WidthClass.Compact) { + // As on the phone, the bar floats over the pane and the pane pads its end by the bar's + // height through LocalTabBarPadding. + val density = LocalDensity.current + var barHeight by remember { mutableStateOf(0.dp) } + CompositionLocalProvider( + LocalTabBarPadding provides PaddingValues(bottom = barHeight + TabBarBottomMargin), + ) { + Box(Modifier.fillMaxSize()) { pane() } + } + NavigationBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = FloatingChrome.horizontalInset) + .padding(bottom = TabBarBottomMargin) + .onSizeChanged { barHeight = with(density) { it.height.toDp() } }, + state = rememberNavigationBarState(selectedTab = selectedTab), + onButtonClick = { selectedTab = it }, + avatar = avatar, + tabs = DesktopTabs, + ) + } else { + Row(Modifier.fillMaxSize()) { + DesktopRail(selectedTab = selectedTab, onTabClick = { selectedTab = it }, avatar = avatar) + PaneDivider() + Box(Modifier.weight(1f).fillMaxSize()) { pane() } + } + } + } +} + +private val TabBarBottomMargin = 16.dp diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt new file mode 100644 index 0000000000..352724573d --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt @@ -0,0 +1,120 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.flipcash.app.balance.internal.WalletScreen +import com.flipcash.app.balance.internal.WalletViewModel +import com.flipcash.app.cardexpand.CardExpansionController +import com.flipcash.app.cardexpand.LocalCardExpansion +import com.flipcash.app.core.AppRoute +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull + +/** What the Money tab shows beside or over the wallet. */ +sealed interface MoneyDetail { + data class Currency(val mint: Mint) : MoneyDetail + /** A screen the wallet opens that has no JVM build yet. */ + data class Unavailable(val name: String) : MoneyDetail +} + +/** + * Money (node 12626:56). Compact and medium are one pane, capped at 560 and centred, and a currency + * opens over the wallet. Expanded keeps the wallet 400 wide and opens a currency in a detail pane + * beside it (Decisions 12631:459, card 3). + */ +@Composable +fun MoneyPane( + widthClass: WidthClass, + walletViewModel: WalletViewModel, + tokenViewModel: SelectTokenViewModel, + modifier: Modifier = Modifier, +) { + var detail by remember { mutableStateOf(null) } + + // The wallet opens a currency through the card-expand overlay, which the Android app root hosts. + // Desktop shows the currency in a pane instead, so it takes the tapped card's key and drops the + // expansion before the deck starts reorganising around it. + val cardExpansion = remember { CardExpansionController().apply { animationScale = { 0f } } } + LaunchedEffect(cardExpansion) { + snapshotFlow { cardExpansion.expandedKey } + .filterNotNull() + .collect { key -> + (key as? Mint)?.let { detail = MoneyDetail.Currency(it) } + cardExpansion.clear() + } + } + LaunchedEffect(walletViewModel) { + walletViewModel.eventFlow + .filterIsInstance() + .collect { event -> routeToDetail(event.screen)?.let { detail = it } } + } + LaunchedEffect(walletViewModel) { + walletViewModel.eventFlow + .filterIsInstance() + .collect { detail = MoneyDetail.Unavailable("Region selection") } + } + + val wallet: @Composable (Modifier) -> Unit = { m -> + CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { + Box(m.padding(top = LocalTitleBarInset.current)) { + WalletScreen(walletViewModel, tokenViewModel) + } + } + } + val detailPane: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> + Box(m, contentAlignment = Alignment.TopCenter) { + val shown = detail + PlaceholderPane( + title = when (shown) { + is MoneyDetail.Currency -> "Currency info" + is MoneyDetail.Unavailable -> shown.name + null -> "Select a currency" + }, + blockedBy = when (shown) { + is MoneyDetail.Currency -> "Currency info for ${shown.mint} is TokenInfoScreen in " + + ":apps:flipcash:shared:tokens androidMain (TokenInfoViewModel and its chart)." + is MoneyDetail.Unavailable -> "This screen has no desktop build yet." + null -> "A currency's info opens here." + }, + modifier = Modifier.widthIn(max = DesktopDimens.DetailMaxWidth), + onBack = onBack, + ) + } + } + + when (widthClass) { + WidthClass.Compact, WidthClass.Medium -> CenteredColumn(DesktopDimens.MoneySinglePaneMaxWidth) { + if (detail != null) detailPane(Modifier.fillMaxSize()) { detail = null } + else wallet(Modifier.fillMaxSize()) + } + WidthClass.Expanded -> Row(modifier.fillMaxSize()) { + wallet(Modifier.width(DesktopDimens.MoneyListExpanded).fillMaxHeight()) + PaneDivider() + detailPane(Modifier.weight(1f).fillMaxHeight(), detail?.let { { detail = null } }) + } + } +} + +/** Where a route the wallet opens lands on desktop; null for one desktop drops (the scanner). */ +internal fun routeToDetail(route: AppRoute): MoneyDetail? = when (route) { + is AppRoute.Token.Info -> MoneyDetail.Currency(route.mint) + is AppRoute.Tabs.Scanner -> null + else -> MoneyDetail.Unavailable(route::class.simpleName ?: "Screen") +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt new file mode 100644 index 0000000000..61497a16ee --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt @@ -0,0 +1,23 @@ +package com.flipcash.desktop.shell + +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.navigation.NavBarButton +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopLayoutTest { + @Test + fun `width classes switch at 600 and 840`() { + assertEquals(WidthClass.Compact, widthClassFor(400.dp)) + assertEquals(WidthClass.Compact, widthClassFor(599.dp)) + assertEquals(WidthClass.Medium, widthClassFor(600.dp)) + assertEquals(WidthClass.Medium, widthClassFor(839.dp)) + assertEquals(WidthClass.Expanded, widthClassFor(840.dp)) + } + + @Test + fun `desktop has no scan tab and opens to chat`() { + assertEquals(listOf(NavBarButton.Chats, NavBarButton.Wallet, NavBarButton.TipCard), DesktopTabs) + assertEquals(NavBarButton.Chats, DesktopStartTab) + } +} From 2c9a17e13966eb851e9ba5a031bfe6ee73727c96 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:36:44 -0400 Subject: [PATCH 03/18] feat(desktop): width classes for the three-pane shell --- .../flipcash/desktop/shell/DesktopLayout.kt | 24 ++++++++++++++----- .../flipcash/desktop/shell/DesktopPanes.kt | 4 ++-- .../com/flipcash/desktop/shell/MoneyPane.kt | 2 +- .../desktop/shell/DesktopLayoutTest.kt | 13 ++++++---- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt index 3d1ca3e633..e5982253a5 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -4,28 +4,40 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.core.navigation.NavBarButton -/** The window's width class (Decisions node 12631:459, card 1): Material's window size classes. */ -enum class WidthClass { Compact, Medium, Expanded } +/** + * The window's width class (widths node 13045:3284). Material's classes, plus Wide at 1100, where the + * context pane gets its own column instead of sliding over the main pane. + */ +enum class WidthClass { Compact, Medium, Expanded, Wide } fun widthClassFor(width: Dp): WidthClass = when { width < 600.dp -> WidthClass.Compact width < 840.dp -> WidthClass.Medium - else -> WidthClass.Expanded + width < 1100.dp -> WidthClass.Expanded + else -> WidthClass.Wide } +/** How the context pane (Info, Wallet, Currency, You) sits next to the main pane. */ +enum class ContextPaneMode { Column, Overlay } + +fun contextPaneModeFor(widthClass: WidthClass): ContextPaneMode = + if (widthClass == WidthClass.Wide) ContextPaneMode.Column else ContextPaneMode.Overlay + /** The mobile tabs minus Scan, which needs a camera: Chat, Money, You. Desktop opens to Chat. */ val DesktopTabs = listOf(NavBarButton.Chats, NavBarButton.Wallet, NavBarButton.TipCard) val DesktopStartTab = NavBarButton.Chats -/** The sizes the Figma frames set (Chats 12627:208, Money 12626:56). */ +/** The sizes the Figma frames set (window 13000:91, widths 13045:3284). */ object DesktopDimens { val MinWindowWidth = 400.dp val MinWindowHeight = 640.dp val RailWidth = 64.dp - val ChatListMedium = 280.dp - val ChatListExpanded = 360.dp + val SidebarMedium = 280.dp + val SidebarExpanded = 300.dp + val SidebarGlassInset = 8.dp + val ContextPaneWidth = 336.dp val MessagesMaxWidth = 720.dp val MoneySinglePaneMaxWidth = 560.dp diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt index 6a6f8a4198..83ae9a3d11 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt @@ -123,9 +123,9 @@ fun ChatPane(widthClass: WidthClass, modifier: Modifier = Modifier) { WidthClass.Compact -> if (conversationOpen) conversation(modifier.fillMaxSize()) { conversationOpen = false } else list(modifier.fillMaxSize()) - WidthClass.Medium, WidthClass.Expanded -> Row(modifier.fillMaxSize()) { + WidthClass.Medium, WidthClass.Expanded, WidthClass.Wide -> Row(modifier.fillMaxSize()) { val listWidth = - if (widthClass == WidthClass.Medium) DesktopDimens.ChatListMedium else DesktopDimens.ChatListExpanded + if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded list(Modifier.width(listWidth).fillMaxHeight()) PaneDivider() conversation(Modifier.weight(1f).fillMaxHeight(), null) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt index 352724573d..8441879825 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt @@ -104,7 +104,7 @@ fun MoneyPane( if (detail != null) detailPane(Modifier.fillMaxSize()) { detail = null } else wallet(Modifier.fillMaxSize()) } - WidthClass.Expanded -> Row(modifier.fillMaxSize()) { + WidthClass.Expanded, WidthClass.Wide -> Row(modifier.fillMaxSize()) { wallet(Modifier.width(DesktopDimens.MoneyListExpanded).fillMaxHeight()) PaneDivider() detailPane(Modifier.weight(1f).fillMaxHeight(), detail?.let { { detail = null } }) diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt index 61497a16ee..c92bc0db92 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt @@ -1,23 +1,26 @@ package com.flipcash.desktop.shell import androidx.compose.ui.unit.dp -import com.flipcash.app.core.navigation.NavBarButton import kotlin.test.Test import kotlin.test.assertEquals class DesktopLayoutTest { @Test - fun `width classes switch at 600 and 840`() { + fun `width classes switch at 600, 840 and 1100`() { assertEquals(WidthClass.Compact, widthClassFor(400.dp)) assertEquals(WidthClass.Compact, widthClassFor(599.dp)) assertEquals(WidthClass.Medium, widthClassFor(600.dp)) assertEquals(WidthClass.Medium, widthClassFor(839.dp)) assertEquals(WidthClass.Expanded, widthClassFor(840.dp)) + assertEquals(WidthClass.Expanded, widthClassFor(1099.dp)) + assertEquals(WidthClass.Wide, widthClassFor(1100.dp)) } @Test - fun `desktop has no scan tab and opens to chat`() { - assertEquals(listOf(NavBarButton.Chats, NavBarButton.Wallet, NavBarButton.TipCard), DesktopTabs) - assertEquals(NavBarButton.Chats, DesktopStartTab) + fun `context pane overlays below wide`() { + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Compact)) + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Medium)) + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Expanded)) + assertEquals(ContextPaneMode.Column, contextPaneModeFor(WidthClass.Wide)) } } From bd2c0ee17445ee9fc06344657ebe598e40c35170 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:37:45 -0400 Subject: [PATCH 04/18] feat(desktop): pane state for sidebar, main and context --- .../flipcash/desktop/shell/DesktopNavState.kt | 86 +++++++++++++++ .../com/flipcash/desktop/shell/MoneyPane.kt | 42 +++----- .../desktop/shell/DesktopNavStateTest.kt | 100 ++++++++++++++++++ 3 files changed, 199 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt new file mode 100644 index 0000000000..696c1a52b2 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt @@ -0,0 +1,86 @@ +package com.flipcash.desktop.shell + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.flipcash.app.core.AppRoute +import com.getcode.solana.keys.Mint + +/** What the main pane shows. The leaderboard replaces the chat until Back (spec: Leaderboard). */ +sealed interface MainContent { + data object Chat : MainContent + data object Leaderboard : MainContent +} + +/** What the context pane shows. It holds one thing at a time (context node 13041:1986). */ +sealed interface ContextContent { + data object Info : ContextContent + data object Wallet : ContextContent + data object You : ContextContent + + /** A currency, with what the pane showed before it so Back returns there. */ + data class Currency(val mint: Mint, val from: ContextContent?) : ContextContent + + /** A screen with no JVM build yet. */ + data class Unavailable(val name: String) : ContextContent +} + +/** + * What each pane of the window shows. Plain state so the rules are tested without Compose; the + * fields are snapshot state so the shell recomposes when they change. + */ +class DesktopNavState { + var main: MainContent by mutableStateOf(MainContent.Chat) + var context: ContextContent? by mutableStateOf(null) + + /** Below 600 the window shows the list or the chat; true while it shows the chat. */ + var compactShowsChat: Boolean by mutableStateOf(false) + + fun toggleInfo() { + context = if (context == ContextContent.Info) null else ContextContent.Info + } + + fun openWallet() { + context = ContextContent.Wallet + } + + fun openYou() { + context = ContextContent.You + } + + fun openCurrency(mint: Mint) { + context = ContextContent.Currency(mint, from = context) + } + + /** A route a shared screen asks for. Desktop has no scanner, so that one is dropped. */ + fun openRoute(route: AppRoute) { + when (route) { + is AppRoute.Token.Info -> openCurrency(route.mint) + is AppRoute.Tabs.Scanner -> Unit + else -> context = ContextContent.Unavailable(route::class.simpleName ?: "Screen") + } + } + + /** One step back: a currency to where it came from, the leaderboard to the chat, the compact chat to the list. */ + fun back(): Boolean { + val shown = context + when { + shown is ContextContent.Currency -> context = shown.from + main != MainContent.Chat -> main = MainContent.Chat + compactShowsChat -> compactShowsChat = false + else -> return false + } + return true + } + + fun closeContext() { + context = null + } + + /** Esc closes the context pane only when it slides over the main pane; a column stays put. */ + fun onEscape(mode: ContextPaneMode): Boolean { + if (mode != ContextPaneMode.Overlay || context == null) return false + context = null + return true + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt index 8441879825..17824a5e03 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt @@ -11,9 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,19 +19,11 @@ import com.flipcash.app.balance.internal.WalletScreen import com.flipcash.app.balance.internal.WalletViewModel import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion -import com.flipcash.app.core.AppRoute import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.getcode.solana.keys.Mint import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull -/** What the Money tab shows beside or over the wallet. */ -sealed interface MoneyDetail { - data class Currency(val mint: Mint) : MoneyDetail - /** A screen the wallet opens that has no JVM build yet. */ - data class Unavailable(val name: String) : MoneyDetail -} - /** * Money (node 12626:56). Compact and medium are one pane, capped at 560 and centred, and a currency * opens over the wallet. Expanded keeps the wallet 400 wide and opens a currency in a detail pane @@ -46,7 +36,8 @@ fun MoneyPane( tokenViewModel: SelectTokenViewModel, modifier: Modifier = Modifier, ) { - var detail by remember { mutableStateOf(null) } + val nav = remember { DesktopNavState() } + val detail = nav.context // The wallet opens a currency through the card-expand overlay, which the Android app root hosts. // Desktop shows the currency in a pane instead, so it takes the tapped card's key and drops the @@ -56,19 +47,19 @@ fun MoneyPane( snapshotFlow { cardExpansion.expandedKey } .filterNotNull() .collect { key -> - (key as? Mint)?.let { detail = MoneyDetail.Currency(it) } + (key as? Mint)?.let { nav.openCurrency(it) } cardExpansion.clear() } } LaunchedEffect(walletViewModel) { walletViewModel.eventFlow .filterIsInstance() - .collect { event -> routeToDetail(event.screen)?.let { detail = it } } + .collect { event -> nav.openRoute(event.screen) } } LaunchedEffect(walletViewModel) { walletViewModel.eventFlow .filterIsInstance() - .collect { detail = MoneyDetail.Unavailable("Region selection") } + .collect { nav.context = ContextContent.Unavailable("Region selection") } } val wallet: @Composable (Modifier) -> Unit = { m -> @@ -83,15 +74,15 @@ fun MoneyPane( val shown = detail PlaceholderPane( title = when (shown) { - is MoneyDetail.Currency -> "Currency info" - is MoneyDetail.Unavailable -> shown.name - null -> "Select a currency" + is ContextContent.Currency -> "Currency info" + is ContextContent.Unavailable -> shown.name + else -> "Select a currency" }, blockedBy = when (shown) { - is MoneyDetail.Currency -> "Currency info for ${shown.mint} is TokenInfoScreen in " + + is ContextContent.Currency -> "Currency info for ${shown.mint} is TokenInfoScreen in " + ":apps:flipcash:shared:tokens androidMain (TokenInfoViewModel and its chart)." - is MoneyDetail.Unavailable -> "This screen has no desktop build yet." - null -> "A currency's info opens here." + is ContextContent.Unavailable -> "This screen has no desktop build yet." + else -> "A currency's info opens here." }, modifier = Modifier.widthIn(max = DesktopDimens.DetailMaxWidth), onBack = onBack, @@ -101,20 +92,13 @@ fun MoneyPane( when (widthClass) { WidthClass.Compact, WidthClass.Medium -> CenteredColumn(DesktopDimens.MoneySinglePaneMaxWidth) { - if (detail != null) detailPane(Modifier.fillMaxSize()) { detail = null } + if (detail != null) detailPane(Modifier.fillMaxSize()) { nav.closeContext() } else wallet(Modifier.fillMaxSize()) } WidthClass.Expanded, WidthClass.Wide -> Row(modifier.fillMaxSize()) { wallet(Modifier.width(DesktopDimens.MoneyListExpanded).fillMaxHeight()) PaneDivider() - detailPane(Modifier.weight(1f).fillMaxHeight(), detail?.let { { detail = null } }) + detailPane(Modifier.weight(1f).fillMaxHeight(), detail?.let { { nav.closeContext() } }) } } } - -/** Where a route the wallet opens lands on desktop; null for one desktop drops (the scanner). */ -internal fun routeToDetail(route: AppRoute): MoneyDetail? = when (route) { - is AppRoute.Token.Info -> MoneyDetail.Currency(route.mint) - is AppRoute.Tabs.Scanner -> null - else -> MoneyDetail.Unavailable(route::class.simpleName ?: "Screen") -} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt new file mode 100644 index 0000000000..594ea542d0 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt @@ -0,0 +1,100 @@ +package com.flipcash.desktop.shell + +import com.flipcash.app.core.AppRoute +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopNavStateTest { + private val mintA = Mint.usdf + + @Test + fun `currency opened from wallet goes back to wallet`() { + val s = DesktopNavState() + s.openWallet() + s.openCurrency(mintA) + assertTrue(s.back()) + assertEquals(ContextContent.Wallet, s.context) + } + + @Test + fun `currency opened with nothing open goes back to closed`() { + val s = DesktopNavState() + s.openCurrency(mintA) + assertTrue(s.back()) + assertNull(s.context) + } + + @Test + fun `info toggles`() { + val s = DesktopNavState() + s.toggleInfo() + assertEquals(ContextContent.Info, s.context) + s.toggleInfo() + assertNull(s.context) + } + + @Test + fun `info replaces wallet rather than closing it`() { + val s = DesktopNavState() + s.openWallet() + s.toggleInfo() + assertEquals(ContextContent.Info, s.context) + } + + @Test + fun `escape closes the overlay but not the column`() { + val s = DesktopNavState() + s.openWallet() + assertFalse(s.onEscape(ContextPaneMode.Column)) + assertEquals(ContextContent.Wallet, s.context) + assertTrue(s.onEscape(ContextPaneMode.Overlay)) + assertNull(s.context) + } + + @Test + fun `escape with nothing open does nothing`() { + val s = DesktopNavState() + s.compactShowsChat = true + assertFalse(s.onEscape(ContextPaneMode.Overlay)) + assertTrue(s.compactShowsChat) + } + + @Test + fun `token route opens currency, scanner is dropped`() { + val s = DesktopNavState() + s.openRoute(AppRoute.Token.Info(mintA)) + assertEquals(mintA, (s.context as ContextContent.Currency).mint) + + val t = DesktopNavState() + t.openRoute(AppRoute.Tabs.Scanner) + assertNull(t.context) + } + + @Test + fun `other routes show as unavailable`() { + val s = DesktopNavState() + s.openRoute(AppRoute.Token.Transactions(mintA)) + assertTrue(s.context is ContextContent.Unavailable) + } + + @Test + fun `back from leaderboard returns to chat`() { + val s = DesktopNavState() + s.main = MainContent.Leaderboard + assertTrue(s.back()) + assertEquals(MainContent.Chat, s.main) + } + + @Test + fun `back from the compact chat returns to the list`() { + val s = DesktopNavState() + s.compactShowsChat = true + assertTrue(s.back()) + assertFalse(s.compactShowsChat) + assertFalse(s.back()) + } +} From 160436c3b4e72a351bd371e15d98cca021095d7c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:48:01 -0400 Subject: [PATCH 05/18] feat(desktop): three-pane shell with sidebar footer and context pane --- apps/desktop/build.gradle.kts | 3 + .../flipcash/desktop/DesktopMainContent.kt | 55 +++-- .../main/kotlin/com/flipcash/desktop/Main.kt | 2 +- .../com/flipcash/desktop/SnapshotMain.kt | 14 +- .../com/flipcash/desktop/shell/ContextPane.kt | 46 ++++ .../flipcash/desktop/shell/DesktopLayout.kt | 11 - .../desktop/shell/DesktopNavigation.kt | 113 ---------- .../flipcash/desktop/shell/DesktopPanes.kt | 69 ++---- .../flipcash/desktop/shell/DesktopShell.kt | 210 +++++++++++++----- .../com/flipcash/desktop/shell/MoneyPane.kt | 64 +----- .../flipcash/desktop/shell/SidebarFooter.kt | 86 +++++++ .../desktop/shell/DesktopShellTest.kt | 131 +++++++++++ 12 files changed, 497 insertions(+), 307 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt delete mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 7b180d1994..a5bc65ca7e 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -22,6 +22,7 @@ metro { dependencies { implementation(compose.desktop.currentOs) implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) implementation(project(":services:opencode")) implementation(project(":apps:flipcash:features:balance")) implementation(project(":apps:flipcash:core")) @@ -57,6 +58,8 @@ dependencies { testImplementation(project(":apps:flipcash:startup-contract")) testImplementation(libs.kotlin.test.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(compose.desktop.uiTestJUnit4) + testImplementation(compose.desktop.currentOs) } tasks.withType().configureEach { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt index 501de56d81..fa9d109a4d 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -11,15 +11,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.flipcash.app.analytics.LocalAnalytics -import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.theme.FlipcashTheme import com.flipcash.app.tokens.ui.SelectTokenViewModel -import com.flipcash.desktop.shell.ChatPane +import com.flipcash.desktop.shell.ChatListPlaceholder +import com.flipcash.desktop.shell.ContextContent +import com.flipcash.desktop.shell.ContextPane +import com.flipcash.desktop.shell.ConversationPlaceholder +import com.flipcash.desktop.shell.DesktopNavState import com.flipcash.desktop.shell.DesktopShell -import com.flipcash.desktop.shell.DesktopStartTab -import com.flipcash.desktop.shell.MoneyPane -import com.flipcash.desktop.shell.YouPane +import com.flipcash.desktop.shell.SidebarFooter +import com.flipcash.desktop.shell.WalletPane import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme @@ -30,9 +32,15 @@ import com.getcode.utils.network.LocalNetworkObserver * The signed-in window, with the locals and window background that Android's app root provides * (`App.kt` paints [CodeTheme.colors.background] behind every screen). The graph doesn't scope view * models, so each read of [DesktopGraph.walletViewModel] builds a new one; they're held here. + * [initialContext] opens the context pane on first composition, for snapshots. */ @Composable -fun DesktopMainContent(graph: DesktopGraph, initialTab: NavBarButton = DesktopStartTab) { +fun DesktopMainContent( + graph: DesktopGraph, + initialContext: ContextContent? = null, + onSettings: () -> Unit = {}, +) { + val nav = remember { DesktopNavState().apply { context = initialContext } } val walletViewModel = remember { graph.walletViewModel } val tokenViewModel = remember { graph.selectTokenViewModel } LaunchedEffect(tokenViewModel) { @@ -40,6 +48,9 @@ fun DesktopMainContent(graph: DesktopGraph, initialTab: NavBarButton = DesktopSt } val user by graph.userManager.state.collectAsState() val displayName = user.userProfile?.displayName.orEmpty() + val tokenState by tokenViewModel.stateFlow.collectAsState() + // The same total the wallet header shows. + val balance = tokenState.totalBalance?.nativeAmount?.formatted().orEmpty() FlipcashTheme { CompositionLocalProvider( LocalExchange provides graph.exchange, @@ -49,13 +60,31 @@ fun DesktopMainContent(graph: DesktopGraph, initialTab: NavBarButton = DesktopSt ) { Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { DesktopShell( - chat = { ChatPane(it) }, - money = { MoneyPane(it, walletViewModel, tokenViewModel) }, - you = { YouPane() }, - // Initials on the avatar gradient. The photo needs the blob URL resolver the - // Android app provides, which desktop doesn't have yet. - avatar = { modifier -> ContactAvatar(photoUri = null, displayName = displayName, modifier = modifier) }, - initialTab = initialTab, + nav = nav, + floatingSidebar = isMac, + sidebar = { m -> ChatListPlaceholder(onOpenChat = { nav.compactShowsChat = true }, modifier = m) }, + main = { m -> ConversationPlaceholder(m) }, + context = { content, m -> + ContextPane( + content = content, + nav = nav, + wallet = { wm -> WalletPane(nav, walletViewModel, tokenViewModel, wm) }, + modifier = m, + ) + }, + footer = { m -> + SidebarFooter( + displayName = displayName, + balance = balance, + onYou = nav::openYou, + onWallet = nav::openWallet, + onSettings = onSettings, + modifier = m, + // Initials on the avatar gradient. The photo needs the blob URL resolver the + // Android app provides, which desktop doesn't have yet. + avatar = { am -> ContactAvatar(photoUri = null, displayName = displayName, modifier = am) }, + ) + }, ) } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index c138f10132..8668f03f97 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.runBlocking import java.awt.Dimension import java.lang.management.ManagementFactory -private val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") +internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") // Height of the macOS title bar the content runs under, which the traffic lights sit in. private val MacTitleBarHeight = 28.dp diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt index 8f0248ba5e..731d123f30 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt @@ -3,7 +3,7 @@ package com.flipcash.desktop import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.use -import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.desktop.shell.ContextContent import dev.zacsweers.metro.createGraph import kotlinx.coroutines.runBlocking import java.io.File @@ -12,7 +12,7 @@ import org.jetbrains.skia.EncodedImageFormat /** * Renders a screen off-screen through the same graph as [main] and writes a PNG. - * Usage: `./gradlew :apps:desktop:snapshot -Pout= -Pscreen=chat|money|you|link -Pwidth= -Pheight=`. + * Usage: `./gradlew :apps:desktop:snapshot -Pout= -Pscreen=shell|shell-wallet|you|link -Pwidth= -Pheight=`. * The link screen skips sign-in. Width picks the layout exactly as resizing the window does. */ fun main(args: Array) { @@ -21,10 +21,10 @@ fun main(args: Array) { val width = args.getOrNull(2)?.toIntOrNull() ?: 1040 val height = args.getOrNull(3)?.toIntOrNull() ?: 760 val linkScreen = screen == "link" - val tab = when (screen) { - "money", "wallet" -> NavBarButton.Wallet - "you" -> NavBarButton.TipCard - else -> NavBarButton.Chats + val context = when (screen) { + "shell-wallet", "money", "wallet" -> ContextContent.Wallet + "you" -> ContextContent.You + else -> null } val graph = createGraph() runBlocking { @@ -33,7 +33,7 @@ fun main(args: Array) { } val scale = 2f ImageComposeScene(width = (width * scale).toInt(), height = (height * scale).toInt(), density = Density(scale)) { - if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopMainContent(graph, initialTab = tab) + if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopMainContent(graph, initialContext = context) }.use { scene -> var nanos = 0L repeat(80) { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt new file mode 100644 index 0000000000..bd2f3d1f4d --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt @@ -0,0 +1,46 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * What the context pane shows for [content]. Wallet is the real wallet; the rest are placeholders + * naming the plan that ports them (roadmap: Info and You in Plan 3b, Currency in Plan 5). + */ +@Composable +fun ContextPane( + content: ContextContent, + nav: DesktopNavState, + wallet: @Composable (Modifier) -> Unit, + modifier: Modifier = Modifier, +) { + val back: (() -> Unit)? = if (content is ContextContent.Currency && content.from != null) { + { nav.back() } + } else null + Box(modifier.padding(top = LocalTitleBarInset.current)) { + when (content) { + ContextContent.Wallet -> wallet(Modifier) + ContextContent.Info -> PlaceholderPane( + title = "Info", + blockedBy = "Chat Info needs the chat module on desktop (roadmap Plan 3b).", + ) + ContextContent.You -> PlaceholderPane( + title = "You", + blockedBy = "The profile screen lives in :apps:flipcash:features:myaccount, Android-only " + + "until roadmap Plan 3b.", + ) + is ContextContent.Currency -> PlaceholderPane( + title = "Currency info", + blockedBy = "TokenInfoScreen is in :apps:flipcash:shared:tokens androidMain (roadmap Plan 5).", + onBack = back, + ) + is ContextContent.Unavailable -> PlaceholderPane( + title = content.name, + blockedBy = "This screen has no desktop build yet.", + onBack = { nav.back() }, + ) + } + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt index e5982253a5..d8b70492c0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -2,7 +2,6 @@ package com.flipcash.desktop.shell import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.flipcash.app.core.navigation.NavBarButton /** * The window's width class (widths node 13045:3284). Material's classes, plus Wide at 1100, where the @@ -23,24 +22,14 @@ enum class ContextPaneMode { Column, Overlay } fun contextPaneModeFor(widthClass: WidthClass): ContextPaneMode = if (widthClass == WidthClass.Wide) ContextPaneMode.Column else ContextPaneMode.Overlay -/** The mobile tabs minus Scan, which needs a camera: Chat, Money, You. Desktop opens to Chat. */ -val DesktopTabs = listOf(NavBarButton.Chats, NavBarButton.Wallet, NavBarButton.TipCard) -val DesktopStartTab = NavBarButton.Chats - /** The sizes the Figma frames set (window 13000:91, widths 13045:3284). */ object DesktopDimens { val MinWindowWidth = 400.dp val MinWindowHeight = 640.dp - val RailWidth = 64.dp - val SidebarMedium = 280.dp val SidebarExpanded = 300.dp val SidebarGlassInset = 8.dp val ContextPaneWidth = 336.dp val MessagesMaxWidth = 720.dp - - val MoneySinglePaneMaxWidth = 560.dp - val MoneyListExpanded = 400.dp - val DetailMaxWidth = 720.dp } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt deleted file mode 100644 index 945de22597..0000000000 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavigation.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.flipcash.desktop.shell - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.TooltipArea -import androidx.compose.foundation.TooltipPlacement -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.ui.NavBarGlyph -import com.flipcash.app.core.ui.label -import com.getcode.theme.CodeTheme -import org.jetbrains.compose.resources.stringResource - -/** - * Height of the area the window's title bar draws over. On macOS the content runs under a transparent - * title bar, so whatever sits at the top of a pane starts below the traffic lights; elsewhere it is 0. - */ -val LocalTitleBarInset = compositionLocalOf { 0.dp } - -/** - * The 64-wide icon rail that replaces the tab bar from 600 wide (Decisions 12631:459, card 2): Chat - * and Money at the top, the account's avatar (You) pinned at the bottom. The glyphs are the tab bar's - * own; each tab's name moves into a tooltip. - */ -@Composable -fun DesktopRail( - selectedTab: NavBarButton, - onTabClick: (NavBarButton) -> Unit, - avatar: (@Composable (Modifier) -> Unit)?, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .width(DesktopDimens.RailWidth) - .fillMaxHeight() - .padding(top = LocalTitleBarInset.current + 12.dp, bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - DesktopTabs.filter { it != NavBarButton.TipCard }.forEach { tab -> - RailItem(tab, selected = tab == selectedTab, onClick = { onTabClick(tab) }) { - NavBarGlyph(button = tab, selected = tab == selectedTab, badgeCount = 0) - } - } - Spacer(Modifier.weight(1f)) - val you = NavBarButton.TipCard - RailItem(you, selected = selectedTab == you, onClick = { onTabClick(you) }, highlight = false) { - NavBarGlyph(button = you, selected = selectedTab == you, badgeCount = 0, avatar = avatar) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun RailItem( - tab: NavBarButton, - selected: Boolean, - onClick: () -> Unit, - // The avatar carries its own selection ring, so it gets no pill behind it. - highlight: Boolean = true, - glyph: @Composable () -> Unit, -) { - TooltipArea( - tooltip = { - Text( - text = stringResource(tab.label), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textMain, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(CodeTheme.colors.surfaceVariant) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - }, - tooltipPlacement = TooltipPlacement.ComponentRect( - anchor = Alignment.CenterEnd, - alignment = Alignment.CenterEnd, - offset = DpOffset(8.dp, 0.dp), - ), - ) { - Box( - modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (selected && highlight) CodeTheme.colors.surfaceVariant else Color.Transparent) - .clickable(role = Role.Tab, onClick = onClick) - .testTag("rail_${tab.name}"), - contentAlignment = Alignment.Center, - ) { - glyph() - } - } -} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt index 83ae9a3d11..4594185bc0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt @@ -17,10 +17,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape 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.platform.testTag @@ -94,55 +90,25 @@ private const val ChatBlockers = "The chat list and conversation live in :apps:flipcash:features:messenger and " + ":apps:flipcash:shared:chat / :chat-ui, which build for Android only." -/** - * Chat (Chats node 12627:208): list and conversation side by side from 600 wide, the list 280 wide at - * medium and 360 at expanded, messages capped at 720. At compact a conversation opens over the list. - */ +/** The chat list in the sidebar, until the chat module builds for desktop (roadmap Plan 3b). */ @Composable -fun ChatPane(widthClass: WidthClass, modifier: Modifier = Modifier) { - var conversationOpen by remember { mutableStateOf(false) } - val list: @Composable (Modifier) -> Unit = { m -> - PlaceholderPane( - title = "Chats", - blockedBy = ChatBlockers, - modifier = m, - action = "Open a conversation" to { conversationOpen = true }, - ) - } - val conversation: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> - Box(m, contentAlignment = Alignment.TopCenter) { - PlaceholderPane( - title = "Conversation", - blockedBy = ChatBlockers, - modifier = Modifier.widthIn(max = DesktopDimens.MessagesMaxWidth), - onBack = onBack, - ) - } - } - when (widthClass) { - WidthClass.Compact -> - if (conversationOpen) conversation(modifier.fillMaxSize()) { conversationOpen = false } - else list(modifier.fillMaxSize()) - WidthClass.Medium, WidthClass.Expanded, WidthClass.Wide -> Row(modifier.fillMaxSize()) { - val listWidth = - if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded - list(Modifier.width(listWidth).fillMaxHeight()) - PaneDivider() - conversation(Modifier.weight(1f).fillMaxHeight(), null) - } - } +fun ChatListPlaceholder(onOpenChat: () -> Unit, modifier: Modifier = Modifier) { + PlaceholderPane( + title = "Chats", + blockedBy = ChatBlockers, + modifier = modifier, + action = "Open a conversation" to onOpenChat, + ) } -/** You: the mobile You screen, with Settings behind its gear. */ +/** The conversation in the main pane, capped at the message column width. */ @Composable -fun YouPane(modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { +fun ConversationPlaceholder(modifier: Modifier = Modifier) { + Box(modifier, contentAlignment = Alignment.TopCenter) { PlaceholderPane( - title = "You", - blockedBy = "The You screen (tip card and profile) lives in :apps:flipcash:features:tipping " + - "and :apps:flipcash:features:myaccount, and Settings in :apps:flipcash:features:settings, " + - "all Android-only.", - modifier = Modifier.widthIn(max = DesktopDimens.MoneySinglePaneMaxWidth), + title = "Conversation", + blockedBy = ChatBlockers, + modifier = Modifier.widthIn(max = DesktopDimens.MessagesMaxWidth), ) } } @@ -156,10 +122,3 @@ fun PaneDivider(modifier: Modifier = Modifier) { .border(0.5.dp, CodeTheme.colors.divider), ) } - -@Composable -internal fun CenteredColumn(maxWidth: androidx.compose.ui.unit.Dp, content: @Composable () -> Unit) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Box(Modifier.widthIn(max = maxWidth).fillMaxWidth()) { content() } - } -} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index f687ed8224..7b68e1d6ff 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -1,82 +1,190 @@ package com.flipcash.desktop.shell +import androidx.compose.foundation.background +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.BoxWithConstraints -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.ui.NavigationBar -import com.flipcash.app.core.ui.rememberNavigationBarState -import com.getcode.ui.components.glass.FloatingChrome +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState /** - * The signed-in window: the mobile tabs minus Scan, as a tab bar under 600 wide and a 64-wide rail - * from there up, around the pane for the selected tab. The width class comes from the space this is - * given, not the window, so an off-screen render at a fixed width lays out exactly as the window does. + * Height of the area the window's title bar draws over. On macOS the content runs under a transparent + * title bar, so whatever sits at the top of a pane starts below the traffic lights; elsewhere it is 0. + */ +val LocalTitleBarInset = compositionLocalOf { 0.dp } + +/** + * The signed-in window (macOS window node 13000:91, widths node 13045:3284): the chat list with its + * footer, the main pane, and the context pane that [nav] opens. The width class comes from the space + * this is given, not the window, so an off-screen render at a fixed width lays out as the window does. + * + * Wide gives the context pane its own column; below that it slides over the main pane with a scrim. + * Compact shows the list or the main pane, never both. [floatingSidebar] draws the sidebar as the + * macOS 26 glass panel inset from the window edges; elsewhere it is a flat surface. */ @Composable fun DesktopShell( - chat: @Composable (WidthClass) -> Unit, - money: @Composable (WidthClass) -> Unit, - you: @Composable (WidthClass) -> Unit, - avatar: (@Composable (Modifier) -> Unit)?, + nav: DesktopNavState, + sidebar: @Composable (Modifier) -> Unit, + main: @Composable (Modifier) -> Unit, + context: @Composable (ContextContent, Modifier) -> Unit, + footer: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, - initialTab: NavBarButton = DesktopStartTab, + title: String = "", + floatingSidebar: Boolean = false, ) { - var selectedTab by rememberSaveable { mutableStateOf(initialTab) } BoxWithConstraints(modifier.fillMaxSize()) { val widthClass = widthClassFor(maxWidth) - val pane: @Composable () -> Unit = { - when (selectedTab) { - NavBarButton.Wallet -> money(widthClass) - NavBarButton.TipCard -> you(widthClass) - else -> chat(widthClass) + val mode = contextPaneModeFor(widthClass) + val hazeState = rememberHazeState() + val shown = nav.context + + val sidebarColumn: @Composable (Modifier) -> Unit = { m -> + Column(m.testTag("sidebar")) { + sidebar(Modifier.weight(1f).fillMaxWidth().padding(top = LocalTitleBarInset.current)) + footer(Modifier.fillMaxWidth()) + } + } + val mainWithToolbar: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> + Column(m.testTag("main")) { + Toolbar(title = title, onBack = onBack, onInfo = nav::toggleInfo) + main(Modifier.weight(1f).fillMaxWidth()) } } + val contextPane: @Composable (ContextContent, Modifier) -> Unit = { content, m -> + Box(m.background(CodeTheme.colors.background)) { context(content, Modifier.fillMaxSize()) } + } + if (widthClass == WidthClass.Compact) { - // As on the phone, the bar floats over the pane and the pane pads its end by the bar's - // height through LocalTabBarPadding. - val density = LocalDensity.current - var barHeight by remember { mutableStateOf(0.dp) } - CompositionLocalProvider( - LocalTabBarPadding provides PaddingValues(bottom = barHeight + TabBarBottomMargin), - ) { - Box(Modifier.fillMaxSize()) { pane() } + when { + shown != null -> contextPane(shown, Modifier.fillMaxSize().testTag("context_overlay")) + nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize()) { nav.compactShowsChat = false } + else -> sidebarColumn(Modifier.fillMaxSize()) } - NavigationBar( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = FloatingChrome.horizontalInset) - .padding(bottom = TabBarBottomMargin) - .onSizeChanged { barHeight = with(density) { it.height.toDp() } }, - state = rememberNavigationBarState(selectedTab = selectedTab), - onButtonClick = { selectedTab = it }, - avatar = avatar, - tabs = DesktopTabs, - ) - } else { - Row(Modifier.fillMaxSize()) { - DesktopRail(selectedTab = selectedTab, onTabClick = { selectedTab = it }, avatar = avatar) + return@BoxWithConstraints + } + + val sidebarWidth = if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded + Row(Modifier.fillMaxSize()) { + if (floatingSidebar) { + // The glass panel floats over the window's background; the panes beside it are the + // haze source, so the panel tints what scrolls under its rounded edge. + Box(Modifier.width(sidebarWidth + DesktopDimens.SidebarGlassInset).fillMaxHeight()) { + sidebarColumn( + Modifier + .padding(DesktopDimens.SidebarGlassInset) + .padding(end = 0.dp) + .fillMaxSize() + .floatingGlass(hazeState, RoundedCornerShape(16.dp)), + ) + } + } else { + sidebarColumn(Modifier.width(sidebarWidth).fillMaxHeight()) + PaneDivider() + } + Box(Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { + mainWithToolbar(Modifier.fillMaxSize(), null) + if (shown != null && mode == ContextPaneMode.Overlay) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = nav::closeContext, + ) + .testTag("context_scrim"), + ) + contextPane( + shown, + Modifier + .align(Alignment.TopEnd) + .width(DesktopDimens.ContextPaneWidth) + .fillMaxHeight() + .testTag("context_overlay"), + ) + } + } + if (shown != null && mode == ContextPaneMode.Column) { PaneDivider() - Box(Modifier.weight(1f).fillMaxSize()) { pane() } + contextPane( + shown, + Modifier.width(DesktopDimens.ContextPaneWidth).fillMaxHeight().testTag("context_pane"), + ) } } } } -private val TabBarBottomMargin = 16.dp +/** The bar over the main pane: the chat's title, and ⓘ for its Info. Back appears at compact. */ +@Composable +private fun Toolbar(title: String, onBack: (() -> Unit)?, onInfo: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = LocalTitleBarInset.current) + .height(44.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (onBack != null) { + Text( + text = "‹ Chats", + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClick = onBack) + .padding(horizontal = 6.dp, vertical = 4.dp) + .testTag("compact_back"), + ) + } + Text( + text = title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onInfo) + .testTag("toolbar_info"), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Info, contentDescription = "Info", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt index 17824a5e03..adb996351c 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt @@ -1,19 +1,11 @@ package com.flipcash.desktop.shell import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.flipcash.app.balance.internal.WalletScreen import com.flipcash.app.balance.internal.WalletViewModel @@ -25,23 +17,19 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull /** - * Money (node 12626:56). Compact and medium are one pane, capped at 560 and centred, and a currency - * opens over the wallet. Expanded keeps the wallet 400 wide and opens a currency in a detail pane - * beside it (Decisions 12631:459, card 3). + * Wallet in the context pane (node 13000:91): the mobile wallet in one column. A tapped currency card + * drills to that currency in the same pane, and the wallet's other routes open through [nav]. */ @Composable -fun MoneyPane( - widthClass: WidthClass, +fun WalletPane( + nav: DesktopNavState, walletViewModel: WalletViewModel, tokenViewModel: SelectTokenViewModel, modifier: Modifier = Modifier, ) { - val nav = remember { DesktopNavState() } - val detail = nav.context - // The wallet opens a currency through the card-expand overlay, which the Android app root hosts. - // Desktop shows the currency in a pane instead, so it takes the tapped card's key and drops the - // expansion before the deck starts reorganising around it. + // Desktop shows the currency in the context pane instead, so it takes the tapped card's key and + // drops the expansion before the deck starts reorganising around it. val cardExpansion = remember { CardExpansionController().apply { animationScale = { 0f } } } LaunchedEffect(cardExpansion) { snapshotFlow { cardExpansion.expandedKey } @@ -62,43 +50,7 @@ fun MoneyPane( .collect { nav.context = ContextContent.Unavailable("Region selection") } } - val wallet: @Composable (Modifier) -> Unit = { m -> - CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { - Box(m.padding(top = LocalTitleBarInset.current)) { - WalletScreen(walletViewModel, tokenViewModel) - } - } - } - val detailPane: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> - Box(m, contentAlignment = Alignment.TopCenter) { - val shown = detail - PlaceholderPane( - title = when (shown) { - is ContextContent.Currency -> "Currency info" - is ContextContent.Unavailable -> shown.name - else -> "Select a currency" - }, - blockedBy = when (shown) { - is ContextContent.Currency -> "Currency info for ${shown.mint} is TokenInfoScreen in " + - ":apps:flipcash:shared:tokens androidMain (TokenInfoViewModel and its chart)." - is ContextContent.Unavailable -> "This screen has no desktop build yet." - else -> "A currency's info opens here." - }, - modifier = Modifier.widthIn(max = DesktopDimens.DetailMaxWidth), - onBack = onBack, - ) - } - } - - when (widthClass) { - WidthClass.Compact, WidthClass.Medium -> CenteredColumn(DesktopDimens.MoneySinglePaneMaxWidth) { - if (detail != null) detailPane(Modifier.fillMaxSize()) { nav.closeContext() } - else wallet(Modifier.fillMaxSize()) - } - WidthClass.Expanded, WidthClass.Wide -> Row(modifier.fillMaxSize()) { - wallet(Modifier.width(DesktopDimens.MoneyListExpanded).fillMaxHeight()) - PaneDivider() - detailPane(Modifier.weight(1f).fillMaxHeight(), detail?.let { { nav.closeContext() } }) - } + CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { + Box(modifier) { WalletScreen(walletViewModel, tokenViewModel) } } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt new file mode 100644 index 0000000000..4d73a000ed --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt @@ -0,0 +1,86 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +/** + * The bottom of the chat list (node 13000:91): avatar and name open You, the balance opens Wallet, + * the gear opens Settings. The balance is the wallet's total, so it reads the same as Wallet's header. + */ +@Composable +fun SidebarFooter( + displayName: String, + balance: String, + onYou: () -> Unit, + onWallet: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (Modifier) -> Unit = {}, +) { + Row( + modifier = modifier.height(56.dp).padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onYou) + .padding(6.dp) + .testTag("footer_you"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + avatar(Modifier.size(28.dp)) + Text( + text = displayName, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = balance, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onWallet) + .padding(horizontal = 8.dp, vertical = 6.dp) + .testTag("footer_balance"), + ) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onSettings) + .testTag("footer_settings"), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Settings, contentDescription = "Settings", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } + } +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt new file mode 100644 index 0000000000..e111642ab1 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt @@ -0,0 +1,131 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +// The test window is 1024 x 768, so at the wide widths these tests use the trailing controls sit +// outside it; the click goes through the node's action rather than a pointer. +private fun SemanticsNodeInteraction.click() = performSemanticsAction(SemanticsActions.OnClick) + +class DesktopShellTest { + @get:Rule + val rule = createComposeRule() + + private val nav = DesktopNavState() + private var width by mutableStateOf(1280.dp) + + private fun shell(at: Dp, block: ComposeContentTestRule.() -> Unit) { + width = at + rule.setContent { + FlipcashTheme { + Box(Modifier.requiredWidth(width).fillMaxHeight()) { + DesktopShell( + nav = nav, + sidebar = { m -> Box(m.testTag("fake_list")) }, + main = { m -> Box(m.testTag("fake_main")) }, + context = { content, m -> Box(m.testTag("fake_${content::class.simpleName}")) { Text("$content") } }, + footer = { m -> + SidebarFooter( + displayName = "Brandon", + balance = "$80.80", + onYou = nav::openYou, + onWallet = nav::openWallet, + onSettings = {}, + modifier = m, + ) + }, + ) + } + } + } + rule.block() + } + + @Test + fun `balance opens wallet in a column at wide`() = shell(1280.dp) { + onNodeWithTag("footer_balance").click() + onNodeWithTag("context_pane").assertExists() + onNodeWithTag("context_overlay").assertDoesNotExist() + onNodeWithTag("fake_Wallet").assertExists() + } + + @Test + fun `wallet overlays main below 1100 and survives a resize`() = shell(1280.dp) { + onNodeWithTag("footer_balance").click() + onNodeWithTag("context_pane").assertExists() + width = 1000.dp + waitForIdle() + onNodeWithTag("context_overlay").assertExists() + onNodeWithTag("fake_Wallet").assertExists() + assertEquals(ContextContent.Wallet, nav.context) + } + + @Test + fun `scrim closes the overlay`() = shell(1000.dp) { + onNodeWithTag("footer_balance").click() + onNodeWithTag("context_scrim").click() + onNodeWithTag("context_overlay").assertDoesNotExist() + } + + @Test + fun `info button toggles info`() = shell(1280.dp) { + onNodeWithTag("toolbar_info").click() + onNodeWithTag("fake_Info").assertExists() + onNodeWithTag("toolbar_info").click() + onNodeWithTag("context_pane").assertDoesNotExist() + } + + @Test + fun `footer avatar opens you`() = shell(1280.dp) { + onNodeWithTag("footer_you").click() + onNodeWithTag("fake_You").assertExists() + } + + @Test + fun `no rail or tab bar at any width`() = shell(500.dp) { + for (w in listOf(500.dp, 700.dp, 1000.dp, 1280.dp)) { + width = w + waitForIdle() + onAllNodesWithTag("rail").fetchSemanticsNodes().let { assertEquals(0, it.size, "rail at $w") } + onAllNodesWithTag("tab_bar").fetchSemanticsNodes().let { assertEquals(0, it.size, "tab bar at $w") } + } + } + + @Test + fun `compact shows the list, then the chat with a back control`() = shell(500.dp) { + onNodeWithTag("fake_list").assertExists() + onNodeWithTag("fake_main").assertDoesNotExist() + nav.compactShowsChat = true + waitForIdle() + onNodeWithTag("fake_main").assertExists() + onNodeWithTag("fake_list").assertDoesNotExist() + onNodeWithTag("compact_back").click() + onNodeWithTag("fake_list").assertExists() + } + + @Test + fun `medium shows list and main side by side`() = shell(700.dp) { + onNodeWithTag("fake_list").assertExists() + onNodeWithTag("fake_main").assertExists() + } +} From 2cd7913b2a6e1c1029713ee144e1819494aab9b1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:51:30 -0400 Subject: [PATCH 06/18] feat(desktop): restore window size and position --- apps/desktop/build.gradle.kts | 2 + .../kotlin/com/flipcash/desktop/DesktopApp.kt | 5 +- .../flipcash/desktop/DesktopMainContent.kt | 2 +- .../main/kotlin/com/flipcash/desktop/Main.kt | 77 +++++++++++++++++-- .../desktop/window/WindowStateStore.kt | 68 ++++++++++++++++ .../desktop/window/WindowStateStoreTest.kt | 67 ++++++++++++++++ 6 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index a5bc65ca7e..a58ed89d3e 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrains.compose) alias(libs.plugins.metro) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -23,6 +24,7 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.kotlinx.serialization.json) implementation(project(":services:opencode")) implementation(project(":apps:flipcash:features:balance")) implementation(project(":apps:flipcash:core")) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt index 9c3d876c25..0a9895e753 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt @@ -14,16 +14,17 @@ import androidx.compose.ui.Modifier import com.flipcash.app.devicelink.DesktopLinkHost import com.flipcash.app.devicelink.ui.DesktopLinkScreen import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.desktop.shell.DesktopNavState import com.getcode.theme.CodeTheme import java.net.Inet4Address import java.net.NetworkInterface /** The main window when signed in; otherwise the link screen, which stores the phone's seed and signs in. */ @Composable -fun DesktopApp(graph: DesktopGraph, signedIn: Boolean) { +fun DesktopApp(graph: DesktopGraph, signedIn: Boolean, nav: DesktopNavState = remember { DesktopNavState() }) { var linked by remember { mutableStateOf(signedIn) } if (linked) { - DesktopMainContent(graph) + DesktopMainContent(graph, nav = nav) } else { DesktopLinkContent(graph, onSignedIn = { linked = true }) } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt index fa9d109a4d..2e05b7cba0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -39,8 +39,8 @@ fun DesktopMainContent( graph: DesktopGraph, initialContext: ContextContent? = null, onSettings: () -> Unit = {}, + nav: DesktopNavState = remember { DesktopNavState().apply { context = initialContext } }, ) { - val nav = remember { DesktopNavState().apply { context = initialContext } } val walletViewModel = remember { graph.walletViewModel } val tokenViewModel = remember { graph.selectTokenViewModel } LaunchedEffect(tokenViewModel) { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index 8668f03f97..7c04568449 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -2,16 +2,31 @@ package com.flipcash.desktop import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.flipcash.desktop.shell.ContextContent import com.flipcash.desktop.shell.DesktopDimens +import com.flipcash.desktop.shell.DesktopNavState import com.flipcash.desktop.shell.LocalTitleBarInset +import com.flipcash.desktop.window.SavedWindow +import com.flipcash.desktop.window.WindowStateStore +import com.flipcash.desktop.window.fitTo import dev.zacsweers.metro.createGraph +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.runBlocking import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.Rectangle +import java.awt.Toolkit import java.lang.management.ManagementFactory internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") @@ -19,21 +34,61 @@ internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsW // Height of the macOS title bar the content runs under, which the traffic lights sit in. private val MacTitleBarHeight = 28.dp +private val MinWindow = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) + +@OptIn(FlowPreview::class) fun main() { val graph = createGraph() val signedIn = runBlocking { graph.startup() graph.restoreSession() } + val windowStore = WindowStateStore(DesktopModule.appDir.toPath().resolve("window.json")) + val saved = windowStore.load()?.fitTo(usableScreens(), MinWindow) application { + val windowState = rememberWindowState( + placement = if (saved?.maximized == true) WindowPlacement.Maximized else WindowPlacement.Floating, + position = saved?.let { WindowPosition(it.x.dp, it.y.dp) } ?: WindowPosition.PlatformDefault, + width = saved?.width?.dp ?: 1040.dp, + height = saved?.height?.dp ?: 760.dp, + ) + // Only Wallet comes back open; Info, You and a currency belong to what was on screen then. + val nav = remember { + DesktopNavState().apply { if (saved?.contextPaneOpen == true) context = ContextContent.Wallet } + } + val awtWindowRef = remember { java.util.concurrent.atomic.AtomicReference() } + fun current(): SavedWindow? { + val b = awtWindowRef.get()?.bounds ?: return null + return SavedWindow( + x = b.x, + y = b.y, + width = b.width, + height = b.height, + maximized = windowState.placement == WindowPlacement.Maximized, + contextPaneOpen = nav.context == ContextContent.Wallet, + ) + } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + // Debounced saves can miss a move made just before quitting. + current()?.let(windowStore::save) + exitApplication() + }, title = "Flipcash", - state = rememberWindowState(width = 1040.dp, height = 760.dp), + state = windowState, ) { - LaunchedEffect(Unit) { configureChrome() } + LaunchedEffect(Unit) { + awtWindowRef.set(window) + configureChrome() + } + LaunchedEffect(Unit) { + snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, nav.context) } + .drop(1) + .debounce(500) + .collect { current()?.let(windowStore::save) } + } CompositionLocalProvider(LocalTitleBarInset provides if (isMac) MacTitleBarHeight else 0.dp) { - DesktopApp(graph, signedIn) + DesktopApp(graph, signedIn, nav) } LaunchedEffect(Unit) { val sinceJvmStart = System.currentTimeMillis() - @@ -44,12 +99,24 @@ fun main() { } } +/** Each screen's bounds less the dock, menu bar or taskbar. */ +private fun usableScreens(): List { + if (GraphicsEnvironment.isHeadless()) return emptyList() + val toolkit = Toolkit.getDefaultToolkit() + return GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { device -> + val config = device.defaultConfiguration + val b = config.bounds + val i = toolkit.getScreenInsets(config) + Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom) + } +} + /** * The minimum window, and on macOS a unified title bar: the content runs under a transparent title * bar with the traffic lights over it. Windows and Linux keep their normal decorated window. */ private fun FrameWindowScope.configureChrome() { - window.minimumSize = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) + window.minimumSize = MinWindow if (isMac) { window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt new file mode 100644 index 0000000000..5fa052538e --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt @@ -0,0 +1,68 @@ +package com.flipcash.desktop.window + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.awt.Dimension +import java.awt.Rectangle +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** The window as it was when last moved, resized or closed. */ +@Serializable +data class SavedWindow( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val maximized: Boolean, + val contextPaneOpen: Boolean, +) + +/** Reads and writes [SavedWindow] as JSON in [file]. A missing or unreadable file loads as null. */ +class WindowStateStore(private val file: Path) { + private val json = Json { ignoreUnknownKeys = true } + + fun load(): SavedWindow? = + if (!file.exists()) null else runCatching { json.decodeFromString(file.readText()) }.getOrNull() + + fun save(w: SavedWindow) { + file.parent?.createDirectories() + file.writeText(json.encodeToString(SavedWindow.serializer(), w)) + } +} + +// The strip a user drags the window by. If none of it is on a screen, the window can't be moved back. +private const val TitleBarHeight = 28 + +/** + * Fits the saved window to the [screens] now attached (usable bounds, without the dock or taskbar): + * no larger than its screen, no smaller than [min], and wholly on it. A window whose title bar is on + * no screen, as after unplugging a monitor, is centred on the first screen at its saved size. + */ +fun SavedWindow.fitTo(screens: List, min: Dimension): SavedWindow { + val first = screens.firstOrNull() ?: return this + val titleBar = Rectangle(x, y, width, TitleBarHeight) + val home = screens.firstOrNull { it.intersects(titleBar) } + val screen = home ?: first + val w = width.coerceAtMost(screen.width).coerceAtLeast(min.width) + val h = height.coerceAtMost(screen.height).coerceAtLeast(min.height) + if (home == null) { + return copy( + x = screen.x + (screen.width - w) / 2, + y = screen.y + (screen.height - h) / 2, + width = w, + height = h, + ) + } + val maxX = screen.x + screen.width - w + val maxY = screen.y + screen.height - h + return copy( + x = x.coerceAtMost(maxX).coerceAtLeast(screen.x), + y = y.coerceAtMost(maxY).coerceAtLeast(screen.y), + width = w, + height = h, + ) +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt new file mode 100644 index 0000000000..7348344e09 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.desktop.window + +import java.awt.Dimension +import java.awt.Rectangle +import java.nio.file.Files +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowStateStoreTest { + private val file = Files.createTempDirectory("window").resolve("window.json") + private val store = WindowStateStore(file) + private val screen = listOf(Rectangle(0, 0, 1440, 900)) + private val min = Dimension(400, 640) + + private fun SavedWindow.bounds() = Rectangle(x, y, width, height) + + @Test + fun `round trips`() { + val w = SavedWindow(10, 20, 1280, 800, maximized = false, contextPaneOpen = true) + store.save(w) + assertEquals(w, store.load()) + } + + @Test + fun `missing file loads as null`() { + assertNull(store.load()) + } + + @Test + fun `corrupt file loads as null`() { + file.writeText("{not json") + assertNull(store.load()) + } + + @Test + fun `off-screen window moves onto the first screen at its size`() { + val fitted = SavedWindow(5000, 200, 1280, 800, false, false).fitTo(screen, min) + assertEquals(Rectangle(80, 50, 1280, 800), fitted.bounds()) + } + + @Test + fun `on-screen window stays put`() { + val w = SavedWindow(100, 40, 1000, 700, false, false) + assertEquals(w, w.fitTo(screen, min)) + } + + @Test + fun `larger than the screen shrinks to it`() { + val fitted = SavedWindow(0, 0, 3000, 2000, false, false).fitTo(screen, min) + assertEquals(Rectangle(0, 0, 1440, 900), fitted.bounds()) + } + + @Test + fun `never smaller than the minimum`() { + val fitted = SavedWindow(100, 100, 100, 100, false, false).fitTo(screen, min) + assertEquals(Dimension(400, 640), Dimension(fitted.width, fitted.height)) + } + + @Test + fun `a window on the second screen stays there`() { + val screens = listOf(Rectangle(0, 0, 1440, 900), Rectangle(1440, 0, 1920, 1080)) + val w = SavedWindow(1600, 100, 1280, 800, false, false) + assertEquals(w, w.fitTo(screens, min)) + } +} From a9dd8352079fe2bf4f4f84b0ac0e613e12080260 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:55:05 -0400 Subject: [PATCH 07/18] feat(desktop): shortcuts and macOS menu bar Cmd on macOS and Ctrl elsewhere map the spec's shortcut table to commands. Info, Back and Esc work now; Search, New Chat, Settings and a new window do nothing until their plans land, so those keys still reach the focused field. On macOS the File, View and Window menus carry the same shortcuts. --- apps/desktop/build.gradle.kts | 5 ++ .../main/kotlin/com/flipcash/desktop/Main.kt | 13 ++++ .../flipcash/desktop/input/DesktopCommands.kt | 57 ++++++++++++++ .../flipcash/desktop/input/DesktopMenuBar.kt | 60 +++++++++++++++ .../desktop/input/DesktopCommandsTest.kt | 76 +++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index a58ed89d3e..373647cf95 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -77,6 +77,11 @@ val devMnemonic = tryReadProperty(rootProject.rootDir, "flipcash.test.mnemonic", compose.desktop { application { mainClass = "com.flipcash.desktop.MainKt" + // Names the macOS app menu and Dock item when run from Gradle; a packaged app takes packageName. + // -Xdock is a macOS-only JVM flag, so other hosts must not get it. + if (System.getProperty("os.name").lowercase().startsWith("mac")) { + jvmArgs += listOf("-Xdock:name=Flipcash", "-Dapple.awt.application.name=Flipcash") + } nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Flipcash" diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index 7c04568449..ed0fbada81 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -11,7 +11,14 @@ import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.flipcash.desktop.input.DesktopCommand +import com.flipcash.desktop.input.DesktopMenuBar +import com.flipcash.desktop.input.HostOs +import com.flipcash.desktop.input.desktopKeyHandler +import com.flipcash.desktop.input.perform import com.flipcash.desktop.shell.ContextContent +import com.flipcash.desktop.shell.contextPaneModeFor +import com.flipcash.desktop.shell.widthClassFor import com.flipcash.desktop.shell.DesktopDimens import com.flipcash.desktop.shell.DesktopNavState import com.flipcash.desktop.shell.LocalTitleBarInset @@ -38,6 +45,8 @@ private val MinWindow = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), De @OptIn(FlowPreview::class) fun main() { + // Swing menus go in the window unless this is set before AWT starts; macOS apps use the screen menu bar. + if (isMac) System.setProperty("apple.laf.useScreenMenuBar", "true") val graph = createGraph() val signedIn = runBlocking { graph.startup() @@ -68,6 +77,8 @@ fun main() { contextPaneOpen = nav.context == ContextContent.Wallet, ) } + fun run(command: DesktopCommand): Boolean = + nav.perform(command, contextPaneModeFor(widthClassFor(windowState.size.width))) Window( onCloseRequest = { // Debounced saves can miss a move made just before quitting. @@ -76,7 +87,9 @@ fun main() { }, title = "Flipcash", state = windowState, + onPreviewKeyEvent = desktopKeyHandler(HostOs.current, ::run), ) { + if (isMac) DesktopMenuBar(windowState, ::run) LaunchedEffect(Unit) { awtWindowRef.set(window) configureChrome() diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt new file mode 100644 index 0000000000..17c15dc666 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt @@ -0,0 +1,57 @@ +package com.flipcash.desktop.input + +import androidx.compose.ui.input.key.Key +import com.flipcash.desktop.shell.ContextPaneMode +import com.flipcash.desktop.shell.DesktopNavState + +/** What the window's shortcuts and menu items do (spec: Keyboard shortcuts). */ +enum class DesktopCommand { Search, NewChat, ToggleInfo, Back, Settings, OpenInNewWindow, Escape } + +enum class HostOs { + Mac, Windows, Linux; + + companion object { + val current: HostOs = System.getProperty("os.name").orEmpty().lowercase().let { + when { + it.startsWith("mac") -> Mac + it.startsWith("windows") -> Windows + else -> Linux + } + } + } +} + +/** + * The command for a key press: ⌘ on macOS, Ctrl on Windows and Linux, the same letter on each. + * Shift, or the other platform's modifier, makes it something else. Esc needs no modifier. + */ +fun commandFor(key: Key, meta: Boolean, ctrl: Boolean, shift: Boolean, os: HostOs): DesktopCommand? { + if (key == Key.Escape) return if (!meta && !ctrl && !shift) DesktopCommand.Escape else null + val primary = if (os == HostOs.Mac) meta && !ctrl else ctrl && !meta + if (!primary || shift) return null + return when (key) { + Key.K -> DesktopCommand.Search + Key.N -> DesktopCommand.NewChat + Key.I -> DesktopCommand.ToggleInfo + Key.LeftBracket -> DesktopCommand.Back + Key.Comma -> DesktopCommand.Settings + Key.Enter -> DesktopCommand.OpenInNewWindow + else -> null + } +} + +/** + * Runs [command] and says whether it did anything, so an unhandled key still reaches the focused + * field. Search and New Chat arrive with chat (roadmap Plan 3b), a new window with Plans 3b and 5, + * and Settings with Plan 7; until then they do nothing. + */ +fun DesktopNavState.perform(command: DesktopCommand, mode: ContextPaneMode): Boolean = when (command) { + DesktopCommand.ToggleInfo -> { toggleInfo(); true } + DesktopCommand.Back -> back() + DesktopCommand.Escape -> onEscape(mode) + DesktopCommand.Search, DesktopCommand.NewChat, DesktopCommand.OpenInNewWindow, DesktopCommand.Settings -> false +} + +/** Whether [perform] does anything for [command] yet; the menu bar greys out the rest. */ +val DesktopCommand.available: Boolean + get() = this == DesktopCommand.ToggleInfo || this == DesktopCommand.Back || this == DesktopCommand.Escape diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt new file mode 100644 index 0000000000..b48c96fca8 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt @@ -0,0 +1,60 @@ +package com.flipcash.desktop.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.input.key.KeyShortcut + +/** The commands the macOS menu bar owns; their shortcuts reach the app through the menu, not as key events. */ +private val menuCommands = setOf(DesktopCommand.NewChat, DesktopCommand.ToggleInfo, DesktopCommand.Search) + +/** The window's key handling: a key press becomes a [DesktopCommand], unless the menu bar already handles it. */ +fun desktopKeyHandler(os: HostOs, run: (DesktopCommand) -> Boolean): (KeyEvent) -> Boolean = handler@{ event -> + if (event.type != KeyEventType.KeyDown) return@handler false + val command = commandFor(event.key, event.isMetaPressed, event.isCtrlPressed, event.isShiftPressed, os) + ?: return@handler false + if (os == HostOs.Mac && command in menuCommands) return@handler false + run(command) +} + +/** + * The macOS menu bar (spec: Native per OS). Flipcash's own menu, with Settings…, is the system app + * menu and gets its item when Settings lands (Plan 7). Items for commands that can't run yet are + * disabled rather than hidden, so the menus keep their final shape. + */ +@Composable +fun FrameWindowScope.DesktopMenuBar(windowState: WindowState, run: (DesktopCommand) -> Boolean) { + fun item(command: DesktopCommand) = { run(command); Unit } + MenuBar { + Menu("File") { + Item("New Chat", enabled = DesktopCommand.NewChat.available, + shortcut = KeyShortcut(Key.N, meta = true), onClick = item(DesktopCommand.NewChat)) + } + Menu("View") { + Item("Chat Info", enabled = DesktopCommand.ToggleInfo.available, + shortcut = KeyShortcut(Key.I, meta = true), onClick = item(DesktopCommand.ToggleInfo)) + Item("Search", enabled = DesktopCommand.Search.available, + shortcut = KeyShortcut(Key.K, meta = true), onClick = item(DesktopCommand.Search)) + } + Menu("Window") { + Item("Minimize", shortcut = KeyShortcut(Key.M, meta = true), onClick = { windowState.isMinimized = true }) + Item("Zoom", onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) { + WindowPlacement.Floating + } else { + WindowPlacement.Maximized + } + }) + } + } +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt new file mode 100644 index 0000000000..a93a8c373b --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.desktop.input + +import androidx.compose.ui.input.key.Key +import com.flipcash.desktop.shell.ContextContent +import com.flipcash.desktop.shell.ContextPaneMode +import com.flipcash.desktop.shell.DesktopNavState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopCommandsTest { + private fun mac(key: Key) = commandFor(key, meta = true, ctrl = false, shift = false, os = HostOs.Mac) + + @Test + fun `mac uses command`() { + assertEquals(DesktopCommand.Search, mac(Key.K)) + assertNull(commandFor(Key.K, meta = false, ctrl = true, shift = false, os = HostOs.Mac)) + } + + @Test + fun `windows and linux use control`() { + for (os in listOf(HostOs.Windows, HostOs.Linux)) { + assertEquals(DesktopCommand.ToggleInfo, commandFor(Key.I, meta = false, ctrl = true, shift = false, os = os)) + assertNull(commandFor(Key.I, meta = true, ctrl = false, shift = false, os = os)) + } + } + + @Test + fun `the spec's table`() { + assertEquals(DesktopCommand.Search, mac(Key.K)) + assertEquals(DesktopCommand.NewChat, mac(Key.N)) + assertEquals(DesktopCommand.ToggleInfo, mac(Key.I)) + assertEquals(DesktopCommand.Back, mac(Key.LeftBracket)) + assertEquals(DesktopCommand.Settings, mac(Key.Comma)) + assertEquals(DesktopCommand.OpenInNewWindow, mac(Key.Enter)) + assertEquals(DesktopCommand.Escape, commandFor(Key.Escape, false, false, false, HostOs.Mac)) + } + + @Test + fun `plain letters are not commands`() { + assertNull(commandFor(Key.K, meta = false, ctrl = false, shift = false, os = HostOs.Mac)) + } + + @Test + fun `shifted shortcuts are not these commands`() { + assertNull(commandFor(Key.K, meta = true, ctrl = false, shift = true, os = HostOs.Mac)) + } + + @Test + fun `toggle info opens and closes info`() { + val nav = DesktopNavState() + assertTrue(nav.perform(DesktopCommand.ToggleInfo, ContextPaneMode.Column)) + assertEquals(ContextContent.Info, nav.context) + nav.perform(DesktopCommand.ToggleInfo, ContextPaneMode.Column) + assertNull(nav.context) + } + + @Test + fun `escape closes the overlay but not the column`() { + val nav = DesktopNavState().apply { openWallet() } + assertFalse(nav.perform(DesktopCommand.Escape, ContextPaneMode.Column)) + assertEquals(ContextContent.Wallet, nav.context) + assertTrue(nav.perform(DesktopCommand.Escape, ContextPaneMode.Overlay)) + assertNull(nav.context) + } + + @Test + fun `commands without a screen yet are not consumed`() { + val nav = DesktopNavState() + for (c in listOf(DesktopCommand.Search, DesktopCommand.NewChat, DesktopCommand.OpenInNewWindow, DesktopCommand.Settings)) { + assertFalse(nav.perform(c, ContextPaneMode.Column)) + } + } +} From 5c5f9b44cb13ca464a30944b3c8461869b637a2c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:56:05 -0400 Subject: [PATCH 08/18] revert(core-ui): keep the tab bar Android-only The three-pane shell replaced the desktop rail, so nothing outside Android uses the tab bar and the move to commonMain is no longer needed. --- .../app/internal/ui/AppNavigationBar.kt | 3 +- .../com/flipcash/app/core/ui/NavigationBar.kt | 93 +++++++++++++------ .../app/core/ui/NavigationBarPreviews.kt | 61 ------------ 3 files changed, 67 insertions(+), 90 deletions(-) rename apps/flipcash/core-ui/src/{commonMain => androidMain}/kotlin/com/flipcash/app/core/ui/NavigationBar.kt (85%) delete mode 100644 apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 91ba70a973..875a4cd253 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import org.jetbrains.compose.resources.stringResource as composeStringResource import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.getcode.ui.components.glass.floatingGlass @@ -311,7 +310,7 @@ internal fun SearchOriginTabButton( } val unread by rememberChatListUnreadCount() val state = rememberNavigationBarState(selectedTab = tab, chatListUnreadCount = unread) - val label = composeStringResource(tab.label) + val label = stringResource(tab.label) // The glyph sits over the button rather than in it: the button clips to its circle, and the // badge has to overhang the corner the way it does on the tab. Box(contentAlignment = Alignment.Center) { diff --git a/apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt similarity index 85% rename from apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt rename to apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index f7b43e091d..e1d36b2f22 100644 --- a/apps/flipcash/core-ui/src/commonMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -40,28 +40,19 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.flipcash.app.core.navigation.NavBarButton import dev.chrisbanes.haze.HazeState -import com.flipcash.core.resources.Res -import com.flipcash.core.resources.ic_nav_chat -import com.flipcash.core.resources.ic_nav_chat_selected -import com.flipcash.core.resources.ic_nav_tipcard -import com.flipcash.core.resources.ic_nav_tipcard_selected -import com.flipcash.core.resources.ic_nav_wallet -import com.flipcash.core.resources.ic_nav_wallet_selected -import com.flipcash.core.resources.ic_people_circle -import com.flipcash.core.resources.title_tabChat -import com.flipcash.core.resources.title_tabScan -import com.flipcash.core.resources.title_tabWallet -import com.flipcash.core.resources.title_tabYou +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge import com.getcode.ui.components.glass.floatingGlass @@ -113,10 +104,8 @@ fun NavigationBar( // long-press at all — no haptic, and a long hold still selects it — which is what a caller // passes while the switcher's gate is closed. The other tabs never take a long-press. onYouTabLongClick: (() -> Unit)? = null, - // The tabs to draw, in bar order. Desktop leaves out Scan, having no camera. - tabs: List = NavBarButton.tabs, ) { - val order = tabs + val order = NavBarButton.tabs if (order.isEmpty()) return // The design frames glyph and name with 5pt above and below in an 86.5 x 50pt item (node @@ -313,12 +302,12 @@ internal val NavBarButton.testTag: String } /** The tab's name under its glyph, in tab order Scan, Chat, Wallet, You (node 10642:1325). */ -val NavBarButton.label: StringResource +val NavBarButton.label: Int get() = when (this) { - NavBarButton.Scanner -> Res.string.title_tabScan - NavBarButton.Chats -> Res.string.title_tabChat - NavBarButton.Wallet -> Res.string.title_tabWallet - NavBarButton.TipCard -> Res.string.title_tabYou + NavBarButton.Scanner -> R.string.title_tabScan + NavBarButton.Chats -> R.string.title_tabChat + NavBarButton.Wallet -> R.string.title_tabWallet + NavBarButton.TipCard -> R.string.title_tabYou } /** @@ -339,12 +328,62 @@ val NavBarButton.label: StringResource @Composable private fun NavBarButton.icon(selected: Boolean): Painter = when (this) { NavBarButton.Scanner -> - painterResource(if (selected) Res.drawable.ic_nav_tipcard_selected else Res.drawable.ic_nav_tipcard) + painterResource(if (selected) R.drawable.ic_nav_tipcard_selected else R.drawable.ic_nav_tipcard) NavBarButton.Wallet -> - painterResource(if (selected) Res.drawable.ic_nav_wallet_selected else Res.drawable.ic_nav_wallet) + painterResource(if (selected) R.drawable.ic_nav_wallet_selected else R.drawable.ic_nav_wallet) NavBarButton.Chats -> - painterResource(if (selected) Res.drawable.ic_nav_chat_selected else Res.drawable.ic_nav_chat) + painterResource(if (selected) R.drawable.ic_nav_chat_selected else R.drawable.ic_nav_chat) NavBarButton.TipCard -> if (selected) rememberVectorPainter(Icons.Filled.AccountCircle) - else painterResource(Res.drawable.ic_people_circle) + else painterResource(R.drawable.ic_people_circle) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarPreview() { + NavigationBar( + state = rememberNavigationBarState(chatListUnreadCount = 100), + onButtonClick = { } + ) +} + +/** + * The You tab's photo slot in both of its states (node 9713:664) — a flat fill stands in for the + * photo, since a preview has no profile to read. + */ +@Preview(name = "Avatar, You tab unselected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarUnselectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} + +@Preview(name = "Avatar, You tab selected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarAvatarSelectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + onButtonClick = { }, + avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, + ) +} + +/** + * The You tab with no photo to draw (node 10000:111297) — the people-circle fallback, selected, so + * the filled twin gets a preview of its own rather than only appearing in the screenshot test. + */ +@Preview(name = "No photo, You tab selected") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarNoAvatarSelectedPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), + onButtonClick = { }, + ) } diff --git a/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt b/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt deleted file mode 100644 index 5977fe9470..0000000000 --- a/apps/flipcash/core-ui/src/androidMain/kotlin/com/flipcash/app/core/ui/NavigationBarPreviews.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.flipcash.app.core.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.theme.FlipcashThemeWrapper - -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarPreview() { - NavigationBar( - state = rememberNavigationBarState(chatListUnreadCount = 100), - onButtonClick = { } - ) -} - -/** - * The You tab's photo slot in both of its states (node 9713:664) — a flat fill stands in for the - * photo, since a preview has no profile to read. - */ -@Preview(name = "Avatar, You tab unselected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarAvatarUnselectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.Wallet), - onButtonClick = { }, - avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, - ) -} - -@Preview(name = "Avatar, You tab selected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarAvatarSelectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), - onButtonClick = { }, - avatar = { modifier -> Box(modifier.background(Color(0xFF8E6E5B))) }, - ) -} - -/** - * The You tab with no photo to draw (node 10000:111297) — the people-circle fallback, selected, so - * the filled twin gets a preview of its own rather than only appearing in the screenshot test. - */ -@Preview(name = "No photo, You tab selected") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarNoAvatarSelectedPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.TipCard), - onButtonClick = { }, - ) -} From d3ab53f50c2b6577551a5ce587156e80a18c4417 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 00:00:58 -0400 Subject: [PATCH 09/18] fix(desktop): window state and pane closing from review - A failed window-state save is logged, never thrown, so the window always closes; writes go through a temp file and a rename. - Maximized or full-screen windows save their floating bounds, and a minimized window saves nothing, so un-maximizing and the next launch land where the user left the window. - Before linking, the saved Wallet-open choice is carried forward rather than cleared. - Alt and AltGr shortcuts are never commands, so AltGr characters type on Windows layouts. - Every context pane has a close button, and an unavailable screen's back closes it. --- .../main/kotlin/com/flipcash/desktop/Main.kt | 24 ++++++-- .../flipcash/desktop/input/DesktopCommands.kt | 16 ++++-- .../flipcash/desktop/input/DesktopMenuBar.kt | 3 +- .../com/flipcash/desktop/shell/ContextPane.kt | 28 ++++++++- .../desktop/window/WindowStateStore.kt | 15 ++++- .../desktop/input/DesktopCommandsTest.kt | 8 +++ .../flipcash/desktop/shell/ContextPaneTest.kt | 57 +++++++++++++++++++ .../desktop/window/WindowStateStoreTest.kt | 10 ++++ 8 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/ContextPaneTest.kt diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index ed0fbada81..63fcbaa42d 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -66,15 +66,24 @@ fun main() { DesktopNavState().apply { if (saved?.contextPaneOpen == true) context = ContextContent.Wallet } } val awtWindowRef = remember { java.util.concurrent.atomic.AtomicReference() } + // The floating bounds, kept while maximized or full screen so un-maximizing returns to them. + val lastFloating = remember { + java.util.concurrent.atomic.AtomicReference(saved?.let { Rectangle(it.x, it.y, it.width, it.height) }) + } fun current(): SavedWindow? { - val b = awtWindowRef.get()?.bounds ?: return null + // A minimized window reports nonsense bounds (about -32000,-32000 on Windows); keep the last good save. + if (windowState.isMinimized) return null + val awt = awtWindowRef.get() ?: return null + if (windowState.placement == WindowPlacement.Floating) lastFloating.set(awt.bounds) + val b = lastFloating.get() ?: awt.bounds return SavedWindow( x = b.x, y = b.y, width = b.width, height = b.height, - maximized = windowState.placement == WindowPlacement.Maximized, - contextPaneOpen = nav.context == ContextContent.Wallet, + maximized = windowState.placement != WindowPlacement.Floating, + // Before linking there is no Wallet to show; carry the saved choice forward. + contextPaneOpen = if (signedIn) nav.context == ContextContent.Wallet else saved?.contextPaneOpen == true, ) } fun run(command: DesktopCommand): Boolean = @@ -82,8 +91,11 @@ fun main() { Window( onCloseRequest = { // Debounced saves can miss a move made just before quitting. - current()?.let(windowStore::save) - exitApplication() + try { + current()?.let(windowStore::save) + } finally { + exitApplication() + } }, title = "Flipcash", state = windowState, @@ -95,7 +107,7 @@ fun main() { configureChrome() } LaunchedEffect(Unit) { - snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, nav.context) } + snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, windowState.isMinimized, nav.context) } .drop(1) .debounce(500) .collect { current()?.let(windowStore::save) } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt index 17c15dc666..ac2b33d8cb 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt @@ -23,12 +23,20 @@ enum class HostOs { /** * The command for a key press: ⌘ on macOS, Ctrl on Windows and Linux, the same letter on each. - * Shift, or the other platform's modifier, makes it something else. Esc needs no modifier. + * Shift, Alt, or the other platform's modifier, makes it something else. Esc needs no modifier. */ -fun commandFor(key: Key, meta: Boolean, ctrl: Boolean, shift: Boolean, os: HostOs): DesktopCommand? { - if (key == Key.Escape) return if (!meta && !ctrl && !shift) DesktopCommand.Escape else null +fun commandFor( + key: Key, + meta: Boolean, + ctrl: Boolean, + shift: Boolean, + os: HostOs, + alt: Boolean = false, +): DesktopCommand? { + if (key == Key.Escape) return if (!meta && !ctrl && !shift && !alt) DesktopCommand.Escape else null val primary = if (os == HostOs.Mac) meta && !ctrl else ctrl && !meta - if (!primary || shift) return null + // Alt (AltGr arrives as Ctrl+Alt on Windows) types characters on many layouts; never a command. + if (!primary || shift || alt) return null return when (key) { Key.K -> DesktopCommand.Search Key.N -> DesktopCommand.NewChat diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt index b48c96fca8..12a1bb6b3a 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isShiftPressed @@ -21,7 +22,7 @@ private val menuCommands = setOf(DesktopCommand.NewChat, DesktopCommand.ToggleIn /** The window's key handling: a key press becomes a [DesktopCommand], unless the menu bar already handles it. */ fun desktopKeyHandler(os: HostOs, run: (DesktopCommand) -> Boolean): (KeyEvent) -> Boolean = handler@{ event -> if (event.type != KeyEventType.KeyDown) return@handler false - val command = commandFor(event.key, event.isMetaPressed, event.isCtrlPressed, event.isShiftPressed, os) + val command = commandFor(event.key, event.isMetaPressed, event.isCtrlPressed, event.isShiftPressed, os, event.isAltPressed) ?: return@handler false if (os == HostOs.Mac && command in menuCommands) return@handler false run(command) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt index bd2f3d1f4d..7538c5cc29 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt @@ -4,6 +4,18 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Icon +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme /** * What the context pane shows for [content]. Wallet is the real wallet; the rest are placeholders @@ -39,8 +51,22 @@ fun ContextPane( is ContextContent.Unavailable -> PlaceholderPane( title = content.name, blockedBy = "This screen has no desktop build yet.", - onBack = { nav.back() }, + onBack = nav::closeContext, ) } + // Every context pane can be closed: a column has no Esc, and at compact widths the pane + // covers the toolbar and footer that opened it. + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(32.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = nav::closeContext) + .testTag("context_close"), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Close, contentDescription = "Close", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt index 5fa052538e..1dcb020f99 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt @@ -4,7 +4,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.awt.Dimension import java.awt.Rectangle +import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption import kotlin.io.path.createDirectories import kotlin.io.path.exists import kotlin.io.path.readText @@ -28,9 +30,18 @@ class WindowStateStore(private val file: Path) { fun load(): SavedWindow? = if (!file.exists()) null else runCatching { json.decodeFromString(file.readText()) }.getOrNull() + /** + * Writes [w] through a temp file and a rename, so a crash mid-write leaves the old file. A failure + * (read-only home, full disk, a locked file) is logged and dropped: losing the window's place must + * never stop the window closing. + */ fun save(w: SavedWindow) { - file.parent?.createDirectories() - file.writeText(json.encodeToString(SavedWindow.serializer(), w)) + runCatching { + file.parent?.createDirectories() + val tmp = file.resolveSibling(file.fileName.toString() + ".tmp") + tmp.writeText(json.encodeToString(SavedWindow.serializer(), w)) + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + }.onFailure { System.err.println("window state not saved: $it") } } } diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt index a93a8c373b..082399c521 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt @@ -48,6 +48,14 @@ class DesktopCommandsTest { assertNull(commandFor(Key.K, meta = true, ctrl = false, shift = true, os = HostOs.Mac)) } + @Test + fun `alt and altgr are never commands`() { + // AltGr arrives as Ctrl+Alt on Windows and types characters on many layouts. + assertNull(commandFor(Key.N, meta = false, ctrl = true, shift = false, os = HostOs.Windows, alt = true)) + assertNull(commandFor(Key.I, meta = true, ctrl = false, shift = false, os = HostOs.Mac, alt = true)) + assertNull(commandFor(Key.Escape, meta = false, ctrl = false, shift = false, os = HostOs.Mac, alt = true)) + } + @Test fun `toggle info opens and closes info`() { val nav = DesktopNavState() diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/ContextPaneTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/ContextPaneTest.kt new file mode 100644 index 0000000000..07a72948a0 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/ContextPaneTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals +import androidx.compose.ui.test.onNodeWithText + +private fun SemanticsNodeInteraction.click() = performSemanticsAction(SemanticsActions.OnClick) + +class ContextPaneTest { + @get:Rule val rule = createComposeRule() + private val nav = DesktopNavState() + + private fun pane(content: ContextContent) { + nav.context = content + rule.setContent { + FlipcashTheme { + nav.context?.let { ContextPane(it, nav, wallet = { m -> Box(m.testTag("fake_wallet")) }) } + } + } + } + + @Test + fun `close dismisses any pane`() { + pane(ContextContent.Wallet) + rule.onNodeWithTag("context_close").click() + rule.waitForIdle() + assertEquals(null, nav.context) + } + + @Test + fun `an unavailable screen's back closes it`() { + pane(ContextContent.Unavailable("Bill")) + rule.onNodeWithText("‹ Back").click() + rule.waitForIdle() + assertEquals(null, nav.context) + } +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt index 7348344e09..f6a9f7f7f2 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt @@ -23,6 +23,16 @@ class WindowStateStoreTest { assertEquals(w, store.load()) } + @Test + fun `a failed save does not throw and keeps the old file`() { + val w = SavedWindow(10, 20, 1280, 800, maximized = false, contextPaneOpen = true) + store.save(w) + // A directory where the temp file should go makes the write fail. + Files.createDirectory(file.resolveSibling("window.json.tmp")) + store.save(w.copy(x = 99)) + assertEquals(w, store.load()) + } + @Test fun `missing file loads as null`() { assertNull(store.load()) From 7d0fcdcf71619722fd74198a3105a2cc7f88ac67 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 08:03:02 -0400 Subject: [PATCH 10/18] feat(desktop): Edit menu on macOS Undo, Redo, Cut, Copy, Paste and Select All, each sending its shortcut to the focused text field. The menu accelerator takes the key press before the field, so the item forwards it; a forward that returns through the menu is dropped. --- .../flipcash/desktop/input/DesktopMenuBar.kt | 10 +++- .../com/flipcash/desktop/input/EditMenu.kt | 50 +++++++++++++++++ .../flipcash/desktop/input/EditMenuTest.kt | 55 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/input/EditMenu.kt create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/input/EditMenuTest.kt diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt index 12a1bb6b3a..5a96f3a02b 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt @@ -1,6 +1,7 @@ package com.flipcash.desktop.input import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType @@ -30,17 +31,24 @@ fun desktopKeyHandler(os: HostOs, run: (DesktopCommand) -> Boolean): (KeyEvent) /** * The macOS menu bar (spec: Native per OS). Flipcash's own menu, with Settings…, is the system app - * menu and gets its item when Settings lands (Plan 7). Items for commands that can't run yet are + * menu and gets its item when Settings lands (Plan 7). Edit acts on the focused text field. Items for commands that can't run yet are * disabled rather than hidden, so the menus keep their final shape. */ @Composable fun FrameWindowScope.DesktopMenuBar(windowState: WindowState, run: (DesktopCommand) -> Boolean) { fun item(command: DesktopCommand) = { run(command); Unit } + val edit = remember { EditForwarder() } MenuBar { Menu("File") { Item("New Chat", enabled = DesktopCommand.NewChat.available, shortcut = KeyShortcut(Key.N, meta = true), onClick = item(DesktopCommand.NewChat)) } + Menu("Edit") { + for (action in EditAction.entries) { + Item(action.title, shortcut = action.shortcut, onClick = { edit.forward(action) }) + if (action == EditAction.Redo) Separator() + } + } Menu("View") { Item("Chat Info", enabled = DesktopCommand.ToggleInfo.available, shortcut = KeyShortcut(Key.I, meta = true), onClick = item(DesktopCommand.ToggleInfo)) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/EditMenu.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/EditMenu.kt new file mode 100644 index 0000000000..5eb5e53fac --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/EditMenu.kt @@ -0,0 +1,50 @@ +package com.flipcash.desktop.input + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import java.awt.Component +import java.awt.KeyboardFocusManager +import java.awt.event.InputEvent +import java.awt.event.KeyEvent + +/** The standard macOS Edit menu items, with the shortcut each one stands for. */ +enum class EditAction(val title: String, val key: Key, val keyCode: Int, val shift: Boolean = false) { + Undo("Undo", Key.Z, KeyEvent.VK_Z), + Redo("Redo", Key.Z, KeyEvent.VK_Z, shift = true), + Cut("Cut", Key.X, KeyEvent.VK_X), + Copy("Copy", Key.C, KeyEvent.VK_C), + Paste("Paste", Key.V, KeyEvent.VK_V), + SelectAll("Select All", Key.A, KeyEvent.VK_A); + + val shortcut get() = KeyShortcut(key, meta = true, shift = shift) +} + +/** + * Runs an Edit menu item by sending its shortcut to the focused component, where the Compose text + * field handles it as if typed. The menu's accelerator takes the real key press before the field sees + * it, so without this ⌘C would stop copying once the menu exists. A forward that comes back through + * the menu (nothing focused handled it) is dropped instead of looping. + */ +class EditForwarder( + private val focusOwner: () -> Component? = { KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner }, + private val dispatch: (Component, KeyEvent) -> Unit = { target, event -> + KeyboardFocusManager.getCurrentKeyboardFocusManager().redispatchEvent(target, event) + }, +) { + private var forwarding = false + + fun forward(action: EditAction) { + if (forwarding) return + val target = focusOwner() ?: return + val modifiers = InputEvent.META_DOWN_MASK or (if (action.shift) InputEvent.SHIFT_DOWN_MASK else 0) + val now = System.currentTimeMillis() + forwarding = true + try { + for (id in listOf(KeyEvent.KEY_PRESSED, KeyEvent.KEY_RELEASED)) { + dispatch(target, KeyEvent(target, id, now, modifiers, action.keyCode, KeyEvent.CHAR_UNDEFINED)) + } + } finally { + forwarding = false + } + } +} diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/EditMenuTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/EditMenuTest.kt new file mode 100644 index 0000000000..c457c3db63 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/EditMenuTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.desktop.input + +import java.awt.Component +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import javax.swing.JPanel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EditMenuTest { + private val field = JPanel() + + private fun forwarded(action: EditAction, focused: Component? = field): List { + val sent = mutableListOf() + lateinit var forwarder: EditForwarder + forwarder = EditForwarder(focusOwner = { focused }) { _, event -> + sent += event + // A menu accelerator that catches the forwarded key must not forward it again. + forwarder.forward(action) + } + forwarder.forward(action) + return sent + } + + @Test + fun `each item sends its shortcut to the focused field`() { + val expected = mapOf( + EditAction.Undo to (KeyEvent.VK_Z to false), + EditAction.Redo to (KeyEvent.VK_Z to true), + EditAction.Cut to (KeyEvent.VK_X to false), + EditAction.Copy to (KeyEvent.VK_C to false), + EditAction.Paste to (KeyEvent.VK_V to false), + EditAction.SelectAll to (KeyEvent.VK_A to false), + ) + for ((action, key) in expected) { + val (code, shift) = key + val press = forwarded(action).first() + assertEquals(KeyEvent.KEY_PRESSED, press.id, "$action") + assertEquals(code, press.keyCode, "$action") + assertTrue(press.modifiersEx and InputEvent.META_DOWN_MASK != 0, "$action") + assertEquals(shift, press.modifiersEx and InputEvent.SHIFT_DOWN_MASK != 0, "$action") + } + } + + @Test + fun `press then release, once`() { + assertEquals(listOf(KeyEvent.KEY_PRESSED, KeyEvent.KEY_RELEASED), forwarded(EditAction.Paste).map { it.id }) + } + + @Test + fun `nothing focused sends nothing`() { + assertTrue(forwarded(EditAction.Copy, focused = null).isEmpty()) + } +} From ad1f8ff7b0d9cd94a4a42d8b04b26fc7e1f8b10b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 10:06:08 -0400 Subject: [PATCH 11/18] feat(desktop): sidebar rail, hide toggle and hover peek At 840-1100 with a context pane open, the chat list folds to an 80-wide rail so the pane gets a column instead of covering the chat. Resting on the rail for 150 ms floats the full list over the chat; leaving it, Esc, or picking a chat folds it back. Closing the pane brings the list back. Hiding the list yourself (the toggle beside the traffic lights, View > Hide Sidebar, Ctrl-Cmd-S; Ctrl+Shift+S on Windows and Linux) removes it at any width and is saved with the window. The toggle then sits in the chat's toolbar. The pane that floats over the chat below 840 no longer dims it. On the stock macOS title bar the glass sidebar now meets the top edge flush, so the traffic lights no longer sit on its rounded corner, and the window title is hidden behind the toolbar. Widths node 13045:3284. --- .../flipcash/desktop/DesktopMainContent.kt | 13 +- .../main/kotlin/com/flipcash/desktop/Main.kt | 26 +- .../flipcash/desktop/input/DesktopCommands.kt | 11 +- .../flipcash/desktop/input/DesktopMenuBar.kt | 10 +- .../com/flipcash/desktop/shell/ContextPane.kt | 2 +- .../flipcash/desktop/shell/DesktopLayout.kt | 24 +- .../flipcash/desktop/shell/DesktopNavState.kt | 32 +- .../flipcash/desktop/shell/DesktopPanes.kt | 1 - .../flipcash/desktop/shell/DesktopShell.kt | 276 ++++++++++++++---- .../flipcash/desktop/shell/SidebarFooter.kt | 67 +++++ .../flipcash/desktop/shell/WindowChrome.kt | 23 ++ .../desktop/window/WindowStateStore.kt | 2 + .../desktop/input/DesktopCommandsTest.kt | 21 ++ .../desktop/shell/DesktopLayoutTest.kt | 30 +- .../desktop/shell/DesktopNavStateTest.kt | 40 +++ .../desktop/shell/DesktopShellTest.kt | 51 +++- .../desktop/window/WindowStateStoreTest.kt | 6 + 17 files changed, 539 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt index 2e05b7cba0..c721dfa34a 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -20,6 +20,7 @@ import com.flipcash.desktop.shell.ContextPane import com.flipcash.desktop.shell.ConversationPlaceholder import com.flipcash.desktop.shell.DesktopNavState import com.flipcash.desktop.shell.DesktopShell +import com.flipcash.desktop.shell.RailFooter import com.flipcash.desktop.shell.SidebarFooter import com.flipcash.desktop.shell.WalletPane import com.flipcash.shared.common.ui.ContactAvatar @@ -62,7 +63,7 @@ fun DesktopMainContent( DesktopShell( nav = nav, floatingSidebar = isMac, - sidebar = { m -> ChatListPlaceholder(onOpenChat = { nav.compactShowsChat = true }, modifier = m) }, + sidebar = { m -> ChatListPlaceholder(onOpenChat = nav::openChat, modifier = m) }, main = { m -> ConversationPlaceholder(m) }, context = { content, m -> ContextPane( @@ -72,6 +73,16 @@ fun DesktopMainContent( modifier = m, ) }, + railFooter = { m -> + RailFooter( + balance = balance, + onYou = nav::openYou, + onWallet = nav::openWallet, + onSettings = onSettings, + modifier = m, + avatar = { am -> ContactAvatar(photoUri = null, displayName = displayName, modifier = am) }, + ) + }, footer = { m -> SidebarFooter( displayName = displayName, diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index 63fcbaa42d..87b1fa227a 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -17,11 +17,11 @@ import com.flipcash.desktop.input.HostOs import com.flipcash.desktop.input.desktopKeyHandler import com.flipcash.desktop.input.perform import com.flipcash.desktop.shell.ContextContent -import com.flipcash.desktop.shell.contextPaneModeFor import com.flipcash.desktop.shell.widthClassFor import com.flipcash.desktop.shell.DesktopDimens import com.flipcash.desktop.shell.DesktopNavState -import com.flipcash.desktop.shell.LocalTitleBarInset +import com.flipcash.desktop.shell.LocalWindowChrome +import com.flipcash.desktop.shell.WindowChrome import com.flipcash.desktop.window.SavedWindow import com.flipcash.desktop.window.WindowStateStore import com.flipcash.desktop.window.fitTo @@ -38,8 +38,9 @@ import java.lang.management.ManagementFactory internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") -// Height of the macOS title bar the content runs under, which the traffic lights sit in. -private val MacTitleBarHeight = 28.dp +// The standard macOS title bar the content runs under. Its traffic lights sit near the top edge, so +// the sidebar meets that edge flush rather than putting its rounded corner under them. +private val MacChrome = WindowChrome(titleBarHeight = 28.dp, controlsWidth = 72.dp, flushSidebar = true) private val MinWindow = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) @@ -63,7 +64,10 @@ fun main() { ) // Only Wallet comes back open; Info, You and a currency belong to what was on screen then. val nav = remember { - DesktopNavState().apply { if (saved?.contextPaneOpen == true) context = ContextContent.Wallet } + DesktopNavState().apply { + if (saved?.contextPaneOpen == true) context = ContextContent.Wallet + sidebarHidden = saved?.sidebarHidden == true + } } val awtWindowRef = remember { java.util.concurrent.atomic.AtomicReference() } // The floating bounds, kept while maximized or full screen so un-maximizing returns to them. @@ -84,10 +88,11 @@ fun main() { maximized = windowState.placement != WindowPlacement.Floating, // Before linking there is no Wallet to show; carry the saved choice forward. contextPaneOpen = if (signedIn) nav.context == ContextContent.Wallet else saved?.contextPaneOpen == true, + sidebarHidden = nav.sidebarHidden, ) } fun run(command: DesktopCommand): Boolean = - nav.perform(command, contextPaneModeFor(widthClassFor(windowState.size.width))) + nav.perform(command, nav.contextPaneModeAt(widthClassFor(windowState.size.width))) Window( onCloseRequest = { // Debounced saves can miss a move made just before quitting. @@ -101,18 +106,18 @@ fun main() { state = windowState, onPreviewKeyEvent = desktopKeyHandler(HostOs.current, ::run), ) { - if (isMac) DesktopMenuBar(windowState, ::run) + if (isMac) DesktopMenuBar(windowState, nav.sidebarHidden, ::run) LaunchedEffect(Unit) { awtWindowRef.set(window) configureChrome() } LaunchedEffect(Unit) { - snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, windowState.isMinimized, nav.context) } + snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, windowState.isMinimized, nav.context, nav.sidebarHidden) } .drop(1) .debounce(500) .collect { current()?.let(windowStore::save) } } - CompositionLocalProvider(LocalTitleBarInset provides if (isMac) MacTitleBarHeight else 0.dp) { + CompositionLocalProvider(LocalWindowChrome provides if (isMac) MacChrome else WindowChrome()) { DesktopApp(graph, signedIn, nav) } LaunchedEffect(Unit) { @@ -145,6 +150,7 @@ private fun FrameWindowScope.configureChrome() { if (isMac) { window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - window.rootPane.putClientProperty("apple.awt.windowTitleVisible", true) + // The toolbar names the chat; the window title would sit over it. + window.rootPane.putClientProperty("apple.awt.windowTitleVisible", false) } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt index ac2b33d8cb..38a12b1e98 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt @@ -5,7 +5,7 @@ import com.flipcash.desktop.shell.ContextPaneMode import com.flipcash.desktop.shell.DesktopNavState /** What the window's shortcuts and menu items do (spec: Keyboard shortcuts). */ -enum class DesktopCommand { Search, NewChat, ToggleInfo, Back, Settings, OpenInNewWindow, Escape } +enum class DesktopCommand { Search, NewChat, ToggleInfo, ToggleSidebar, Back, Settings, OpenInNewWindow, Escape } enum class HostOs { Mac, Windows, Linux; @@ -24,6 +24,7 @@ enum class HostOs { /** * The command for a key press: ⌘ on macOS, Ctrl on Windows and Linux, the same letter on each. * Shift, Alt, or the other platform's modifier, makes it something else. Esc needs no modifier. + * Hiding the sidebar is ⌃⌘S on macOS, as in Finder and Notes, and Ctrl+Shift+S elsewhere. */ fun commandFor( key: Key, @@ -34,6 +35,10 @@ fun commandFor( alt: Boolean = false, ): DesktopCommand? { if (key == Key.Escape) return if (!meta && !ctrl && !shift && !alt) DesktopCommand.Escape else null + if (key == Key.S && !alt) { + val sidebar = if (os == HostOs.Mac) meta && ctrl && !shift else ctrl && shift && !meta + if (sidebar) return DesktopCommand.ToggleSidebar + } val primary = if (os == HostOs.Mac) meta && !ctrl else ctrl && !meta // Alt (AltGr arrives as Ctrl+Alt on Windows) types characters on many layouts; never a command. if (!primary || shift || alt) return null @@ -55,6 +60,7 @@ fun commandFor( */ fun DesktopNavState.perform(command: DesktopCommand, mode: ContextPaneMode): Boolean = when (command) { DesktopCommand.ToggleInfo -> { toggleInfo(); true } + DesktopCommand.ToggleSidebar -> { toggleSidebar(); true } DesktopCommand.Back -> back() DesktopCommand.Escape -> onEscape(mode) DesktopCommand.Search, DesktopCommand.NewChat, DesktopCommand.OpenInNewWindow, DesktopCommand.Settings -> false @@ -62,4 +68,5 @@ fun DesktopNavState.perform(command: DesktopCommand, mode: ContextPaneMode): Boo /** Whether [perform] does anything for [command] yet; the menu bar greys out the rest. */ val DesktopCommand.available: Boolean - get() = this == DesktopCommand.ToggleInfo || this == DesktopCommand.Back || this == DesktopCommand.Escape + get() = this == DesktopCommand.ToggleInfo || this == DesktopCommand.ToggleSidebar || + this == DesktopCommand.Back || this == DesktopCommand.Escape diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt index 5a96f3a02b..fdb8d1a33d 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.window.WindowState import androidx.compose.ui.input.key.KeyShortcut /** The commands the macOS menu bar owns; their shortcuts reach the app through the menu, not as key events. */ -private val menuCommands = setOf(DesktopCommand.NewChat, DesktopCommand.ToggleInfo, DesktopCommand.Search) +private val menuCommands = setOf(DesktopCommand.NewChat, DesktopCommand.ToggleInfo, DesktopCommand.ToggleSidebar, DesktopCommand.Search) /** The window's key handling: a key press becomes a [DesktopCommand], unless the menu bar already handles it. */ fun desktopKeyHandler(os: HostOs, run: (DesktopCommand) -> Boolean): (KeyEvent) -> Boolean = handler@{ event -> @@ -35,7 +35,11 @@ fun desktopKeyHandler(os: HostOs, run: (DesktopCommand) -> Boolean): (KeyEvent) * disabled rather than hidden, so the menus keep their final shape. */ @Composable -fun FrameWindowScope.DesktopMenuBar(windowState: WindowState, run: (DesktopCommand) -> Boolean) { +fun FrameWindowScope.DesktopMenuBar( + windowState: WindowState, + sidebarHidden: Boolean, + run: (DesktopCommand) -> Boolean, +) { fun item(command: DesktopCommand) = { run(command); Unit } val edit = remember { EditForwarder() } MenuBar { @@ -50,6 +54,8 @@ fun FrameWindowScope.DesktopMenuBar(windowState: WindowState, run: (DesktopComma } } Menu("View") { + Item(if (sidebarHidden) "Show Sidebar" else "Hide Sidebar", enabled = DesktopCommand.ToggleSidebar.available, + shortcut = KeyShortcut(Key.S, meta = true, ctrl = true), onClick = item(DesktopCommand.ToggleSidebar)) Item("Chat Info", enabled = DesktopCommand.ToggleInfo.available, shortcut = KeyShortcut(Key.I, meta = true), onClick = item(DesktopCommand.ToggleInfo)) Item("Search", enabled = DesktopCommand.Search.available, diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt index 7538c5cc29..3555c1f07f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt @@ -31,7 +31,7 @@ fun ContextPane( val back: (() -> Unit)? = if (content is ContextContent.Currency && content.from != null) { { nav.back() } } else null - Box(modifier.padding(top = LocalTitleBarInset.current)) { + Box(modifier.padding(top = LocalWindowChrome.current.titleBarHeight)) { when (content) { ContextContent.Wallet -> wallet(Modifier) ContextContent.Info -> PlaceholderPane( diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt index d8b70492c0..07eb8237da 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -16,11 +16,27 @@ fun widthClassFor(width: Dp): WidthClass = when { else -> WidthClass.Wide } +/** + * How the chat list sits at the window's leading edge (widths node 13045:3284). Hidden is the user's + * choice and wins at every width; Rail is the list folding by itself so an open pane gets a column. + */ +enum class SidebarMode { List, Rail, Hidden } + +fun sidebarModeFor(widthClass: WidthClass, contextOpen: Boolean, hidden: Boolean): SidebarMode = when { + hidden -> SidebarMode.Hidden + widthClass == WidthClass.Expanded && contextOpen -> SidebarMode.Rail + else -> SidebarMode.List +} + /** How the context pane (Info, Wallet, Currency, You) sits next to the main pane. */ enum class ContextPaneMode { Column, Overlay } -fun contextPaneModeFor(widthClass: WidthClass): ContextPaneMode = - if (widthClass == WidthClass.Wide) ContextPaneMode.Column else ContextPaneMode.Overlay +/** A column at Wide, and at Expanded once the list is out of the way; below that it floats over the chat. */ +fun contextPaneModeFor(widthClass: WidthClass, sidebar: SidebarMode): ContextPaneMode = when (widthClass) { + WidthClass.Wide -> ContextPaneMode.Column + WidthClass.Expanded -> if (sidebar == SidebarMode.List) ContextPaneMode.Overlay else ContextPaneMode.Column + WidthClass.Medium, WidthClass.Compact -> ContextPaneMode.Overlay +} /** The sizes the Figma frames set (window 13000:91, widths 13045:3284). */ object DesktopDimens { @@ -29,7 +45,11 @@ object DesktopDimens { val SidebarMedium = 280.dp val SidebarExpanded = 300.dp + val SidebarRail = 80.dp val SidebarGlassInset = 8.dp + + /** The row the traffic lights sit in: the top of the sidebar and the chat's toolbar. */ + val TitleRowHeight = 52.dp val ContextPaneWidth = 336.dp val MessagesMaxWidth = 720.dp } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt index 696c1a52b2..bb5fc8ea56 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt @@ -36,6 +36,29 @@ class DesktopNavState { /** Below 600 the window shows the list or the chat; true while it shows the chat. */ var compactShowsChat: Boolean by mutableStateOf(false) + /** The user hid the chat list (toggle, View → Hide Sidebar). Sticky, and saved with the window. */ + var sidebarHidden: Boolean by mutableStateOf(false) + + /** The full list floating over the chat while the pointer rests on the rail. */ + var sidebarPeek: Boolean by mutableStateOf(false) + + fun sidebarModeAt(widthClass: WidthClass): SidebarMode = + sidebarModeFor(widthClass, contextOpen = context != null, hidden = sidebarHidden) + + fun contextPaneModeAt(widthClass: WidthClass): ContextPaneMode = + contextPaneModeFor(widthClass, sidebarModeAt(widthClass)) + + fun toggleSidebar() { + sidebarHidden = !sidebarHidden + sidebarPeek = false + } + + /** A chat picked from the list: compact shows it, and a floating list folds back to the rail. */ + fun openChat() { + compactShowsChat = true + sidebarPeek = false + } + fun toggleInfo() { context = if (context == ContextContent.Info) null else ContextContent.Info } @@ -77,8 +100,15 @@ class DesktopNavState { context = null } - /** Esc closes the context pane only when it slides over the main pane; a column stays put. */ + /** + * Esc folds a floating list back to the rail, else closes the context pane when it floats over the + * main pane; a column stays put. + */ fun onEscape(mode: ContextPaneMode): Boolean { + if (sidebarPeek) { + sidebarPeek = false + return true + } if (mode != ContextPaneMode.Overlay || context == null) return false context = null return true diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt index 4594185bc0..c2d5673cf6 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt @@ -38,7 +38,6 @@ fun PlaceholderPane( Column( modifier = modifier .fillMaxSize() - .padding(top = LocalTitleBarInset.current) .padding(16.dp) .testTag("placeholder_$title"), ) { diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index 7b68e1d6ff..6d18279bc8 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -1,13 +1,14 @@ package com.flipcash.desktop.shell +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,38 +20,56 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch -/** - * Height of the area the window's title bar draws over. On macOS the content runs under a transparent - * title bar, so whatever sits at the top of a pane starts below the traffic lights; elsewhere it is 0. - */ -val LocalTitleBarInset = compositionLocalOf { 0.dp } +// How long the pointer rests on the rail before the full list floats out (spec: Sidebar). +private const val PeekDelayMillis = 150L /** * The signed-in window (macOS window node 13000:91, widths node 13045:3284): the chat list with its * footer, the main pane, and the context pane that [nav] opens. The width class comes from the space * this is given, not the window, so an off-screen render at a fixed width lays out as the window does. * - * Wide gives the context pane its own column; below that it slides over the main pane with a scrim. - * Compact shows the list or the main pane, never both. [floatingSidebar] draws the sidebar as the - * macOS 26 glass panel inset from the window edges; elsewhere it is a flat surface. + * The list folds to a rail ([rail] and [railFooter]) at 840–1100 while a pane is open, and resting on + * the rail floats the full list over the chat. The user can hide the list at any width; the toggle + * then moves into the chat's toolbar. The context pane gets a column when there's room and otherwise + * floats over the chat without dimming it. Compact shows the list or the main pane, never both. + * [floatingSidebar] draws the sidebar as the macOS 26 glass panel inset from the window edges; + * elsewhere it is a flat surface. */ +@OptIn(ExperimentalComposeUiApi::class) @Composable fun DesktopShell( nav: DesktopNavState, @@ -59,24 +78,59 @@ fun DesktopShell( context: @Composable (ContextContent, Modifier) -> Unit, footer: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, + rail: @Composable (Modifier) -> Unit = {}, + railFooter: @Composable (Modifier) -> Unit = {}, title: String = "", floatingSidebar: Boolean = false, ) { BoxWithConstraints(modifier.fillMaxSize()) { val widthClass = widthClassFor(maxWidth) - val mode = contextPaneModeFor(widthClass) + val sidebarMode = nav.sidebarModeAt(widthClass) + val mode = nav.contextPaneModeAt(widthClass) + val chrome = LocalWindowChrome.current val hazeState = rememberHazeState() val shown = nav.context - val sidebarColumn: @Composable (Modifier) -> Unit = { m -> + // The floating list belongs to the rail; it can't outlive it. + LaunchedEffect(sidebarMode) { if (sidebarMode != SidebarMode.Rail) nav.sidebarPeek = false } + + val listColumn: @Composable (Modifier, Boolean) -> Unit = { m, withToggle -> Column(m.testTag("sidebar")) { - sidebar(Modifier.weight(1f).fillMaxWidth().padding(top = LocalTitleBarInset.current)) + Row( + modifier = Modifier.fillMaxWidth().height(DesktopDimens.TitleRowHeight).padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.weight(1f)) + if (withToggle) SidebarToggle(nav::toggleSidebar) + } + sidebar(Modifier.weight(1f).fillMaxWidth()) footer(Modifier.fillMaxWidth()) } } - val mainWithToolbar: @Composable (Modifier, (() -> Unit)?) -> Unit = { m, onBack -> + val railColumn: @Composable (Modifier) -> Unit = { m -> + Column(m.testTag("rail"), horizontalAlignment = Alignment.CenterHorizontally) { + // The traffic lights take the top row on macOS; the toggle and search sit under them. + Spacer(Modifier.height(chrome.titleBarHeight)) + SidebarToggle(nav::toggleSidebar) + Box(Modifier.size(32.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Search, contentDescription = "Search", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } + // A box holds the space, so the footer stays at the bottom whatever the slot draws. + Box(Modifier.weight(1f).fillMaxWidth()) { rail(Modifier.fillMaxSize()) } + railFooter(Modifier.fillMaxWidth()) + } + } + val mainWithToolbar: @Composable (Modifier, Boolean, (() -> Unit)?) -> Unit = { m, leadingEdge, onBack -> Column(m.testTag("main")) { - Toolbar(title = title, onBack = onBack, onInfo = nav::toggleInfo) + Toolbar( + title = title, + // At the window's leading edge the toolbar makes room for the traffic lights. + leadingInset = if (leadingEdge) chrome.controlsWidth else 0.dp, + showToggle = leadingEdge && onBack == null, + onToggleSidebar = nav::toggleSidebar, + onBack = onBack, + onInfo = nav::toggleInfo, + ) main(Modifier.weight(1f).fillMaxWidth()) } } @@ -87,77 +141,171 @@ fun DesktopShell( if (widthClass == WidthClass.Compact) { when { shown != null -> contextPane(shown, Modifier.fillMaxSize().testTag("context_overlay")) - nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize()) { nav.compactShowsChat = false } - else -> sidebarColumn(Modifier.fillMaxSize()) + nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize(), true) { nav.compactShowsChat = false } + else -> listColumn(Modifier.fillMaxSize(), false) } return@BoxWithConstraints } - val sidebarWidth = if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded - Row(Modifier.fillMaxSize()) { - if (floatingSidebar) { - // The glass panel floats over the window's background; the panes beside it are the - // haze source, so the panel tints what scrolls under its rounded edge. - Box(Modifier.width(sidebarWidth + DesktopDimens.SidebarGlassInset).fillMaxHeight()) { - sidebarColumn( - Modifier - .padding(DesktopDimens.SidebarGlassInset) - .padding(end = 0.dp) - .fillMaxSize() - .floatingGlass(hazeState, RoundedCornerShape(16.dp)), - ) + val listWidth = if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded + Box(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize()) { + when (sidebarMode) { + SidebarMode.List -> SidebarSlot(listWidth, floatingSidebar, hazeState, chrome) { m -> listColumn(m, true) } + SidebarMode.Rail -> { + val scope = rememberCoroutineScope() + var resting by remember { mutableStateOf(null) } + SidebarSlot( + width = DesktopDimens.SidebarRail, + floating = floatingSidebar, + hazeState = hazeState, + chrome = chrome, + modifier = Modifier + .onPointerEvent(PointerEventType.Enter) { + resting?.cancel() + resting = scope.launch { + delay(PeekDelayMillis) + nav.sidebarPeek = true + } + } + .onPointerEvent(PointerEventType.Exit) { resting?.cancel() }, + content = railColumn, + ) + } + SidebarMode.Hidden -> Unit } - } else { - sidebarColumn(Modifier.width(sidebarWidth).fillMaxHeight()) - PaneDivider() - } - Box(Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { - mainWithToolbar(Modifier.fillMaxSize(), null) - if (shown != null && mode == ContextPaneMode.Overlay) { - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = nav::closeContext, - ) - .testTag("context_scrim"), - ) + Box(Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { + mainWithToolbar(Modifier.fillMaxSize(), sidebarMode == SidebarMode.Hidden, null) + if (shown != null && mode == ContextPaneMode.Overlay) { + // Keeps its width and covers the chat without dimming it; the chat beside it + // stays usable. Esc and the pane's ✕ close it. + contextPane( + shown, + Modifier + .align(Alignment.TopEnd) + .width(DesktopDimens.ContextPaneWidth) + .fillMaxHeight() + .shadow(16.dp) + .testTag("context_overlay"), + ) + } + } + if (shown != null && mode == ContextPaneMode.Column) { + PaneDivider() contextPane( shown, - Modifier - .align(Alignment.TopEnd) - .width(DesktopDimens.ContextPaneWidth) - .fillMaxHeight() - .testTag("context_overlay"), + Modifier.width(DesktopDimens.ContextPaneWidth).fillMaxHeight().testTag("context_pane"), ) } } - if (shown != null && mode == ContextPaneMode.Column) { - PaneDivider() - contextPane( - shown, - Modifier.width(DesktopDimens.ContextPaneWidth).fillMaxHeight().testTag("context_pane"), - ) + if (sidebarMode == SidebarMode.Rail && nav.sidebarPeek) { + // The full list over the chat, which doesn't reflow. It covers the rail, so leaving it + // is leaving both. + SidebarSlot( + width = DesktopDimens.SidebarExpanded, + floating = true, + hazeState = hazeState, + chrome = chrome, + modifier = Modifier + .onPointerEvent(PointerEventType.Exit) { nav.sidebarPeek = false } + .testTag("sidebar_peek"), + elevated = true, + ) { m -> listColumn(m, true) } } } } } -/** The bar over the main pane: the chat's title, and ⓘ for its Info. Back appears at compact. */ +/** + * The sidebar's place at the leading edge. Floating, it's the glass panel inset from the window edges + * (or meeting the top edge, see [WindowChrome.flushSidebar]); otherwise a flat column with a divider. + */ +@Composable +private fun SidebarSlot( + width: Dp, + floating: Boolean, + hazeState: HazeState, + chrome: WindowChrome, + modifier: Modifier = Modifier, + elevated: Boolean = false, + content: @Composable (Modifier) -> Unit, +) { + if (!floating) { + Row(modifier.width(width).fillMaxHeight()) { + content(Modifier.weight(1f).fillMaxHeight().background(CodeTheme.colors.background)) + PaneDivider() + } + return + } + val inset = DesktopDimens.SidebarGlassInset + val shape: Shape = if (chrome.flushSidebar) { + RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp, bottomStart = 16.dp, bottomEnd = 16.dp) + } else { + RoundedCornerShape(16.dp) + } + Box(modifier.width(width + inset).fillMaxHeight()) { + content( + Modifier + .padding(start = inset, end = inset, bottom = inset, top = if (chrome.flushSidebar) 0.dp else inset) + .fillMaxSize() + .then(if (elevated) Modifier.shadow(24.dp, shape) else Modifier) + .floatingGlass(hazeState, shape), + ) + } +} + +/** Shows or hides the chat list, beside the traffic lights as in Finder and Notes (node 12997:185). */ +@Composable +private fun SidebarToggle(onClick: () -> Unit) { + val tint = CodeTheme.colors.textSecondary + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClickLabel = "Toggle sidebar", onClick = onClick) + .testTag("sidebar_toggle"), + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(width = 18.dp, height = 14.dp)) { + val stroke = 1.5.dp.toPx() + val half = stroke / 2 + drawRoundRect( + color = tint, + topLeft = Offset(half, half), + size = size.copy(width = size.width - stroke, height = size.height - stroke), + cornerRadius = CornerRadius(3.dp.toPx()), + style = Stroke(stroke), + ) + val x = size.width * 0.38f + drawLine(tint, Offset(x, half), Offset(x, size.height - half), stroke) + } + } +} + +/** + * The bar over the main pane: the chat's title, and ⓘ for its Info. It's the window's title row, so + * it shares the traffic lights' height; at the leading edge it starts after them, with the sidebar + * toggle while the list is hidden. Back appears at compact. + */ @Composable -private fun Toolbar(title: String, onBack: (() -> Unit)?, onInfo: () -> Unit) { +private fun Toolbar( + title: String, + leadingInset: Dp, + showToggle: Boolean, + onToggleSidebar: () -> Unit, + onBack: (() -> Unit)?, + onInfo: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() - .padding(top = LocalTitleBarInset.current) - .height(44.dp) + .height(DesktopDimens.TitleRowHeight) + .padding(start = leadingInset) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + if (showToggle) SidebarToggle(onToggleSidebar) if (onBack != null) { Text( text = "‹ Chats", diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt index 4d73a000ed..24d1cf57f0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt @@ -1,8 +1,12 @@ package com.flipcash.desktop.shell +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -10,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -18,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -84,3 +90,64 @@ fun SidebarFooter( } } } + +/** + * The rail's footer (rail node 13104:3750): Wallet, with the balance on hover, Settings, and your + * avatar for You. The same three destinations as [SidebarFooter], stacked. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RailFooter( + balance: String, + onYou: () -> Unit, + onWallet: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (Modifier) -> Unit = {}, +) { + Column( + modifier = modifier.padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TooltipArea( + tooltip = { + Text( + text = balance, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + modifier = Modifier + .background(CodeTheme.colors.surface, RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + }, + ) { + RailButton(Icons.Outlined.AccountBalanceWallet, "Wallet, $balance", onWallet, "rail_wallet") + } + RailButton(Icons.Outlined.Settings, "Settings", onSettings, "rail_settings") + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClickLabel = "You", onClick = onYou) + .testTag("rail_you"), + contentAlignment = Alignment.Center, + ) { + avatar(Modifier.size(28.dp)) + } + } +} + +@Composable +private fun RailButton(icon: ImageVector, label: String, onClick: () -> Unit, tag: String) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onClick) + .testTag(tag), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = label, tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt new file mode 100644 index 0000000000..82a9ce9206 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt @@ -0,0 +1,23 @@ +package com.flipcash.desktop.shell + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * What the OS window frame takes from the top-leading corner of the content. On macOS the content runs + * under the title bar: [titleBarHeight] is the band the traffic lights sit in and [controlsWidth] the + * room they take from the leading edge. Windows and Linux draw their own frame above the content, so + * both are 0. [flushSidebar] is set when the runtime can't make the title bar as tall as the sidebar's + * title row: the lights then sit near the top edge, so the glass panel meets that edge squarely + * instead of putting its rounded corner under them. + */ +@Immutable +data class WindowChrome( + val titleBarHeight: Dp = 0.dp, + val controlsWidth: Dp = 0.dp, + val flushSidebar: Boolean = false, +) + +val LocalWindowChrome = staticCompositionLocalOf { WindowChrome() } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt index 1dcb020f99..0c4bfc0870 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt @@ -21,6 +21,8 @@ data class SavedWindow( val height: Int, val maximized: Boolean, val contextPaneOpen: Boolean, + /** The user hid the chat list. Defaults so a file saved before the toggle existed still loads. */ + val sidebarHidden: Boolean = false, ) /** Reads and writes [SavedWindow] as JSON in [file]. A missing or unreadable file loads as null. */ diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt index 082399c521..baa0ec3997 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt @@ -81,4 +81,25 @@ class DesktopCommandsTest { assertFalse(nav.perform(c, ContextPaneMode.Column)) } } + + @Test + fun `hide sidebar is control command S on mac and control shift S elsewhere`() { + assertEquals(DesktopCommand.ToggleSidebar, commandFor(Key.S, meta = true, ctrl = true, shift = false, os = HostOs.Mac)) + assertNull(commandFor(Key.S, meta = true, ctrl = false, shift = false, os = HostOs.Mac)) + assertNull(commandFor(Key.S, meta = true, ctrl = true, shift = true, os = HostOs.Mac)) + for (os in listOf(HostOs.Windows, HostOs.Linux)) { + assertEquals(DesktopCommand.ToggleSidebar, commandFor(Key.S, meta = false, ctrl = true, shift = true, os = os)) + assertNull(commandFor(Key.S, meta = false, ctrl = true, shift = false, os = os)) + assertNull(commandFor(Key.S, meta = false, ctrl = true, shift = true, os = os, alt = true)) + } + } + + @Test + fun `toggle sidebar hides and shows it`() { + val nav = DesktopNavState() + assertTrue(nav.perform(DesktopCommand.ToggleSidebar, ContextPaneMode.Column)) + assertTrue(nav.sidebarHidden) + nav.perform(DesktopCommand.ToggleSidebar, ContextPaneMode.Column) + assertFalse(nav.sidebarHidden) + } } diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt index c92bc0db92..3d872b00a8 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt @@ -17,10 +17,30 @@ class DesktopLayoutTest { } @Test - fun `context pane overlays below wide`() { - assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Compact)) - assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Medium)) - assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Expanded)) - assertEquals(ContextPaneMode.Column, contextPaneModeFor(WidthClass.Wide)) + fun `the list folds to a rail only at expanded with a pane open`() { + assertEquals(SidebarMode.Rail, sidebarModeFor(WidthClass.Expanded, contextOpen = true, hidden = false)) + assertEquals(SidebarMode.List, sidebarModeFor(WidthClass.Expanded, contextOpen = false, hidden = false)) + for (w in listOf(WidthClass.Compact, WidthClass.Medium, WidthClass.Wide)) { + assertEquals(SidebarMode.List, sidebarModeFor(w, contextOpen = true, hidden = false), "at $w") + } + } + + @Test + fun `hiding the sidebar wins over the rail`() { + for (w in WidthClass.entries) { + assertEquals(SidebarMode.Hidden, sidebarModeFor(w, contextOpen = true, hidden = true), "at $w") + } + } + + @Test + fun `context pane is a column at wide, or at expanded once the list is out of the way`() { + for (s in SidebarMode.entries) { + assertEquals(ContextPaneMode.Column, contextPaneModeFor(WidthClass.Wide, s)) + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Medium, s)) + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Compact, s)) + } + assertEquals(ContextPaneMode.Overlay, contextPaneModeFor(WidthClass.Expanded, SidebarMode.List)) + assertEquals(ContextPaneMode.Column, contextPaneModeFor(WidthClass.Expanded, SidebarMode.Rail)) + assertEquals(ContextPaneMode.Column, contextPaneModeFor(WidthClass.Expanded, SidebarMode.Hidden)) } } diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt index 594ea542d0..ced48af149 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt @@ -97,4 +97,44 @@ class DesktopNavStateTest { assertFalse(s.compactShowsChat) assertFalse(s.back()) } + + @Test + fun `hiding the sidebar sticks when a pane opens and closes`() { + val s = DesktopNavState() + s.toggleSidebar() + s.openWallet() + assertEquals(SidebarMode.Hidden, s.sidebarModeAt(WidthClass.Expanded)) + s.closeContext() + assertEquals(SidebarMode.Hidden, s.sidebarModeAt(WidthClass.Wide)) + s.toggleSidebar() + assertEquals(SidebarMode.List, s.sidebarModeAt(WidthClass.Wide)) + } + + @Test + fun `closing the pane brings the list back from the rail`() { + val s = DesktopNavState() + s.openWallet() + assertEquals(SidebarMode.Rail, s.sidebarModeAt(WidthClass.Expanded)) + assertEquals(ContextPaneMode.Column, s.contextPaneModeAt(WidthClass.Expanded)) + s.closeContext() + assertEquals(SidebarMode.List, s.sidebarModeAt(WidthClass.Expanded)) + } + + @Test + fun `escape folds the floating list before it closes a pane`() { + val s = DesktopNavState() + s.openWallet() + s.sidebarPeek = true + assertTrue(s.onEscape(ContextPaneMode.Overlay)) + assertFalse(s.sidebarPeek) + assertEquals(ContextContent.Wallet, s.context) + } + + @Test + fun `picking a chat folds the floating list`() { + val s = DesktopNavState() + s.sidebarPeek = true + s.openChat() + assertFalse(s.sidebarPeek) + } } diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt index e111642ab1..5837276980 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt @@ -15,13 +15,16 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashTheme import org.junit.Rule import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue // The test window is 1024 x 768, so at the wide widths these tests use the trailing controls sit // outside it; the click goes through the node's action rather than a pointer. @@ -70,23 +73,57 @@ class DesktopShellTest { } @Test - fun `wallet overlays main below 1100 and survives a resize`() = shell(1280.dp) { + fun `wallet takes a column beside a rail at 1000 and the list returns when it closes`() = shell(1280.dp) { onNodeWithTag("footer_balance").click() - onNodeWithTag("context_pane").assertExists() width = 1000.dp waitForIdle() - onNodeWithTag("context_overlay").assertExists() + onNodeWithTag("context_pane").assertExists() + onNodeWithTag("rail").assertExists() + onNodeWithTag("sidebar").assertDoesNotExist() onNodeWithTag("fake_Wallet").assertExists() - assertEquals(ContextContent.Wallet, nav.context) + nav.closeContext() + waitForIdle() + onNodeWithTag("sidebar").assertExists() + onNodeWithTag("rail").assertDoesNotExist() } @Test - fun `scrim closes the overlay`() = shell(1000.dp) { + fun `wallet floats over the chat below 840 without dimming it, and esc closes it`() = shell(700.dp) { onNodeWithTag("footer_balance").click() - onNodeWithTag("context_scrim").click() + onNodeWithTag("context_overlay").assertExists() + onNodeWithTag("context_scrim").assertDoesNotExist() + assertTrue(nav.onEscape(nav.contextPaneModeAt(WidthClass.Medium))) + waitForIdle() onNodeWithTag("context_overlay").assertDoesNotExist() } + @Test + fun `the toggle hides the list, and the toolbar's toggle brings it back`() = shell(1280.dp) { + onNodeWithTag("sidebar_toggle").click() + onNodeWithTag("sidebar").assertDoesNotExist() + onNodeWithTag("rail").assertDoesNotExist() + width = 1000.dp + waitForIdle() + onNodeWithTag("sidebar").assertDoesNotExist() + onNodeWithTag("sidebar_toggle").click() + onNodeWithTag("sidebar").assertExists() + } + + @Test + fun `resting on the rail floats the list, and leaving it folds back`() = shell(1000.dp) { + nav.openWallet() + waitForIdle() + onNodeWithTag("rail").performMouseInput { moveTo(center) } + waitUntil(2_000) { nav.sidebarPeek } + onNodeWithTag("sidebar_peek").assertExists() + onNodeWithTag("sidebar_peek").performMouseInput { + moveTo(center) + moveTo(Offset(width + 200f, centerY)) + } + waitUntil(2_000) { !nav.sidebarPeek } + onNodeWithTag("sidebar_peek").assertDoesNotExist() + } + @Test fun `info button toggles info`() = shell(1280.dp) { onNodeWithTag("toolbar_info").click() @@ -102,7 +139,7 @@ class DesktopShellTest { } @Test - fun `no rail or tab bar at any width`() = shell(500.dp) { + fun `no rail without an open pane, and no tab bar at any width`() = shell(500.dp) { for (w in listOf(500.dp, 700.dp, 1000.dp, 1280.dp)) { width = w waitForIdle() diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt index f6a9f7f7f2..03b5d78b3a 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt @@ -38,6 +38,12 @@ class WindowStateStoreTest { assertNull(store.load()) } + @Test + fun `a file saved before the sidebar toggle loads with the sidebar shown`() { + file.writeText("""{"x":1,"y":2,"width":1040,"height":760,"maximized":false,"contextPaneOpen":true}""") + assertEquals(SavedWindow(1, 2, 1040, 760, maximized = false, contextPaneOpen = true, sidebarHidden = false), store.load()) + } + @Test fun `corrupt file loads as null`() { file.writeText("{not json") From f4f14be9eec2d3a9fdd8c110f145df6143f865c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 10:12:23 -0400 Subject: [PATCH 12/18] feat(desktop): center the traffic lights in a 52dp title row On macOS the traffic lights sat in the standard 28dp title bar, on the glass sidebar panel's rounded corner. With the JetBrains Runtime's custom title bar (org.jetbrains.runtime:jbr-api) the bar is 52dp tall, so the lights center in the sidebar's title row next to the toggle, as in Figma node 13000:93. Controls in the band claim their own presses through forceHitTest, and anything else still drags the window, the same rule as Jewel's TitleBar.MacOS.kt. On a runtime without window decorations the window keeps the standard title bar. WindowStateStore.TitleBarHeight moves to 52 so restoring a window still fits it on screen. --- apps/desktop/build.gradle.kts | 1 + .../main/kotlin/com/flipcash/desktop/Main.kt | 31 ++++++++++++-- .../flipcash/desktop/shell/DesktopShell.kt | 27 +++++++----- .../flipcash/desktop/window/MacTitleBar.kt | 42 +++++++++++++++++++ .../desktop/window/WindowStateStore.kt | 2 +- gradle/libs.versions.toml | 2 + 6 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/main/kotlin/com/flipcash/desktop/window/MacTitleBar.kt diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 373647cf95..7121afe3e6 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.coil3) implementation(libs.coil3.network) implementation(libs.event.bus) + implementation(libs.jbr.api) testImplementation(project(":apps:flipcash:startup-contract")) testImplementation(libs.kotlin.test.junit) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index 87b1fa227a..ced61075eb 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -1,6 +1,11 @@ package com.flipcash.desktop +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow @@ -25,6 +30,9 @@ import com.flipcash.desktop.shell.WindowChrome import com.flipcash.desktop.window.SavedWindow import com.flipcash.desktop.window.WindowStateStore import com.flipcash.desktop.window.fitTo +import com.flipcash.desktop.window.installTallTitleBar +import com.flipcash.desktop.window.titleBarHitTest +import com.jetbrains.WindowDecorations.CustomTitleBar import dev.zacsweers.metro.createGraph import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce @@ -38,10 +46,15 @@ import java.lang.management.ManagementFactory internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") -// The standard macOS title bar the content runs under. Its traffic lights sit near the top edge, so -// the sidebar meets that edge flush rather than putting its rounded corner under them. +// The standard macOS title bar the content runs under, when the runtime can't make it taller. Its +// traffic lights sit near the top edge, so the sidebar meets that edge flush rather than putting its +// rounded corner under them. private val MacChrome = WindowChrome(titleBarHeight = 28.dp, controlsWidth = 72.dp, flushSidebar = true) +/** The tall title bar: the traffic lights centre in the 52-tall title row the sidebar and toolbar share. */ +private fun tallChrome(bar: CustomTitleBar) = + WindowChrome(titleBarHeight = DesktopDimens.TitleRowHeight, controlsWidth = bar.leftInset.dp) + private val MinWindow = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) @OptIn(FlowPreview::class) @@ -91,6 +104,7 @@ fun main() { sidebarHidden = nav.sidebarHidden, ) } + var titleBar by remember { mutableStateOf(null) } fun run(command: DesktopCommand): Boolean = nav.perform(command, nav.contextPaneModeAt(widthClassFor(windowState.size.width))) Window( @@ -110,6 +124,7 @@ fun main() { LaunchedEffect(Unit) { awtWindowRef.set(window) configureChrome() + if (isMac) titleBar = installTallTitleBar(window, DesktopDimens.TitleRowHeight.value) } LaunchedEffect(Unit) { snapshotFlow { listOf(windowState.position, windowState.size, windowState.placement, windowState.isMinimized, nav.context, nav.sidebarHidden) } @@ -117,8 +132,16 @@ fun main() { .debounce(500) .collect { current()?.let(windowStore::save) } } - CompositionLocalProvider(LocalWindowChrome provides if (isMac) MacChrome else WindowChrome()) { - DesktopApp(graph, signedIn, nav) + val bar = titleBar + val chrome = when { + bar != null -> tallChrome(bar) + isMac -> MacChrome + else -> WindowChrome() + } + CompositionLocalProvider(LocalWindowChrome provides chrome) { + Box(if (bar != null) Modifier.titleBarHitTest(bar) else Modifier) { + DesktopApp(graph, signedIn, nav) + } } LaunchedEffect(Unit) { val sinceJvmStart = System.currentTimeMillis() - diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index 6d18279bc8..14034040ba 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -94,10 +94,15 @@ fun DesktopShell( // The floating list belongs to the rail; it can't outlive it. LaunchedEffect(sidebarMode) { if (sidebarMode != SidebarMode.Rail) nav.sidebarPeek = false } - val listColumn: @Composable (Modifier, Boolean) -> Unit = { m, withToggle -> + // topInset is how far below the window's top edge the slot starts; the title row is measured + // from the window, so the toggle lines up with the traffic lights and the toolbar. + val listColumn: @Composable (Modifier, Boolean, Dp) -> Unit = { m, withToggle, topInset -> Column(m.testTag("sidebar")) { Row( - modifier = Modifier.fillMaxWidth().height(DesktopDimens.TitleRowHeight).padding(end = 8.dp), + modifier = Modifier + .fillMaxWidth() + .height(DesktopDimens.TitleRowHeight - topInset) + .padding(bottom = topInset, end = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.weight(1f)) @@ -107,10 +112,10 @@ fun DesktopShell( footer(Modifier.fillMaxWidth()) } } - val railColumn: @Composable (Modifier) -> Unit = { m -> + val railColumn: @Composable (Modifier, Dp) -> Unit = { m, topInset -> Column(m.testTag("rail"), horizontalAlignment = Alignment.CenterHorizontally) { // The traffic lights take the top row on macOS; the toggle and search sit under them. - Spacer(Modifier.height(chrome.titleBarHeight)) + Spacer(Modifier.height((chrome.titleBarHeight - topInset).coerceAtLeast(0.dp))) SidebarToggle(nav::toggleSidebar) Box(Modifier.size(32.dp), contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Search, contentDescription = "Search", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) @@ -142,7 +147,7 @@ fun DesktopShell( when { shown != null -> contextPane(shown, Modifier.fillMaxSize().testTag("context_overlay")) nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize(), true) { nav.compactShowsChat = false } - else -> listColumn(Modifier.fillMaxSize(), false) + else -> listColumn(Modifier.fillMaxSize(), false, 0.dp) } return@BoxWithConstraints } @@ -151,7 +156,7 @@ fun DesktopShell( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { when (sidebarMode) { - SidebarMode.List -> SidebarSlot(listWidth, floatingSidebar, hazeState, chrome) { m -> listColumn(m, true) } + SidebarMode.List -> SidebarSlot(listWidth, floatingSidebar, hazeState, chrome) { m, top -> listColumn(m, true, top) } SidebarMode.Rail -> { val scope = rememberCoroutineScope() var resting by remember { mutableStateOf(null) } @@ -210,7 +215,7 @@ fun DesktopShell( .onPointerEvent(PointerEventType.Exit) { nav.sidebarPeek = false } .testTag("sidebar_peek"), elevated = true, - ) { m -> listColumn(m, true) } + ) { m, top -> listColumn(m, true, top) } } } } @@ -228,11 +233,11 @@ private fun SidebarSlot( chrome: WindowChrome, modifier: Modifier = Modifier, elevated: Boolean = false, - content: @Composable (Modifier) -> Unit, + content: @Composable (Modifier, topInset: Dp) -> Unit, ) { if (!floating) { Row(modifier.width(width).fillMaxHeight()) { - content(Modifier.weight(1f).fillMaxHeight().background(CodeTheme.colors.background)) + content(Modifier.weight(1f).fillMaxHeight().background(CodeTheme.colors.background), 0.dp) PaneDivider() } return @@ -243,13 +248,15 @@ private fun SidebarSlot( } else { RoundedCornerShape(16.dp) } + val top = if (chrome.flushSidebar) 0.dp else inset Box(modifier.width(width + inset).fillMaxHeight()) { content( Modifier - .padding(start = inset, end = inset, bottom = inset, top = if (chrome.flushSidebar) 0.dp else inset) + .padding(start = inset, end = inset, bottom = inset, top = top) .fillMaxSize() .then(if (elevated) Modifier.shadow(24.dp, shape) else Modifier) .floatingGlass(hazeState, shape), + top, ) } } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/MacTitleBar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/MacTitleBar.kt new file mode 100644 index 0000000000..36139c05eb --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/MacTitleBar.kt @@ -0,0 +1,42 @@ +package com.flipcash.desktop.window + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import com.jetbrains.JBR +import com.jetbrains.WindowDecorations.CustomTitleBar +import java.awt.Frame + +/** + * Makes the macOS title bar [height] tall, so the traffic lights centre in the sidebar's title row + * (node 13000:93) rather than near the window's top edge. Needs the JetBrains Runtime; on any other + * runtime this returns null and the window keeps the standard title bar. + */ +fun installTallTitleBar(window: Frame, height: Float): CustomTitleBar? { + if (!JBR.isWindowDecorationsSupported()) return null + val decorations = JBR.getWindowDecorations() + val bar = decorations.createCustomTitleBar() + bar.height = height + decorations.setCustomTitleBar(window, bar) + return bar +} + +/** + * Inside the title bar a press drags the window unless the app claims it. A press that a control + * consumed is the control's, and so is the drag that follows it until release; anything else (the + * toolbar's title, empty space) drags the window, as a native title bar does. Same rule as Jewel's + * TitleBar.MacOS.kt. + */ +fun Modifier.titleBarHitTest(bar: CustomTitleBar): Modifier = pointerInput(bar) { + awaitPointerEventScope { + var inControl = false + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + val consumed = event.changes.any { it.isConsumed } + if (event.type == PointerEventType.Press && consumed) inControl = true + bar.forceHitTest(consumed || inControl) + if (event.type == PointerEventType.Release) inControl = false + } + } +} diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt index 0c4bfc0870..6a121355d0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt @@ -48,7 +48,7 @@ class WindowStateStore(private val file: Path) { } // The strip a user drags the window by. If none of it is on a screen, the window can't be moved back. -private const val TitleBarHeight = 28 +private const val TitleBarHeight = 52 /** * Fits the saved window to the [screens] now attached (usable bounds, without the dock or taskbar): diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b423c7813..a9a2848f4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -110,6 +110,7 @@ desugaring = "2.1.5" kotlincrypto-hash = "0.8.0" kotlincrypto-macs = "0.8.0" event-bus = "0.1.0" +jbr-api = "1.12.0" bignum = "0.3.10" bugsnag = "6.28.0" @@ -313,6 +314,7 @@ compose-unstyled-bottom-sheet = { module = "com.composables:composeunstyled-bott rinku = { module = "dev.theolm:rinku", version.ref = "rinku" } rinku-compose = { module = "dev.theolm:rinku-compose-ext", version.ref = "rinku" } event-bus = { module = "io.github.hoc081098:channel-event-bus", version.ref = "event-bus" } +jbr-api = { module = "org.jetbrains.runtime:jbr-api", version.ref = "jbr-api" } # Baseline Profile / Benchmark androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro" } From 7e689fad21eb286417ded2bd20e89e67b0a16c54 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 10:20:02 -0400 Subject: [PATCH 13/18] feat(desktop): flat sidebar, and panes slide instead of jumping The sidebar was a tinted glass panel inset from the window edge. It is now the window's own background with a hairline divider, as redrawn in node 13000:91, so WindowChrome.flushSidebar and the inset go. Hiding the list, folding it to the rail, and opening or closing the context pane all animate over 250ms. The sidebar's width slides with its content pinned to the leading edge and clipped, so text does not reflow; the overlay pane and the rail's peek slide in from their edges. The sidebar toggle is now one button fixed beside the traffic lights. With a toggle in both the sidebar and the toolbar, a recording of the hide showed two toggles at once and then none on the way back. --- .../flipcash/desktop/DesktopMainContent.kt | 1 - .../main/kotlin/com/flipcash/desktop/Main.kt | 6 +- .../flipcash/desktop/shell/DesktopLayout.kt | 1 - .../flipcash/desktop/shell/DesktopShell.kt | 270 ++++++++++-------- .../flipcash/desktop/shell/WindowChrome.kt | 5 +- 5 files changed, 161 insertions(+), 122 deletions(-) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt index c721dfa34a..413143fe26 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -62,7 +62,6 @@ fun DesktopMainContent( Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { DesktopShell( nav = nav, - floatingSidebar = isMac, sidebar = { m -> ChatListPlaceholder(onOpenChat = nav::openChat, modifier = m) }, main = { m -> ConversationPlaceholder(m) }, context = { content, m -> diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt index ced61075eb..0afcac3461 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -46,10 +46,8 @@ import java.lang.management.ManagementFactory internal val isMac = System.getProperty("os.name").orEmpty().lowercase().startsWith("mac") -// The standard macOS title bar the content runs under, when the runtime can't make it taller. Its -// traffic lights sit near the top edge, so the sidebar meets that edge flush rather than putting its -// rounded corner under them. -private val MacChrome = WindowChrome(titleBarHeight = 28.dp, controlsWidth = 72.dp, flushSidebar = true) +// The standard macOS title bar the content runs under, when the runtime can't make it taller. +private val MacChrome = WindowChrome(titleBarHeight = 28.dp, controlsWidth = 72.dp) /** The tall title bar: the traffic lights centre in the 52-tall title row the sidebar and toolbar share. */ private fun tallChrome(bar: CustomTitleBar) = diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt index 07eb8237da..c7010ffb1e 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -46,7 +46,6 @@ object DesktopDimens { val SidebarMedium = 280.dp val SidebarExpanded = 300.dp val SidebarRail = 80.dp - val SidebarGlassInset = 8.dp /** The row the traffic lights sit in: the top of the sidebar and the chat's toolbar. */ val TitleRowHeight = 52.dp diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index 14034040ba..b191b5182b 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -1,5 +1,16 @@ package com.flipcash.desktop.shell +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -14,6 +25,7 @@ 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.wrapContentWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -34,22 +46,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme -import com.getcode.ui.components.glass.floatingGlass -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -66,8 +76,6 @@ private const val PeekDelayMillis = 150L * the rail floats the full list over the chat. The user can hide the list at any width; the toggle * then moves into the chat's toolbar. The context pane gets a column when there's room and otherwise * floats over the chat without dimming it. Compact shows the list or the main pane, never both. - * [floatingSidebar] draws the sidebar as the macOS 26 glass panel inset from the window edges; - * elsewhere it is a flat surface. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -81,41 +89,29 @@ fun DesktopShell( rail: @Composable (Modifier) -> Unit = {}, railFooter: @Composable (Modifier) -> Unit = {}, title: String = "", - floatingSidebar: Boolean = false, ) { BoxWithConstraints(modifier.fillMaxSize()) { val widthClass = widthClassFor(maxWidth) val sidebarMode = nav.sidebarModeAt(widthClass) val mode = nav.contextPaneModeAt(widthClass) val chrome = LocalWindowChrome.current - val hazeState = rememberHazeState() val shown = nav.context // The floating list belongs to the rail; it can't outlive it. LaunchedEffect(sidebarMode) { if (sidebarMode != SidebarMode.Rail) nav.sidebarPeek = false } - // topInset is how far below the window's top edge the slot starts; the title row is measured - // from the window, so the toggle lines up with the traffic lights and the toolbar. - val listColumn: @Composable (Modifier, Boolean, Dp) -> Unit = { m, withToggle, topInset -> + // The title row is left empty: the traffic lights and the toggle sit over it. + val listColumn: @Composable (Modifier) -> Unit = { m -> Column(m.testTag("sidebar")) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(DesktopDimens.TitleRowHeight - topInset) - .padding(bottom = topInset, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.weight(1f)) - if (withToggle) SidebarToggle(nav::toggleSidebar) - } + Spacer(Modifier.height(DesktopDimens.TitleRowHeight)) sidebar(Modifier.weight(1f).fillMaxWidth()) footer(Modifier.fillMaxWidth()) } } - val railColumn: @Composable (Modifier, Dp) -> Unit = { m, topInset -> + val railColumn: @Composable (Modifier) -> Unit = { m -> Column(m.testTag("rail"), horizontalAlignment = Alignment.CenterHorizontally) { // The traffic lights take the top row on macOS; the toggle and search sit under them. - Spacer(Modifier.height((chrome.titleBarHeight - topInset).coerceAtLeast(0.dp))) + Spacer(Modifier.height(chrome.titleBarHeight)) SidebarToggle(nav::toggleSidebar) Box(Modifier.size(32.dp), contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Search, contentDescription = "Search", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) @@ -125,14 +121,11 @@ fun DesktopShell( railFooter(Modifier.fillMaxWidth()) } } - val mainWithToolbar: @Composable (Modifier, Boolean, (() -> Unit)?) -> Unit = { m, leadingEdge, onBack -> + val mainWithToolbar: @Composable (Modifier, Dp, (() -> Unit)?) -> Unit = { m, leadingInset, onBack -> Column(m.testTag("main")) { Toolbar( title = title, - // At the window's leading edge the toolbar makes room for the traffic lights. - leadingInset = if (leadingEdge) chrome.controlsWidth else 0.dp, - showToggle = leadingEdge && onBack == null, - onToggleSidebar = nav::toggleSidebar, + leadingInset = leadingInset, onBack = onBack, onInfo = nav::toggleInfo, ) @@ -146,118 +139,174 @@ fun DesktopShell( if (widthClass == WidthClass.Compact) { when { shown != null -> contextPane(shown, Modifier.fillMaxSize().testTag("context_overlay")) - nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize(), true) { nav.compactShowsChat = false } - else -> listColumn(Modifier.fillMaxSize(), false, 0.dp) + nav.compactShowsChat -> mainWithToolbar(Modifier.fillMaxSize(), chrome.controlsWidth) { nav.compactShowsChat = false } + else -> listColumn(Modifier.fillMaxSize()) } return@BoxWithConstraints } val listWidth = if (widthClass == WidthClass.Medium) DesktopDimens.SidebarMedium else DesktopDimens.SidebarExpanded + // Hiding, folding to the rail and opening a pane all move the panes, so they slide rather than + // jump. The sidebar's content is laid out at its own width and clipped, so text doesn't reflow + // as the edge moves. + val sidebarWidth by animateDpAsState( + targetValue = when (sidebarMode) { + SidebarMode.List -> listWidth + SidebarMode.Rail -> DesktopDimens.SidebarRail + SidebarMode.Hidden -> 0.dp + }, + animationSpec = PaneMotion.size(), + label = "sidebar width", + ) + // With the list hidden, the toolbar starts after the traffic lights and the toggle. + val toolbarInset by animateDpAsState( + targetValue = if (sidebarMode == SidebarMode.Hidden) toggleStart(chrome) + ToggleSize else 0.dp, + animationSpec = PaneMotion.size(), + label = "toolbar inset", + ) + // While the sidebar slides away it keeps showing what it showed. + var shownSidebar by remember { mutableStateOf(sidebarMode) } + if (sidebarMode != SidebarMode.Hidden) shownSidebar = sidebarMode + // The same for the context pane, so it has something to draw while it closes. + var lastShown by remember { mutableStateOf(shown) } + if (shown != null) lastShown = shown Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - when (sidebarMode) { - SidebarMode.List -> SidebarSlot(listWidth, floatingSidebar, hazeState, chrome) { m, top -> listColumn(m, true, top) } - SidebarMode.Rail -> { - val scope = rememberCoroutineScope() - var resting by remember { mutableStateOf(null) } - SidebarSlot( - width = DesktopDimens.SidebarRail, - floating = floatingSidebar, - hazeState = hazeState, - chrome = chrome, - modifier = Modifier - .onPointerEvent(PointerEventType.Enter) { - resting?.cancel() - resting = scope.launch { - delay(PeekDelayMillis) - nav.sidebarPeek = true - } + if (sidebarWidth > 0.dp) { + val scope = rememberCoroutineScope() + var resting by remember { mutableStateOf(null) } + SidebarSlot( + width = sidebarWidth, + modifier = Modifier + .onPointerEvent(PointerEventType.Enter) { + if (sidebarMode != SidebarMode.Rail) return@onPointerEvent + resting?.cancel() + resting = scope.launch { + delay(PeekDelayMillis) + nav.sidebarPeek = true } - .onPointerEvent(PointerEventType.Exit) { resting?.cancel() }, - content = railColumn, - ) + } + .onPointerEvent(PointerEventType.Exit) { resting?.cancel() }, + ) { m -> + Crossfade(shownSidebar, animationSpec = PaneMotion.fade(), label = "sidebar content") { content -> + when (content) { + SidebarMode.Rail -> railColumn(m.wrapContentWidth(Alignment.Start, unbounded = true).width(DesktopDimens.SidebarRail)) + else -> listColumn(m.wrapContentWidth(Alignment.Start, unbounded = true).width(listWidth)) + } + } } - SidebarMode.Hidden -> Unit } - Box(Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { - mainWithToolbar(Modifier.fillMaxSize(), sidebarMode == SidebarMode.Hidden, null) - if (shown != null && mode == ContextPaneMode.Overlay) { - // Keeps its width and covers the chat without dimming it; the chat beside it - // stays usable. Esc and the pane's ✕ close it. - contextPane( - shown, - Modifier - .align(Alignment.TopEnd) - .width(DesktopDimens.ContextPaneWidth) - .fillMaxHeight() - .shadow(16.dp) - .testTag("context_overlay"), - ) + Box(Modifier.weight(1f).fillMaxHeight()) { + mainWithToolbar(Modifier.fillMaxSize(), toolbarInset, null) + // Keeps its width and covers the chat without dimming it; the chat beside it stays + // usable. Esc and the pane's ✕ close it. + OverlayPane( + visible = shown != null && mode == ContextPaneMode.Overlay, + modifier = Modifier.align(Alignment.TopEnd), + ) { + lastShown?.let { + contextPane( + it, + Modifier + .width(DesktopDimens.ContextPaneWidth) + .fillMaxHeight() + .shadow(16.dp) + .testTag("context_overlay"), + ) + } } } - if (shown != null && mode == ContextPaneMode.Column) { - PaneDivider() - contextPane( - shown, - Modifier.width(DesktopDimens.ContextPaneWidth).fillMaxHeight().testTag("context_pane"), - ) + AnimatedVisibility( + visible = shown != null && mode == ContextPaneMode.Column, + enter = expandHorizontally(PaneMotion.bounds(), expandFrom = Alignment.Start), + exit = shrinkHorizontally(PaneMotion.bounds(), shrinkTowards = Alignment.Start), + ) { + Row(Modifier.fillMaxHeight()) { + PaneDivider() + lastShown?.let { + contextPane( + it, + Modifier.width(DesktopDimens.ContextPaneWidth).fillMaxHeight().testTag("context_pane"), + ) + } + } } } - if (sidebarMode == SidebarMode.Rail && nav.sidebarPeek) { - // The full list over the chat, which doesn't reflow. It covers the rail, so leaving it - // is leaving both. + // The full list over the chat, which doesn't reflow. It covers the rail, so leaving it is + // leaving both. + AnimatedVisibility( + visible = sidebarMode == SidebarMode.Rail && nav.sidebarPeek, + enter = slideInHorizontally(PaneMotion.offset()) { -it / 3 } + fadeIn(PaneMotion.fade()), + exit = slideOutHorizontally(PaneMotion.offset()) { -it / 3 } + fadeOut(PaneMotion.fade()), + ) { SidebarSlot( width = DesktopDimens.SidebarExpanded, - floating = true, - hazeState = hazeState, - chrome = chrome, modifier = Modifier .onPointerEvent(PointerEventType.Exit) { nav.sidebarPeek = false } .testTag("sidebar_peek"), elevated = true, - ) { m, top -> listColumn(m, true, top) } + ) { m -> listColumn(m) } + } + // One toggle beside the traffic lights that stays put whether the list is shown or hidden, + // as in Finder and Notes. The rail carries its own, under the lights. + if (sidebarMode != SidebarMode.Rail) { + Box( + Modifier.padding(start = toggleStart(chrome)).height(DesktopDimens.TitleRowHeight), + contentAlignment = Alignment.Center, + ) { SidebarToggle(nav::toggleSidebar) } } } } } +private val ToggleSize = 32.dp + +/** Where the toggle starts: after the traffic lights on macOS, or the window's edge elsewhere. */ +private fun toggleStart(chrome: WindowChrome) = if (chrome.controlsWidth > 0.dp) chrome.controlsWidth else 8.dp + +/** Durations for the panes moving: long enough to follow, short enough not to wait on. */ +private object PaneMotion { + fun size() = tween(durationMillis = 250, easing = FastOutSlowInEasing) + fun offset() = tween(durationMillis = 250, easing = FastOutSlowInEasing) + fun bounds() = tween(durationMillis = 250, easing = FastOutSlowInEasing) + fun fade() = tween(durationMillis = 150) +} + +/** The context pane sliding in over the chat from the trailing edge. */ +@Composable +private fun OverlayPane(visible: Boolean, modifier: Modifier, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = slideInHorizontally(PaneMotion.offset()) { it } + fadeIn(PaneMotion.fade()), + exit = slideOutHorizontally(PaneMotion.offset()) { it } + fadeOut(PaneMotion.fade()), + ) { content() } +} + /** - * The sidebar's place at the leading edge. Floating, it's the glass panel inset from the window edges - * (or meeting the top edge, see [WindowChrome.flushSidebar]); otherwise a flat column with a divider. + * The sidebar's place at the leading edge: the window's own background with a hairline divider, as in + * node 13000:91. [elevated] is the list floating over the chat from the rail, which casts a shadow + * instead. */ @Composable private fun SidebarSlot( width: Dp, - floating: Boolean, - hazeState: HazeState, - chrome: WindowChrome, modifier: Modifier = Modifier, elevated: Boolean = false, - content: @Composable (Modifier, topInset: Dp) -> Unit, + content: @Composable (Modifier) -> Unit, ) { - if (!floating) { - Row(modifier.width(width).fillMaxHeight()) { - content(Modifier.weight(1f).fillMaxHeight().background(CodeTheme.colors.background), 0.dp) - PaneDivider() + Row( + modifier + .width(width) + .fillMaxHeight() + .then(if (elevated) Modifier.shadow(24.dp) else Modifier) + .background(CodeTheme.colors.background) + .clipToBounds(), + ) { + Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.TopStart) { + content(Modifier.fillMaxHeight()) } - return - } - val inset = DesktopDimens.SidebarGlassInset - val shape: Shape = if (chrome.flushSidebar) { - RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp, bottomStart = 16.dp, bottomEnd = 16.dp) - } else { - RoundedCornerShape(16.dp) - } - val top = if (chrome.flushSidebar) 0.dp else inset - Box(modifier.width(width + inset).fillMaxHeight()) { - content( - Modifier - .padding(start = inset, end = inset, bottom = inset, top = top) - .fillMaxSize() - .then(if (elevated) Modifier.shadow(24.dp, shape) else Modifier) - .floatingGlass(hazeState, shape), - top, - ) + PaneDivider() } } @@ -291,15 +340,13 @@ private fun SidebarToggle(onClick: () -> Unit) { /** * The bar over the main pane: the chat's title, and ⓘ for its Info. It's the window's title row, so - * it shares the traffic lights' height; at the leading edge it starts after them, with the sidebar - * toggle while the list is hidden. Back appears at compact. + * it shares the traffic lights' height; at the leading edge it starts after them and, while the list + * is hidden, after the sidebar toggle. Back appears at compact. */ @Composable private fun Toolbar( title: String, leadingInset: Dp, - showToggle: Boolean, - onToggleSidebar: () -> Unit, onBack: (() -> Unit)?, onInfo: () -> Unit, ) { @@ -312,7 +359,6 @@ private fun Toolbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (showToggle) SidebarToggle(onToggleSidebar) if (onBack != null) { Text( text = "‹ Chats", diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt index 82a9ce9206..fb4e5a17b0 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt @@ -9,15 +9,12 @@ import androidx.compose.ui.unit.dp * What the OS window frame takes from the top-leading corner of the content. On macOS the content runs * under the title bar: [titleBarHeight] is the band the traffic lights sit in and [controlsWidth] the * room they take from the leading edge. Windows and Linux draw their own frame above the content, so - * both are 0. [flushSidebar] is set when the runtime can't make the title bar as tall as the sidebar's - * title row: the lights then sit near the top edge, so the glass panel meets that edge squarely - * instead of putting its rounded corner under them. + * both are 0. */ @Immutable data class WindowChrome( val titleBarHeight: Dp = 0.dp, val controlsWidth: Dp = 0.dp, - val flushSidebar: Boolean = false, ) val LocalWindowChrome = staticCompositionLocalOf { WindowChrome() } From be014b24090c4140d7e72355b59d72cbde667751 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 12:23:49 -0400 Subject: [PATCH 14/18] fix(desktop): keep the sidebar toggle on the floating list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resting on the rail floats the full list over the chat, and that list covered the rail's toggle without bringing its own, so there was no way to hide the sidebar from it. The peek frame (node 13104:3820) has the toggle beside the traffic lights. The list now carries it, and it slides in and out with the list. Leaving is judged around the list and toggle together: with the exit handler on the list alone, moving the pointer onto the toggle counted as leaving and folded the list away first. Shell tests now send key presses through desktopKeyHandler, the handler Main installs on the window: Esc folds the floating list before it touches the pane, Esc leaves a pane in its column alone, Ctrl+I opens and closes Info on Windows and Linux, and ⌘I on macOS is left to the menu bar. The tests' click() now moves and clicks a real pointer for any control inside the test window, so hover and hit testing are covered; only controls outside it still use the semantics action. The snapshot task takes -Pmotion=peek|info to write a frame every 33ms into a directory, so a transition can be stepped through without a live window. --- apps/desktop/build.gradle.kts | 3 +- .../com/flipcash/desktop/SnapshotMain.kt | 50 +++++++- .../flipcash/desktop/shell/DesktopShell.kt | 33 +++-- .../desktop/shell/DesktopShellTest.kt | 119 +++++++++++++++++- 4 files changed, 186 insertions(+), 19 deletions(-) diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 7121afe3e6..26bc165e18 100644 --- a/apps/desktop/build.gradle.kts +++ b/apps/desktop/build.gradle.kts @@ -93,7 +93,7 @@ compose.desktop { tasks.register("snapshot") { group = "application" - description = "Renders a screen off-screen to a PNG: -Pout= -Pscreen=chat|money|you|link -Pwidth= -Pheight=." + description = "Renders a screen off-screen to a PNG: -Pout= -Pscreen=chat|money|you|link -Pwidth= -Pheight=, or frames into a directory with -Pmotion=peek|info." classpath = sourceSets["main"].runtimeClasspath mainClass.set("com.flipcash.desktop.SnapshotMainKt") args( @@ -101,6 +101,7 @@ tasks.register("snapshot") { providers.gradleProperty("screen").getOrElse("chat"), providers.gradleProperty("width").getOrElse("1040"), providers.gradleProperty("height").getOrElse("760"), + providers.gradleProperty("motion").getOrElse(""), ) } diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt index 731d123f30..43689ef34f 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt @@ -1,9 +1,16 @@ package com.flipcash.desktop import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import androidx.compose.ui.use +import com.flipcash.desktop.input.DesktopCommand +import com.flipcash.desktop.input.perform import com.flipcash.desktop.shell.ContextContent +import com.flipcash.desktop.shell.DesktopNavState +import com.flipcash.desktop.shell.widthClassFor import dev.zacsweers.metro.createGraph import kotlinx.coroutines.runBlocking import java.io.File @@ -14,12 +21,17 @@ import org.jetbrains.skia.EncodedImageFormat * Renders a screen off-screen through the same graph as [main] and writes a PNG. * Usage: `./gradlew :apps:desktop:snapshot -Pout= -Pscreen=shell|shell-wallet|you|link -Pwidth= -Pheight=`. * The link screen skips sign-in. Width picks the layout exactly as resizing the window does. + * + * `-Pmotion=peek|info` writes frames instead, every 33ms, into the directory `out` names, so a + * transition can be stepped through: peek rests on the rail, moves onto the floating list's toggle, then presses Esc; info opens and + * closes the Info pane. */ fun main(args: Array) { val out = File(args.firstOrNull() ?: "snapshot.png") val screen = args.getOrNull(1) ?: "chat" val width = args.getOrNull(2)?.toIntOrNull() ?: 1040 val height = args.getOrNull(3)?.toIntOrNull() ?: 760 + val motion = args.getOrNull(4)?.takeIf { it.isNotEmpty() } val linkScreen = screen == "link" val context = when (screen) { "shell-wallet", "money", "wallet" -> ContextContent.Wallet @@ -32,8 +44,9 @@ fun main(args: Array) { if (!linkScreen) graph.restoreSession() } val scale = 2f + val nav = DesktopNavState().apply { this.context = context } ImageComposeScene(width = (width * scale).toInt(), height = (height * scale).toInt(), density = Density(scale)) { - if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopMainContent(graph, initialContext = context) + if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopMainContent(graph, nav = nav) }.use { scene -> var nanos = 0L repeat(80) { @@ -41,8 +54,39 @@ fun main(args: Array) { Thread.sleep(100) nanos += 100_000_000L } - val png = scene.render(nanos).encodeToData(EncodedImageFormat.PNG)!!.bytes - out.writeBytes(png) + if (motion == null) { + out.writeBytes(scene.render(nanos).encodeToData(EncodedImageFormat.PNG)!!.bytes) + } else { + out.mkdirs() + var frame = 0 + // Delays in the shell run on real time, so each frame waits as long as it advances. + fun frames(millis: Int) = repeat(millis / 33) { + Thread.sleep(33) + nanos += 33_000_000L + val png = scene.render(nanos).encodeToData(EncodedImageFormat.PNG)!!.bytes + File(out, "%03d.png".format(frame++)).writeBytes(png) + } + fun run(command: DesktopCommand) = nav.perform(command, nav.contextPaneModeAt(widthClassFor(width.dp))) + frames(100) + when (motion) { + "peek" -> { + scene.sendPointerEvent(PointerEventType.Enter, Offset(40 * scale, height * scale / 2)) + scene.sendPointerEvent(PointerEventType.Move, Offset(40 * scale, height * scale / 2)) + frames(700) + // Onto the list's toggle, in the title row: the list should stay out. + scene.sendPointerEvent(PointerEventType.Move, Offset(24 * scale, 26 * scale)) + frames(300) + run(DesktopCommand.Escape) + frames(500) + } + "info" -> { + run(DesktopCommand.ToggleInfo) + frames(500) + run(DesktopCommand.ToggleInfo) + frames(500) + } + } + } } println("snapshot written to ${out.absolutePath}") exitProcess(0) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index b191b5182b..8defa66432 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -239,28 +239,39 @@ fun DesktopShell( enter = slideInHorizontally(PaneMotion.offset()) { -it / 3 } + fadeIn(PaneMotion.fade()), exit = slideOutHorizontally(PaneMotion.offset()) { -it / 3 } + fadeOut(PaneMotion.fade()), ) { - SidebarSlot( - width = DesktopDimens.SidebarExpanded, - modifier = Modifier + // Leaving is judged around the list and its toggle together; the toggle sits on top + // of the list, so the list alone would see the pointer leave on the way to it. + Box( + Modifier .onPointerEvent(PointerEventType.Exit) { nav.sidebarPeek = false } .testTag("sidebar_peek"), - elevated = true, - ) { m -> listColumn(m) } + ) { + SidebarSlot( + width = DesktopDimens.SidebarExpanded, + elevated = true, + ) { m -> listColumn(m) } + // It covers the rail's toggle, so it brings the list's own, which travels with it. + TitleRowToggle(chrome, nav::toggleSidebar) + } } // One toggle beside the traffic lights that stays put whether the list is shown or hidden, // as in Finder and Notes. The rail carries its own, under the lights. - if (sidebarMode != SidebarMode.Rail) { - Box( - Modifier.padding(start = toggleStart(chrome)).height(DesktopDimens.TitleRowHeight), - contentAlignment = Alignment.Center, - ) { SidebarToggle(nav::toggleSidebar) } - } + if (sidebarMode != SidebarMode.Rail) TitleRowToggle(chrome, nav::toggleSidebar) } } } private val ToggleSize = 32.dp +/** The sidebar toggle in the title row, beside the traffic lights on macOS. */ +@Composable +private fun TitleRowToggle(chrome: WindowChrome, onClick: () -> Unit) { + Box( + Modifier.padding(start = toggleStart(chrome)).height(DesktopDimens.TitleRowHeight), + contentAlignment = Alignment.Center, + ) { SidebarToggle(onClick) } +} + /** Where the toggle starts: after the traffic lights on macOS, or the window's edge elsewhere. */ private fun toggleStart(chrome: WindowChrome) = if (chrome.controlsWidth > 0.dp) chrome.controlsWidth else 8.dp diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt index 5837276980..d6acba4570 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt @@ -1,5 +1,6 @@ package com.flipcash.desktop.shell +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.requiredWidth @@ -8,6 +9,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule @@ -15,20 +18,43 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.click as mouseClick +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.desktop.input.DesktopCommand +import com.flipcash.desktop.input.HostOs +import com.flipcash.desktop.input.desktopKeyHandler +import com.flipcash.desktop.input.perform import org.junit.Rule import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue -// The test window is 1024 x 768, so at the wide widths these tests use the trailing controls sit -// outside it; the click goes through the node's action rather than a pointer. -private fun SemanticsNodeInteraction.click() = performSemanticsAction(SemanticsActions.OnClick) +// A real pointer moves and clicks, so hover and hit testing are part of every test. The test window +// is 1024 x 768, and at the wide widths the trailing controls sit outside it; only those go through +// the node's click action instead. +private fun SemanticsNodeInteraction.click(): SemanticsNodeInteraction { + // positionInRoot, not the bounds, which are clipped to the window. + val node = fetchSemanticsNode() + val (x, y) = node.positionInRoot + val inWindow = x >= 0f && y >= 0f && x + node.size.width <= TestWindowWidth && y + node.size.height <= TestWindowHeight + return if (inWindow) performMouseInput { mouseClick(center) } else performSemanticsAction(SemanticsActions.OnClick) +} + +private const val TestWindowWidth = 1024f +private const val TestWindowHeight = 768f class DesktopShellTest { @get:Rule @@ -36,12 +62,22 @@ class DesktopShellTest { private val nav = DesktopNavState() private var width by mutableStateOf(1280.dp) + private var os = HostOs.Linux + + // The window's key handling, as Main wires it, so key presses here take the real path. + private fun run(command: DesktopCommand): Boolean = + nav.perform(command, nav.contextPaneModeAt(widthClassFor(width))) private fun shell(at: Dp, block: ComposeContentTestRule.() -> Unit) { width = at rule.setContent { FlipcashTheme { - Box(Modifier.requiredWidth(width).fillMaxHeight()) { + Box( + Modifier.requiredWidth(width).fillMaxHeight() + .onPreviewKeyEvent(desktopKeyHandler(os, ::run)) + .testTag("window") + .focusable(), + ) { DesktopShell( nav = nav, sidebar = { m -> Box(m.testTag("fake_list")) }, @@ -124,6 +160,81 @@ class DesktopShellTest { onNodeWithTag("sidebar_peek").assertDoesNotExist() } + @OptIn(ExperimentalTestApi::class) + private fun ComposeContentTestRule.press(key: Key, ctrl: Boolean = false, meta: Boolean = false) { + onNodeWithTag("window").performSemanticsAction(SemanticsActions.RequestFocus) + onNodeWithTag("window").performKeyInput { + when { + ctrl -> withKeyDown(Key.CtrlLeft) { pressKey(key) } + meta -> withKeyDown(Key.MetaLeft) { pressKey(key) } + else -> pressKey(key) + } + } + waitForIdle() + } + + @Test + fun `esc folds the floating list first, and leaves the pane in its column`() = shell(1000.dp) { + nav.openWallet() + waitForIdle() + onNodeWithTag("rail").performMouseInput { moveTo(center) } + waitUntil(2_000) { nav.sidebarPeek } + press(Key.Escape) + assertFalse(nav.sidebarPeek) + onNodeWithTag("sidebar_peek").assertDoesNotExist() + onNodeWithTag("context_pane").assertExists() + press(Key.Escape) + assertEquals(ContextContent.Wallet, nav.context) + onNodeWithTag("context_pane").assertExists() + } + + @Test + fun `esc closes the pane floating over the chat`() = shell(700.dp) { + nav.openWallet() + waitForIdle() + onNodeWithTag("context_overlay").assertExists() + press(Key.Escape) + assertNull(nav.context) + onNodeWithTag("context_overlay").assertDoesNotExist() + } + + @Test + fun `control I twice opens info and closes it again on windows and linux`() = shell(1280.dp) { + press(Key.I, ctrl = true) + assertEquals(ContextContent.Info, nav.context) + onNodeWithTag("fake_Info").assertExists() + press(Key.I, ctrl = true) + assertNull(nav.context) + onNodeWithTag("context_pane").assertDoesNotExist() + } + + @Test + fun `command I is left to the menu bar on mac`() { + os = HostOs.Mac + shell(1280.dp) { + press(Key.I, meta = true) + assertNull(nav.context) + } + } + + @Test + fun `the floating list brings the toggle, which hides the sidebar`() = shell(1000.dp) { + nav.openWallet() + waitForIdle() + onNodeWithTag("rail").performMouseInput { moveTo(center) } + waitUntil(2_000) { nav.sidebarPeek } + val toggle = onNode(hasTestTag("sidebar_toggle") and hasAnyAncestor(hasTestTag("sidebar_peek"))) + // A real pointer: moving onto the toggle must not count as leaving the list. + toggle.performMouseInput { moveTo(center) } + waitForIdle() + assertTrue(nav.sidebarPeek) + toggle.click() + waitForIdle() + assertTrue(nav.sidebarHidden) + onNodeWithTag("sidebar_peek").assertDoesNotExist() + onNodeWithTag("rail").assertDoesNotExist() + } + @Test fun `info button toggles info`() = shell(1280.dp) { onNodeWithTag("toolbar_info").click() From 57874cf2e6d708d57b883cf258ea605d1287784f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 12:30:31 -0400 Subject: [PATCH 15/18] docs: step desktop motion frames before calling a layout change done --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 74258b47c7..207f54a309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,8 @@ The feature plugin automatically includes `:libs:logging`, `:ui:core`, `:ui:comp A code change is done when the touched modules' unit tests pass and `./gradlew :apps:flipcash:app:compileDebugKotlin` succeeds. +For a layout or motion change in `apps/desktop`, also render `./gradlew :apps:desktop:snapshot -Pmotion=…` and step the frames before calling it done; stills can't show a transition. + ## Namespaces - App: `com.flipcash.app.android` (no `applicationIdSuffix` — debug and release share the ID, so installing one replaces the other) From 6dd951c8a9aad10bea60f59f5982791a9b036986 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:31:35 -0400 Subject: [PATCH 16/18] fix(ui): shrink a rolling number until its digits fit AnimatedNumberText gives every digit a slot as wide as the widest digit. When the number was wider than its box even at the AutoSize minimum, the slots kept their width anyway and the last digits were drawn on top of each other. The desktop Wallet pane showed "$190.96" with its cents stacked, and Bubble Mew's balance cut to "$32". The minimum now gives way when it would overflow: below it, the font shrinks until the slots as drawn fit. Numbers that already fit are unchanged, on Android too. AnimatedNumberTextFitTest sweeps the token card's header row from 180 to 320 dp with five amounts and checks no two characters overlap. Without this change it fails 42 of those pairs. --- .../desktop/AnimatedNumberTextFitTest.kt | 100 ++++++++++++++++++ .../ui/components/text/AnimatedNumberText.kt | 29 ++++- 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt new file mode 100644 index 0000000000..7404debd62 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt @@ -0,0 +1,100 @@ +package com.flipcash.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.text.AnimatedNumberText +import com.getcode.ui.utils.ConstraintMode +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The wallet's card amounts in the desktop Wallet pane are squeezed by the appreciation badge beside + * them. Every character must keep its own room there: a digit with no room is drawn on top of its + * neighbour. + */ +class AnimatedNumberTextFitTest { + @get:Rule val rule = createComposeRule() + + private var width by mutableStateOf(0) + private var value by mutableStateOf("") + + @Test + fun `card amounts keep every character apart at every width`() { + rule.setContent { FlipcashTheme { CardHeader(width, value) } } + val bad = mutableListOf() + for (w in 180..320 step 6) { + for (v in listOf("$190.96", "$191.20", "$32.92", "$111.19", "$1,234.56")) { + width = w + value = v + rule.mainClock.advanceTimeBy(2_000) + rule.waitForIdle() + problem(v)?.let { bad += "$w dp, $v: $it" } + } + } + assertTrue(bad.isEmpty(), bad.joinToString("\n")) + } + + /** The token card's header row: name on the left, the appreciation badge and balance on the right. */ + @Composable + private fun CardHeader(width: Int, value: String) { + Row( + Modifier.width(width.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Waylon", Modifier.padding(start = 32.dp), style = CodeTheme.typography.textSmall) + Row( + Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("-$43.91", Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = CodeTheme.typography.caption) + Box(Modifier.weight(1f, fill = false).testTag("box")) { + AnimatedNumberText( + value = value, + style = CodeTheme.typography.screenTitle.copy(fontWeight = FontWeight.Bold), + constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.textMedium), + ) + } + } + } + } + + private fun problem(shown: String): String? { + val box = rule.onNodeWithTag("box").fetchSemanticsNode().boundsInRoot + val cells = rule.onAllNodesWithText("", substring = true, useUnmergedTree = true).fetchSemanticsNodes() + .filter { it.config.getOrNull(SemanticsProperties.Text)?.joinToString("") { t -> t.text }?.length == 1 } + .filter { it.boundsInRoot.left >= box.left - 40f } + .sortedBy { it.boundsInRoot.left } + fun text(i: Int) = cells[i].config[SemanticsProperties.Text].joinToString("") { it.text } + for (i in 0 until cells.size - 1) { + val a = cells[i].boundsInRoot + val b = cells[i + 1].boundsInRoot + if (b.left < a.left + a.width * 0.6f) return "'${text(i)}' at $a overlaps '${text(i + 1)}' at $b (box $box)" + } + val right = cells.lastOrNull()?.boundsInRoot?.right ?: return "nothing drawn for $shown" + if (right > box.right + 1f) return "ends at $right past the box at ${box.right}" + return null + } +} diff --git a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt index b1f20c6b5c..a9ab53c604 100644 --- a/ui/components/src/commonMain/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt +++ b/ui/components/src/commonMain/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt @@ -37,7 +37,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.getcode.ui.utils.AutoSizeTextMeasurer import com.getcode.ui.utils.ConstraintMode import com.getcode.ui.utils.MeasureWidthFraction @@ -72,8 +74,8 @@ fun AnimatedNumberText( // Digits are drawn at the widest digit's width, so size the string as it will be drawn. val widest = remember(style) { textMeasurer.digitMetrics(style).widest } - remember(value, style.fontSize, maxWidthPx, widest) { - autosizeTextMeasurer.findFontSize( + remember(value, style, maxWidthPx, widest) { + val fitted = autosizeTextMeasurer.findFontSize( text = AnnotatedString(value.map { if (it.isDigit()) widest else it }.joinToString("")), style = style, constraints = Constraints( @@ -84,6 +86,10 @@ fun AnimatedNumberText( maxFontSize = style.fontSize, autosizeGranularity = 100 ) + // The minimum is a floor for looks, not a reason to overflow: each slot takes its full + // width whatever room is left, so a number wider than the box draws its last digits on + // top of each other. Below the minimum, shrink until the slots as drawn fit. + textMeasurer.shrinkToFit(value, style.copy(fontSize = fitted), maxWidthPx) } } else { style.fontSize @@ -444,6 +450,25 @@ private fun TextMeasurer.digitMetrics(style: TextStyle): DigitMetrics { return DigitMetrics(Digits[widest], ceil(width).toInt()) } +/** The width [value] takes as slots: every digit at the widest digit's width, other characters at their own. */ +private fun TextMeasurer.slotsWidth(value: String, style: TextStyle): Int { + val digitWidth = digitMetrics(style).widthPx + return value.sumOf { if (it.isDigit()) digitWidth else measure(it.toString(), style).size.width } +} + +/** [style]'s font size if [value]'s slots fit [maxWidthPx], else the largest smaller size that does. */ +private fun TextMeasurer.shrinkToFit(value: String, style: TextStyle, maxWidthPx: Int): TextUnit { + val size = style.fontSize + if (slotsWidth(value, style) <= maxWidthPx) return size + var low = 1f + var high = size.value + repeat(10) { + val mid = (low + high) / 2 + if (slotsWidth(value, style.copy(fontSize = mid.sp)) <= maxWidthPx) low = mid else high = mid + } + return low.sp +} + private class CharHolder(var value: Char) private class SlotTrack(var digit: Int, var symbol: Char? = null) From 8cbcc607590059ed5be42defb14aaab274058494 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:31:35 -0400 Subject: [PATCH 17/18] fix(desktop): give the floating sidebar list a visible shadow The list floating over the chat used a 24 dp elevation shadow. Black on the near-black window, it darkened the chat by two levels at most, so the list's edge blended into the chat and the chat text looked sliced off. It now uses a 20 dp drop shadow at 50% black, which measures six levels darker at the edge and fades out by 20 dp, as in node 13104:3820. --- .../com/flipcash/desktop/shell/DesktopShell.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt index 8defa66432..d6233a9929 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -47,7 +47,11 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.drawscope.Stroke @@ -294,6 +298,13 @@ private fun OverlayPane(visible: Boolean, modifier: Modifier, content: @Composab ) { content() } } +/** + * The floating list's shadow over the chat, measured off node 13104:3820: about six levels darker than + * the window at the list's edge, gone by 20 dp. An elevation shadow is black on a near-black window and + * barely shows, so this is a plain blur with a stronger alpha. + */ +private val PeekShadow = Shadow(radius = 20.dp, color = Color.Black, alpha = 0.5f) + /** * The sidebar's place at the leading edge: the window's own background with a hairline divider, as in * node 13000:91. [elevated] is the list floating over the chat from the rail, which casts a shadow @@ -310,7 +321,7 @@ private fun SidebarSlot( modifier .width(width) .fillMaxHeight() - .then(if (elevated) Modifier.shadow(24.dp) else Modifier) + .then(if (elevated) Modifier.dropShadow(RectangleShape, PeekShadow) else Modifier) .background(CodeTheme.colors.background) .clipToBounds(), ) { From e5f835f52c72e24d9427a909331985e7ee273b5e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 21:16:28 -0400 Subject: [PATCH 18/18] fix(desktop): widen the context pane so currency cards match a phone At 336 dp the Wallet pane's cards left their header row about 235 dp. Balances autosize down when the name and change badge leave too little room, so Waylon, Moony and Bubble Mew drew their balances smaller than Jeffy's, and every card came out a different size. The pane is now 400 dp, about a phone's width, which leaves the row about 299 dp. AnimatedNumberTextFitTest checks this account's six cards show their balances at full size at the pane's row width; at 336 dp it fails. --- .../flipcash/desktop/shell/DesktopLayout.kt | 6 ++- .../desktop/AnimatedNumberTextFitTest.kt | 43 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt index c7010ffb1e..7148339e4a 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -49,6 +49,10 @@ object DesktopDimens { /** The row the traffic lights sit in: the top of the sidebar and the chat's toolbar. */ val TitleRowHeight = 52.dp - val ContextPaneWidth = 336.dp + /** + * A phone's width, so the Wallet's currency cards come out as on a phone: at 336 the card's header + * row left a long name too little room, and its balance autosized smaller than the cards around it. + */ + val ContextPaneWidth = 400.dp val MessagesMaxWidth = 720.dp } diff --git a/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt index 7404debd62..595aaf30b0 100644 --- a/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashTheme import com.getcode.theme.CodeTheme import com.getcode.ui.components.text.AnimatedNumberText +import com.flipcash.desktop.shell.DesktopDimens import com.getcode.ui.utils.ConstraintMode import org.junit.Rule import kotlin.test.Test @@ -55,24 +56,60 @@ class AnimatedNumberTextFitTest { assertTrue(bad.isEmpty(), bad.joinToString("\n")) } + @Test + fun `the wallet pane's cards show every balance at full size`() { + var name by mutableStateOf("") + var badge by mutableStateOf("") + rule.setContent { FlipcashTheme { CardHeader(width, value, name, badge, tag = "amt") } } + // The header row is the pane less the wallet's side padding and the card's inset: 101 dp, + // measured off the 336 dp render (card 275 dp, row 235 dp). + val row = DesktopDimens.ContextPaneWidth.value.toInt() - 101 + val cards = listOf( + Triple("Bubble Mew", "+$0.41", "$39.22"), Triple("Waylon", "-$43.91", "$190.96"), + Triple("Moony", "-$44.90", "$111.19"), Triple("Dad Cash", "+$7.81", "$28.31"), + Triple("Jeffy", "-$61.79", "$81.97"), Triple("ALL", "+$49.43", "$357.76"), + ) + fun height(): Float { + rule.mainClock.advanceTimeBy(2_000) + rule.waitForIdle() + return rule.onNodeWithTag("amt").fetchSemanticsNode().size.height.toFloat() + } + width = 600 + name = "Jeffy"; badge = "+$1.00"; value = "$1.00" + val full = height() + width = row + val small = cards.mapNotNull { (n, b, v) -> + name = n; badge = b; value = v + height().takeIf { it < full }?.let { "$n $v at $it, full is $full" } + } + assertTrue(small.isEmpty(), "at a $row dp row: " + small.joinToString()) + } + /** The token card's header row: name on the left, the appreciation badge and balance on the right. */ @Composable - private fun CardHeader(width: Int, value: String) { + private fun CardHeader( + width: Int, + value: String, + name: String = "Waylon", + badge: String = "-$43.91", + tag: String = "amount", + ) { Row( Modifier.width(width.dp), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.CenterVertically, ) { - Text("Waylon", Modifier.padding(start = 32.dp), style = CodeTheme.typography.textSmall) + Text(name, Modifier.padding(start = 32.dp), style = CodeTheme.typography.textSmall) Row( Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { - Text("-$43.91", Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = CodeTheme.typography.caption) + Text(badge, Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = CodeTheme.typography.caption) Box(Modifier.weight(1f, fill = false).testTag("box")) { AnimatedNumberText( value = value, + modifier = Modifier.testTag(tag), style = CodeTheme.typography.screenTitle.copy(fontWeight = FontWeight.Bold), constraintMode = ConstraintMode.AutoSize(minimum = CodeTheme.typography.textMedium), )