diff --git a/CLAUDE.md b/CLAUDE.md index bc1b1db07a..ec8cbfb76b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,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. + Tests for gestures and pointer input replay the sequence a finger makes on the device, such as a partial reveal followed by a full swipe. A semantics action or a single fling can pass while the real gesture fails: the leading swipe's reset and the desktop peek toggle both did. ## Namespaces diff --git a/apps/desktop/build.gradle.kts b/apps/desktop/build.gradle.kts index 3de4ba15df..5c51f1b605 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 { @@ -26,9 +27,15 @@ metro { 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")) + 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")) @@ -50,6 +57,7 @@ dependencies { implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:features:chat-list")) + implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":libs:emojis")) implementation(libs.kotlinx.coroutines.swing) implementation(libs.dagger) @@ -57,10 +65,13 @@ 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) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(compose.desktop.uiTestJUnit4) + testImplementation(compose.desktop.currentOs) } tasks.withType().configureEach { @@ -76,6 +87,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" @@ -86,12 +102,15 @@ 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=, or frames into a directory with -Pmotion=peek|info." 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"), + providers.gradleProperty("motion").getOrElse(""), ) } 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..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 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) { +fun DesktopApp(graph: DesktopGraph, signedIn: Boolean, nav: DesktopNavState = remember { DesktopNavState() }) { var linked by remember { mutableStateOf(signedIn) } if (linked) { - DesktopWalletContent(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 new file mode 100644 index 0000000000..3fd65d3777 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopMainContent.kt @@ -0,0 +1,113 @@ +package com.flipcash.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.core.tokens.TokenPurpose +import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.desktop.shell.ConversationPane +import com.flipcash.desktop.shell.ConversationToolbarTitle +import com.flipcash.desktop.shell.DesktopSidebar +import com.flipcash.desktop.shell.ContextContent +import com.flipcash.desktop.shell.ContextPane +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 +import com.getcode.opencode.compose.LocalExchange +import com.getcode.theme.CodeTheme +import com.getcode.util.resources.LocalResources +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, + initialContext: ContextContent? = null, + onSettings: () -> Unit = {}, + initialChatIndex: Int? = null, + nav: DesktopNavState = remember { DesktopNavState().apply { context = initialContext } }, +) { + val walletViewModel = remember { graph.walletViewModel } + val tokenViewModel = remember { graph.selectTokenViewModel } + val chatsViewModel = remember { graph.chatsViewModel } + LaunchedEffect(tokenViewModel) { + tokenViewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Balance)) + } + val chatsState by chatsViewModel.stateFlow.collectAsState() + // Snapshots only: opens the chat at this index once the list loads, so a render shows a selection. + if (initialChatIndex != null) { + LaunchedEffect(chatsState.chats) { + chatsState.visibleChats.getOrNull(initialChatIndex)?.let { if (nav.openChat == null) nav.openChat(it) } + } + } + 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, + LocalResources provides graph.resources, + LocalNetworkObserver provides graph.networkObserver, + LocalAnalytics provides graph.analytics, + ) { + Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { + DesktopShell( + nav = nav, + sidebar = { m -> DesktopSidebar(chatsState, nav, chatsViewModel::selectFilter, m) }, + main = { m -> ConversationPane(nav.openChat, m) }, + toolbar = { ConversationToolbarTitle(nav.openChat) }, + context = { content, m -> + ContextPane( + content = content, + nav = nav, + wallet = { wm -> WalletPane(nav, walletViewModel, tokenViewModel, wm) }, + 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, + 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/DesktopWalletContent.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopWalletContent.kt deleted file mode 100644 index b344cab896..0000000000 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopWalletContent.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.desktop - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.remember -import androidx.compose.ui.Modifier -import com.flipcash.app.analytics.LocalAnalytics -import com.flipcash.app.balance.internal.WalletScreen -import com.flipcash.app.core.tokens.TokenPurpose -import com.flipcash.app.theme.FlipcashTheme -import com.flipcash.app.tokens.ui.SelectTokenViewModel -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 - * (`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) { - val walletViewModel = remember { graph.walletViewModel } - val tokenViewModel = remember { graph.selectTokenViewModel } - LaunchedEffect(tokenViewModel) { - tokenViewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Balance)) - } - FlipcashTheme { - CompositionLocalProvider( - LocalExchange provides graph.exchange, - LocalResources provides graph.resources, - LocalNetworkObserver provides graph.networkObserver, - LocalAnalytics provides graph.analytics, - ) { - Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) { - WalletScreen(walletViewModel, 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 8bcda37187..0afcac3461 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt @@ -1,27 +1,146 @@ 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 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.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.widthClassFor +import com.flipcash.desktop.shell.DesktopDimens +import com.flipcash.desktop.shell.DesktopNavState +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 +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 +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") + +// 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) = + WindowChrome(titleBarHeight = DesktopDimens.TitleRowHeight, controlsWidth = bar.leftInset.dp) + +private val MinWindow = Dimension(DesktopDimens.MinWindowWidth.value.toInt(), DesktopDimens.MinWindowHeight.value.toInt()) + +@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() 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 + 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. + val lastFloating = remember { + java.util.concurrent.atomic.AtomicReference(saved?.let { Rectangle(it.x, it.y, it.width, it.height) }) + } + fun current(): SavedWindow? { + // 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.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, + ) + } + var titleBar by remember { mutableStateOf(null) } + fun run(command: DesktopCommand): Boolean = + nav.perform(command, nav.contextPaneModeAt(widthClassFor(windowState.size.width))) Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + // Debounced saves can miss a move made just before quitting. + try { + current()?.let(windowStore::save) + } finally { + exitApplication() + } + }, title = "Flipcash", - state = rememberWindowState(width = 420.dp, height = 860.dp), + state = windowState, + onPreviewKeyEvent = desktopKeyHandler(HostOs.current, ::run), ) { - DesktopApp(graph, signedIn) + if (isMac) DesktopMenuBar(windowState, nav.sidebarHidden, ::run) + 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) } + .drop(1) + .debounce(500) + .collect { current()?.let(windowStore::save) } + } + 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() - ManagementFactory.getRuntimeMXBean().startTime @@ -30,3 +149,29 @@ 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 = MinWindow + if (isMac) { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", 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/SnapshotMain.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt index 3f30a1de54..742e186f64 100644 --- a/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/SnapshotMain.kt @@ -1,8 +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 @@ -10,20 +18,37 @@ 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=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() ?: "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 motion = args.getOrNull(4)?.takeIf { it.isNotEmpty() } + val linkScreen = screen == "link" + // `chat` opens the second chat so a render shows the selected row and the conversation pane. + val selectChat = screen == "chat" + val context = when (screen) { + "shell-wallet", "money", "wallet" -> ContextContent.Wallet + "you" -> ContextContent.You + else -> null + } 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 + 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, initialChatIndex = if (selectChat) 1 else null, nav = nav) }.use { scene -> var nanos = 0L repeat(80) { @@ -31,8 +56,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/input/DesktopCommands.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt new file mode 100644 index 0000000000..d4a5b85dcc --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopCommands.kt @@ -0,0 +1,73 @@ +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, ToggleSidebar, 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, 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, + 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 + 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 + 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. New Chat arrives 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.ToggleSidebar -> { toggleSidebar(); true } + DesktopCommand.Back -> back() + DesktopCommand.Escape -> onEscape(mode) + DesktopCommand.Search -> { requestSearchFocus(); true } + 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.Search || 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 new file mode 100644 index 0000000000..fdb8d1a33d --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/input/DesktopMenuBar.kt @@ -0,0 +1,75 @@ +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 +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 +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.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 -> + if (event.type != KeyEventType.KeyDown) return@handler false + 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) +} + +/** + * 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). 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, + sidebarHidden: Boolean, + 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(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, + 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/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/main/kotlin/com/flipcash/desktop/shell/ChatSearch.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ChatSearch.kt new file mode 100644 index 0000000000..49c342502e --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ChatSearch.kt @@ -0,0 +1,14 @@ +package com.flipcash.desktop.shell + +import com.flipcash.shared.chat.ui.ConversationReference + +/** + * The rows whose [ConversationReference.name] contains [query], ignoring case. That is the group's + * title or the counterparty's name or handle, the same text the row shows. A blank query keeps every + * row. Local to desktop: the chat list's view model has no search. + */ +fun List.matching(query: String): List { + val needle = query.trim() + if (needle.isEmpty()) return this + return filter { it.name?.contains(needle, ignoreCase = true) == true } +} 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..3555c1f07f --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/ContextPane.kt @@ -0,0 +1,72 @@ +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 +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 + * 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 = LocalWindowChrome.current.titleBarHeight)) { + 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::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/shell/DesktopChatRow.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopChatRow.kt new file mode 100644 index 0000000000..b503f7451b --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopChatRow.kt @@ -0,0 +1,143 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +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.remember +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.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.shared.chat.ui.ChatRowSubtitle +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.JoinRequestSubtitle +import com.flipcash.shared.chat.ui.MutedIndicator +import com.flipcash.shared.chat.ui.UnreadBadge +import com.flipcash.shared.chat.ui.formatLastActivity +import com.flipcash.shared.chat.ui.joinRequestText +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White + +/** White at 12%: the selected row and the chips' fill (Figma `chip/fill`). No theme token sits there. */ +internal val ChipFill = White.copy(alpha = 0.12f) + +/** The preview line is 13 Regular in the design; the theme has 14 SemiBold (`textSmall`) as its nearest. */ +internal val PreviewStyle @Composable get() = CodeTheme.typography.textSmall.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + lineHeight = 16.sp, +) + +/** + * A row of the sidebar (node 13000:115): 60 tall, a filled rounded background when it is the open + * chat and a lighter one under the pointer, no chevron and no divider. The phone's `TipChatRow` draws + * both and has no selected state, so this lays out its own row from the same pieces: the avatar, the + * time formatter, the preview rules and the unread pill. + */ +@Composable +fun DesktopChatRow( + chat: ConversationReference, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + val fill = when { + selected -> ChipFill + hovered -> White05 + else -> Color.Transparent + } + val unread = chat.rowUnreadCount + Row( + modifier = modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(10.dp)) + .background(fill) + .hoverable(hover) + .semantics { this.selected = selected } + .clickable(role = Role.Tab, onClick = onClick) + .padding(10.dp) + .testTag("chat_row"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier.requiredSize(40.dp).clip(CircleShape), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = chat.name.orEmpty(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + MutedIndicator(viewerState = chat.viewerState) + } + chat.rowActivity?.let { at -> + Text( + text = formatLastActivity(at), + style = CodeTheme.typography.caption, + // The unread colour is the badge's: blue says there is something new. + color = if (unread != 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + val request = joinRequestText(chat.joinRequests) + if (request != null && !chat.isTyping) { + JoinRequestSubtitle( + request = request, + preview = chat.lastMessagePreview.takeIf { chat.unreadCount != 0 }, + style = PreviewStyle, + ) + } else { + ChatRowSubtitle( + isTyping = chat.isTyping, + preview = chat.lastMessagePreview, + hasMessages = chat.hasMessages, + awaitsInvites = chat.awaitsInvites, + style = PreviewStyle, + ) + } + } + if (unread != 0) UnreadBadge(count = unread) + } + } + } +} 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..b500b8e262 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopLayout.kt @@ -0,0 +1,57 @@ +package com.flipcash.desktop.shell + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * 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 + width < 1100.dp -> WidthClass.Expanded + 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 } + +/** 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 { + val MinWindowWidth = 400.dp + val MinWindowHeight = 640.dp + + val SidebarMedium = 280.dp + val SidebarExpanded = 280.dp + val SidebarRail = 80.dp + + /** The row the traffic lights sit in: the top of the sidebar and the chat's toolbar. */ + val TitleRowHeight = 52.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 +} 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..6ba2611f76 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopNavState.kt @@ -0,0 +1,139 @@ +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.flipcash.shared.chat.ui.ConversationReference +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) + + /** 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 + } + + /** The chat open in the main pane, or null before the user picks one. */ + var openChat: ConversationReference? by mutableStateOf(null) + private set + + /** A chat picked from the list: compact shows it, and a floating list folds back to the rail. */ + fun openChat(chat: ConversationReference? = null) { + if (chat != null) openChat = chat + compactShowsChat = true + sidebarPeek = false + } + + /** Whether [chat] is the one open in the main pane, which is what the sidebar marks selected. */ + fun isOpen(chat: ConversationReference): Boolean = openChat?.chatId == chat.chatId + + /** What the sidebar's search field holds. Here rather than in the field so the peek shares it. */ + var searchQuery: String by mutableStateOf("") + + /** Set by the Search command (⌘K); the sidebar's search field takes focus and clears it. */ + var searchFocusRequested: Boolean by mutableStateOf(false) + + /** Brings the search field up: out of hiding, out of a chat at compact, or floated over the rail. */ + fun requestSearchFocus() { + sidebarHidden = false + compactShowsChat = false + sidebarPeek = true + searchFocusRequested = true + } + + 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 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 new file mode 100644 index 0000000000..01c1e60735 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopPanes.kt @@ -0,0 +1,196 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.background +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.RowScope +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AttachMoney +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.theme.White05 +import com.getcode.theme.White50 + +/** + * 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(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), + ) +} + +/** + * The main pane's toolbar content for the open chat (node 13000:224): avatar and name. The design's + * "6 people" line has no data behind it, since [ConversationReference] carries no member count, so + * the name is centred on the avatar instead. + */ +@Composable +fun RowScope.ConversationToolbarTitle(chat: ConversationReference?) { + if (chat == null) { + Spacer(Modifier.weight(1f)) + return + } + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier.size(28.dp).clip(CircleShape), + ) + Text( + text = chat.name.orEmpty(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).testTag("toolbar_title"), + ) +} + +/** + * The conversation in the main pane. Messages need `ChatViewModel`, which is Android-only until it + * moves to commonMain, so the pane says so in one quiet line and shows the composer disabled + * (node 13000:282). With no chat open it asks for one. + */ +@Composable +fun ConversationPane(chat: ConversationReference?, modifier: Modifier = Modifier) { + Column(modifier.testTag("conversation_pane")) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = if (chat == null) "Select a conversation" else "Messages are coming to desktop soon.", + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Normal), + color = CodeTheme.colors.textSecondary, + ) + } + if (chat != null) { + Box(Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 20.dp), contentAlignment = Alignment.Center) { + ComposerShell(Modifier.widthIn(max = 640.dp)) + } + } + } +} + +/** The composer drawn but not usable: the field with its attach button, and Give beside it. */ +@Composable +private fun ComposerShell(modifier: Modifier = Modifier) { + Row( + modifier.fillMaxWidth().alpha(0.5f).testTag("composer_disabled").clearAndSetSemantics { disabled() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(White05) + .border(1.dp, White10, RoundedCornerShape(20.dp)) + .padding(start = 4.dp, end = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(32.dp).clip(CircleShape).background(ChipFill), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(18.dp)) + } + Text("Message", style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.Medium, fontSize = 15.sp), color = White50) + } + Box(Modifier.size(40.dp).clip(CircleShape).background(ChipFill), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.AttachMoney, contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(20.dp)) + } + } +} + +@Composable +fun PaneDivider(modifier: Modifier = Modifier) { + Box( + modifier + .width(1.dp) + .fillMaxHeight() + .border(0.5.dp, CodeTheme.colors.divider), + ) +} 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..6fdf7fae0a --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopShell.kt @@ -0,0 +1,427 @@ +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 +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.RowScope +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.wrapContentWidth +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.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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 +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.theme.White08 +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// 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. + * + * 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. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DesktopShell( + nav: DesktopNavState, + sidebar: @Composable (Modifier) -> Unit, + main: @Composable (Modifier) -> Unit, + context: @Composable (ContextContent, Modifier) -> Unit, + footer: @Composable (Modifier) -> Unit, + modifier: Modifier = Modifier, + rail: @Composable (Modifier) -> Unit = {}, + railFooter: @Composable (Modifier) -> Unit = {}, + title: String = "", + toolbar: (@Composable RowScope.() -> Unit)? = null, +) { + BoxWithConstraints(modifier.fillMaxSize()) { + val widthClass = widthClassFor(maxWidth) + val sidebarMode = nav.sidebarModeAt(widthClass) + val mode = nav.contextPaneModeAt(widthClass) + val chrome = LocalWindowChrome.current + val shown = nav.context + + // The floating list belongs to the rail; it can't outlive it. + LaunchedEffect(sidebarMode, nav.sidebarPeek) { if (sidebarMode != SidebarMode.Rail) nav.sidebarPeek = false } + + // The sidebar draws its own title row (new chat at the trailing end); the traffic lights and + // the toggle sit over its leading end. + val listColumn: @Composable (Modifier) -> Unit = { m -> + Column(m.testTag("sidebar")) { + sidebar(Modifier.weight(1f).fillMaxWidth()) + footer(Modifier.fillMaxWidth()) + } + } + 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, Dp, (() -> Unit)?) -> Unit = { m, leadingInset, onBack -> + Column(m.testTag("main")) { + Toolbar( + title = title, + content = toolbar, + leadingInset = leadingInset, + 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) { + when { + shown != null -> contextPane(shown, Modifier.fillMaxSize().testTag("context_overlay")) + 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()) { + 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() }, + ) { 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)) + } + } + } + } + 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"), + ) + } + } + } + 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"), + ) + } + } + } + } + // 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()), + ) { + // 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"), + ) { + 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) 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 + +/** 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 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 + * instead. + */ +@Composable +private fun SidebarSlot( + width: Dp, + modifier: Modifier = Modifier, + elevated: Boolean = false, + content: @Composable (Modifier) -> Unit, +) { + Row( + modifier + .width(width) + .fillMaxHeight() + .then(if (elevated) Modifier.dropShadow(RectangleShape, PeekShadow) else Modifier) + .background(CodeTheme.colors.background) + .clipToBounds(), + ) { + Box(Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.TopStart) { + content(Modifier.fillMaxHeight()) + } + PaneDivider() + } +} + +/** 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 and, while the list + * is hidden, after the sidebar toggle. Back appears at compact. + */ +@Composable +private fun Toolbar( + title: String, + content: (@Composable RowScope.() -> Unit)?, + leadingInset: Dp, + onBack: (() -> Unit)?, + onInfo: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(DesktopDimens.TitleRowHeight) + .padding(start = leadingInset) + .padding(start = 20.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.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"), + ) + } + if (content != null) { + content() + } else { + Text( + text = title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + modifier = Modifier.weight(1f), + ) + } + // A glass capsule (node 13011:253): the only glass on the window, as the sidebar is flat. + Row( + modifier = Modifier.clip(CircleShape).background(White08).padding(horizontal = 4.dp, vertical = 2.dp), + ) { + 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/DesktopSidebar.kt b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopSidebar.kt new file mode 100644 index 0000000000..9eb938288e --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/DesktopSidebar.kt @@ -0,0 +1,233 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.tipping.internal.ChatsViewModel +import com.flipcash.core.resources.Res +import com.flipcash.core.resources.title_filterAll +import com.flipcash.core.resources.title_filterGroups +import com.flipcash.core.resources.title_filterUnread +import com.flipcash.core.resources.title_noChatsYet +import com.flipcash.core.resources.title_noGroupChats +import com.flipcash.core.resources.title_noUnreadChats +import com.flipcash.desktop.input.HostOs +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.theme.White50 +import org.jetbrains.compose.resources.stringResource + +/** + * The chat list down the window's leading side (Sidebar node 13000:92): the title row with new chat, + * search, the All / Unread / Groups chips, the rows, and the footer the shell appends. It reads the + * shared [ChatsViewModel.State] directly rather than the phone's screen, which brings a centred + * title, a hidden chip row and swipe actions. The window's sidebar toggle is drawn by the shell over + * the title row's leading end; this row keeps that space free. + */ +@Composable +fun DesktopSidebar( + state: ChatsViewModel.State, + nav: DesktopNavState, + onSelectFilter: (ChatListFilter) -> Unit, + modifier: Modifier = Modifier, +) { + val search = remember { FocusRequester() } + // Consumed here so a list that mounts after the request (the peek) doesn't take focus later. + LaunchedEffect(nav.searchFocusRequested) { + if (!nav.searchFocusRequested) return@LaunchedEffect + withFrameNanos { } + runCatching { search.requestFocus() } + nav.searchFocusRequested = false + } + val rows = state.visibleChats.matching(nav.searchQuery) + Column(modifier) { + TitleRow() + SearchField( + query = nav.searchQuery, + onQueryChange = { nav.searchQuery = it }, + modifier = Modifier.padding(horizontal = 12.dp).padding(bottom = 10.dp).focusRequester(search), + ) + FilterRow(state.filter, onSelectFilter, Modifier.padding(horizontal = 12.dp).padding(bottom = 8.dp)) + ChatRows(state, rows, nav, Modifier.weight(1f).fillMaxWidth()) + } +} + +/** New chat sits at the trailing end. There is no desktop new-chat screen yet, so it is drawn disabled. */ +@Composable +private fun TitleRow() { + Row( + Modifier.fillMaxWidth().height(DesktopDimens.TitleRowHeight).padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + Box( + Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).alpha(0.4f).testTag("new_chat"), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Edit, contentDescription = "New chat", tint = CodeTheme.colors.textSecondary, modifier = Modifier.size(20.dp)) + } + } +} + +@Composable +private fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) { + val keycap = if (HostOs.current == HostOs.Mac) "⌘K" else "Ctrl K" + Row( + modifier + .fillMaxWidth() + .height(32.dp) + .clip(RoundedCornerShape(9.dp)) + .background(White05) + .padding(start = 10.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Search, contentDescription = null, tint = White50, modifier = Modifier.size(16.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium, color = CodeTheme.colors.textMain) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = style, + cursorBrush = SolidColor(CodeTheme.colors.textMain), + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { e -> + // Esc clears what was typed; with nothing typed it falls through to the window. + if (e.type == KeyEventType.KeyDown && e.key == Key.Escape && query.isNotEmpty()) { + onQueryChange("") + true + } else false + } + .testTag("sidebar_search"), + ) + if (query.isEmpty()) { + Text("Search", style = style.copy(color = White50)) + } + } + if (query.isEmpty()) { + Text( + text = keycap, + style = CodeTheme.typography.caption, + color = White50, + modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(ChipFill).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +@Composable +private fun FilterRow(selected: ChatListFilter, onSelect: (ChatListFilter) -> Unit, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterPill(stringResource(Res.string.title_filterAll), selected == ChatListFilter.All) { onSelect(ChatListFilter.All) } + FilterPill(stringResource(Res.string.title_filterUnread), selected == ChatListFilter.Unread) { onSelect(ChatListFilter.Unread) } + FilterPill(stringResource(Res.string.title_filterGroups), selected == ChatListFilter.Groups) { onSelect(ChatListFilter.Groups) } + } +} + +/** + * A chip 26 tall (node 13007:255). The shared `FilterChip` is 7dp-padded to iOS's 32, and the design + * shows no counts, so this is its own. + */ +@Composable +private fun FilterPill(label: String, selected: Boolean, onClick: () -> Unit) { + Text( + text = label, + style = CodeTheme.typography.textSmall.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium, lineHeight = 16.sp), + color = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary, + modifier = Modifier + .height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(if (selected) ChipFill else androidx.compose.ui.graphics.Color.Transparent) + .border(1.dp, White10, RoundedCornerShape(13.dp)) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) +} + +@Composable +private fun ChatRows( + state: ChatsViewModel.State, + rows: List, + nav: DesktopNavState, + modifier: Modifier = Modifier, +) { + when { + // Nothing to say until the feed emits, so the empty state doesn't flash. + !state.chats.isLoaded() -> Box(modifier) + state.hasNoChatsAtAll -> Empty(stringResource(Res.string.title_noChatsYet), modifier) + rows.isEmpty() -> Empty( + when { + nav.searchQuery.isNotBlank() -> "No chats match “${nav.searchQuery.trim()}”" + state.filter == ChatListFilter.Unread -> stringResource(Res.string.title_noUnreadChats) + else -> stringResource(Res.string.title_noGroupChats) + }, + modifier, + ) + else -> LazyColumn( + modifier.testTag("chat_list"), + contentPadding = PaddingValues(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(rows, key = { it.chatId }) { chat -> + DesktopChatRow(chat = chat, selected = nav.isOpen(chat), onClick = { nav.openChat(chat) }) + } + } + } +} + +@Composable +private fun Empty(message: String, modifier: Modifier) { + Box(modifier.padding(horizontal = 24.dp), contentAlignment = Alignment.Center) { + Text(message, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center) + } +} 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..adb996351c --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/MoneyPane.kt @@ -0,0 +1,56 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +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.tokens.ui.SelectTokenViewModel +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull + +/** + * 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 WalletPane( + nav: DesktopNavState, + walletViewModel: WalletViewModel, + tokenViewModel: SelectTokenViewModel, + modifier: Modifier = Modifier, +) { + // The wallet opens a currency through the card-expand overlay, which the Android app root hosts. + // 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 } + .filterNotNull() + .collect { key -> + (key as? Mint)?.let { nav.openCurrency(it) } + cardExpansion.clear() + } + } + LaunchedEffect(walletViewModel) { + walletViewModel.eventFlow + .filterIsInstance() + .collect { event -> nav.openRoute(event.screen) } + } + LaunchedEffect(walletViewModel) { + walletViewModel.eventFlow + .filterIsInstance() + .collect { nav.context = ContextContent.Unavailable("Region selection") } + } + + 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..30ea63acf9 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/SidebarFooter.kt @@ -0,0 +1,166 @@ +package com.flipcash.desktop.shell + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.widthIn +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.AccountBalanceWallet +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.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.White + +/** + * 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.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClick = onYou) + .testTag("footer_you"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + avatar(Modifier.size(28.dp)) + Text( + text = displayName, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // The balance pill (node 13000:230): the wallet's total, which opens Wallet. + Row( + modifier = Modifier + .height(30.dp) + .widthIn(min = 120.dp) + .clip(CircleShape) + .background(White.copy(alpha = 0.12f)) + .border(1.dp, White.copy(alpha = 0.35f), CircleShape) + .clickable(role = Role.Button, onClick = onWallet) + .padding(start = 10.dp, end = 12.dp) + .testTag("footer_balance"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Outlined.AccountBalanceWallet, contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(16.dp)) + Text( + text = balance, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + } + Box( + modifier = Modifier + .size(26.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(18.dp)) + } + } +} + +/** + * 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..fb4e5a17b0 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/shell/WindowChrome.kt @@ -0,0 +1,20 @@ +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. + */ +@Immutable +data class WindowChrome( + val titleBarHeight: Dp = 0.dp, + val controlsWidth: Dp = 0.dp, +) + +val LocalWindowChrome = staticCompositionLocalOf { WindowChrome() } 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 new file mode 100644 index 0000000000..6a121355d0 --- /dev/null +++ b/apps/desktop/src/main/kotlin/com/flipcash/desktop/window/WindowStateStore.kt @@ -0,0 +1,81 @@ +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.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 +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, + /** 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. */ +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() + + /** + * 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) { + 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") } + } +} + +// 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 = 52 + +/** + * 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/AnimatedNumberTextFitTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt new file mode 100644 index 0000000000..595aaf30b0 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/AnimatedNumberTextFitTest.kt @@ -0,0 +1,137 @@ +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.flipcash.desktop.shell.DesktopDimens +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")) + } + + @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, + 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(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(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), + ) + } + } + } + } + + 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/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..e5cdd44af1 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/input/DesktopCommandsTest.kt @@ -0,0 +1,113 @@ +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 `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() + 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.NewChat, DesktopCommand.OpenInNewWindow, DesktopCommand.Settings)) { + assertFalse(nav.perform(c, ContextPaneMode.Column)) + } + } + + @Test + fun `search is consumed and asks the sidebar to focus its field`() { + val nav = DesktopNavState().apply { sidebarHidden = true } + assertTrue(nav.perform(DesktopCommand.Search, ContextPaneMode.Column)) + assertTrue(nav.searchFocusRequested) + assertFalse(nav.sidebarHidden) + } + + @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/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()) + } +} 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/shell/DesktopLayoutTest.kt b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt new file mode 100644 index 0000000000..3d872b00a8 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopLayoutTest.kt @@ -0,0 +1,46 @@ +package com.flipcash.desktop.shell + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopLayoutTest { + @Test + 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 `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 new file mode 100644 index 0000000000..265a6a721e --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopNavStateTest.kt @@ -0,0 +1,166 @@ +package com.flipcash.desktop.shell + +import com.flipcash.app.core.AppRoute +import com.getcode.solana.keys.Mint +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +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()) + } + + @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) + } + + private fun chat(id: Int, name: String?, title: String? = null) = + ConversationReference(chatId = ChatId(byteArrayOf(id.toByte())), displayName = name, title = title, isGroup = title != null) + + @Test + fun `only the open chat is selected, matched by id`() { + val s = DesktopNavState() + val a = chat(1, "Ann") + val b = chat(2, "Bob") + assertFalse(s.isOpen(a)) + s.openChat(a) + assertTrue(s.isOpen(a)) + assertFalse(s.isOpen(b)) + assertTrue(s.isOpen(a.copy(lastMessagePreview = "newer"))) + } + + @Test + fun `search matches names case-insensitively and finds groups by title`() { + val rows = listOf(chat(1, "Ann Lee"), chat(2, "Bob"), chat(3, null, title = "Ballers")) + assertEquals(rows, rows.matching(" ")) + assertEquals(listOf(rows[1], rows[2]), rows.matching("B")) + assertEquals(listOf(rows[0]), rows.matching("lee")) + assertEquals(emptyList(), rows.matching("zzz")) + } +} 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..d6acba4570 --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/shell/DesktopShellTest.kt @@ -0,0 +1,279 @@ +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 +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.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 +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 + +// 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 + val rule = createComposeRule() + + 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() + .onPreviewKeyEvent(desktopKeyHandler(os, ::run)) + .testTag("window") + .focusable(), + ) { + 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 takes a column beside a rail at 1000 and the list returns when it closes`() = shell(1280.dp) { + onNodeWithTag("footer_balance").click() + width = 1000.dp + waitForIdle() + onNodeWithTag("context_pane").assertExists() + onNodeWithTag("rail").assertExists() + onNodeWithTag("sidebar").assertDoesNotExist() + onNodeWithTag("fake_Wallet").assertExists() + nav.closeContext() + waitForIdle() + onNodeWithTag("sidebar").assertExists() + onNodeWithTag("rail").assertDoesNotExist() + } + + @Test + fun `wallet floats over the chat below 840 without dimming it, and esc closes it`() = shell(700.dp) { + onNodeWithTag("footer_balance").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() + } + + @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() + 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 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() + 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() + } +} 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..03b5d78b3a --- /dev/null +++ b/apps/desktop/src/test/kotlin/com/flipcash/desktop/window/WindowStateStoreTest.kt @@ -0,0 +1,83 @@ +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 `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()) + } + + @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") + 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)) + } +} diff --git a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 123b0ff80d..74ea0070e8 100644 --- a/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/chat-list/src/commonMain/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,9 +1,5 @@ package com.flipcash.app.tipping.internal.components -import com.flipcash.core.resources.Res -import com.flipcash.core.resources.subtitle_peopleWantToJoin -import com.flipcash.core.resources.subtitle_wantsToJoin -import com.flipcash.core.resources.title_unnamedUser import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,14 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import org.jetbrains.compose.resources.pluralStringResource -import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing @@ -29,8 +22,8 @@ import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.JoinRequestSubtitle import com.flipcash.shared.chat.ui.MutedIndicator import com.flipcash.shared.chat.ui.SubtitleText +import com.flipcash.shared.chat.ui.joinRequestText import com.flipcash.shared.common.ui.ContactAvatar -import com.flipcash.shared.common.ui.profile.chatterName import com.getcode.theme.CodeTheme import kotlin.time.Clock @@ -113,27 +106,6 @@ internal fun TipChatRow( ) } -/** - * "Jasper wants to join" for one requester, "3 people want to join" for several, or null with - * nobody waiting. Named the way the in-chat Join Requests anchor names them. - */ -@Composable -private fun joinRequestText(waiting: List): String? { - val only = waiting.singleOrNull() - return when { - waiting.isEmpty() -> null - only != null -> stringResource( - Res.string.subtitle_wantsToJoin, - chatterName( - only.userProfile.displayName, - only.userProfile.username, - stringResource(Res.string.title_unnamedUser), - ), - ) - else -> pluralStringResource(Res.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) - } -} - @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index 02a32441a6..9a0f882e20 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -1,6 +1,13 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.resources.Res +import com.flipcash.core.resources.subtitle_peopleWantToJoin +import com.flipcash.core.resources.subtitle_wantsToJoin +import com.flipcash.core.resources.title_unnamedUser +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.common.ui.profile.chatterName +import org.jetbrains.compose.resources.pluralStringResource +import androidx.compose.ui.text.TextStyle import com.getcode.util.resources.ic_chevron_right import com.flipcash.core.resources.label_chat_preview_inviteToStart import com.flipcash.core.resources.label_chat_preview_noMessages @@ -84,11 +91,12 @@ fun ChatRowSubtitle( preview: String?, hasMessages: Boolean = true, awaitsInvites: Boolean = false, + style: TextStyle = CodeTheme.typography.textSmall, fallback: @Composable () -> Unit = {}, ) { when { - isTyping -> SubtitleText(stringResource(Res.string.label_isTyping)) - !preview.isNullOrEmpty() -> SubtitleText(preview) + isTyping -> SubtitleText(stringResource(Res.string.label_isTyping), style = style) + !preview.isNullOrEmpty() -> SubtitleText(preview, style = style) !hasMessages -> SubtitleText( text = stringResource( if (awaitsInvites) Res.string.label_chat_preview_inviteToStart @@ -97,6 +105,7 @@ fun ChatRowSubtitle( // Italic marks it as the row talking about itself rather than quoting a message — // the same distinction the tombstone bubble draws. fontStyle = FontStyle.Italic, + style = style, ) else -> fallback() } @@ -104,10 +113,14 @@ fun ChatRowSubtitle( /** Standard single-line secondary text used for chat/contact row subtitles. */ @Composable -fun SubtitleText(text: String, fontStyle: FontStyle = FontStyle.Normal) { +fun SubtitleText( + text: String, + fontStyle: FontStyle = FontStyle.Normal, + style: TextStyle = CodeTheme.typography.textSmall, +) { Text( text = text, - style = CodeTheme.typography.textSmall, + style = style, color = CodeTheme.colors.textSecondary, fontStyle = fontStyle, maxLines = 1, @@ -121,7 +134,11 @@ fun SubtitleText(text: String, fontStyle: FontStyle = FontStyle.Normal) { * secondary colour. One line, cut off at the end, so the request is what stays readable. */ @Composable -fun JoinRequestSubtitle(request: String, preview: String?) { +fun JoinRequestSubtitle( + request: String, + preview: String?, + style: TextStyle = CodeTheme.typography.textSmall, +) { val requestColor = CodeTheme.colors.indicator val previewColor = CodeTheme.colors.textSecondary Text( @@ -134,8 +151,29 @@ fun JoinRequestSubtitle(request: String, preview: String?) { } } }, - style = CodeTheme.typography.textSmall, + style = style, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + +/** + * "Jasper wants to join" for one requester, "3 people want to join" for several, or null with + * nobody waiting. Named the way the in-chat Join Requests anchor names them. + */ +@Composable +fun joinRequestText(waiting: List): String? { + val only = waiting.singleOrNull() + return when { + waiting.isEmpty() -> null + only != null -> stringResource( + Res.string.subtitle_wantsToJoin, + chatterName( + only.userProfile.displayName, + only.userProfile.username, + stringResource(Res.string.title_unnamedUser), + ), + ) + else -> pluralStringResource(Res.plurals.subtitle_peopleWantToJoin, waiting.size, waiting.size) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 168f76fcdc..81d30a8791 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" } 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) diff --git a/ui/resources/src/jvmMain/kotlin/com/getcode/util/resources/ComposeResourceHelper.kt b/ui/resources/src/jvmMain/kotlin/com/getcode/util/resources/ComposeResourceHelper.kt index 17df59207e..5e00a4a664 100644 --- a/ui/resources/src/jvmMain/kotlin/com/getcode/util/resources/ComposeResourceHelper.kt +++ b/ui/resources/src/jvmMain/kotlin/com/getcode/util/resources/ComposeResourceHelper.kt @@ -3,7 +3,8 @@ package com.getcode.util.resources import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.getPluralString import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.getString +// Aliased: inside this class a bare getString(...) resolves to the member overloads and recurses. +import org.jetbrains.compose.resources.getString as composeString /** * Desktop [ResourceHelper] over the Compose resources generated from `ui:resources`' Android @@ -11,13 +12,13 @@ import org.jetbrains.compose.resources.getString */ class ComposeResourceHelper : ResourceHelper { override fun getString(resourceId: Int): String = - runBlocking { getString(ComposeResourceIds.string(resourceId)) } + runBlocking { composeString(ComposeResourceIds.string(resourceId)) } override fun getString(resourceId: Int, vararg formatArgs: Any): String = - runBlocking { getString(ComposeResourceIds.string(resourceId), *formatArgs) } + runBlocking { composeString(ComposeResourceIds.string(resourceId), *formatArgs) } override fun getString(resource: StringResource, vararg formatArgs: Any): String = - runBlocking { getString(resource, *formatArgs) } + runBlocking { composeString(resource, *formatArgs) } override fun getRawResource(resourceId: Int): String = ""