From 7d74036775a3a2ff1ad2a420192022879d3057af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 09:47:18 -0400 Subject: [PATCH] feat(chat): move Send Cash to the composer's right The round `$` now stands to the right of the message field instead of the left. When it shows is unchanged: Send Cash offered, not editing, draft empty, and it still springs out as the draft fills. Cancel-edit stays on the left. With nothing left of the field, the attach menu's leading reach is 0, and the placement test now expects the menu to start at the compact inset. --- .../Conversation/ConversationBottomBar.swift | 42 ++++++++----------- .../Chat/AttachPanelPlacementTests.swift | 4 +- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 2799f600b..00ee6607f 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -181,7 +181,7 @@ enum BarMetrics { enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit - /// The round `$` beside the field, shown while the draft is empty. + /// The round `$` on the field's right, shown while the draft is empty. case cash /// Nothing beside the field. case none @@ -264,15 +264,9 @@ struct ConversationBottomBar: View { /// update, so the split animates without carrying the field's text change with it. @State private var cashIsShown = true - /// How far left of the field the row starts, for the attach menu to open out to. - private var menuLeadingReach: CGFloat { - switch leadingControl { - case .cash: - cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 - case .cancelEdit, .none: - 0 - } - } + /// How far left of the field the row starts, for the attach menu to open out to. `$` stands to + /// the field's right, so nothing does. + private var menuLeadingReach: CGFloat { 0 } @Namespace private var composerGlassNamespace /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. var acceptsMedia: Bool = false @@ -299,7 +293,7 @@ struct ConversationBottomBar: View { private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) /// `$` splitting from the field's glass and joining back into it. private static let cashSpring = Animation.spring(duration: 0.4, bounce: 0.3) - /// The gap between the leading control and the field. The composer's glass joins across no + /// The gap between the field and the control beside it: cancel-edit on its left, `$` on its right. The composer's glass joins across no /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. static let leadingSpacing: CGFloat = 10 @@ -336,7 +330,7 @@ struct ConversationBottomBar: View { } } - /// The glass behind the leading control and the field, drawn as one layer apart from them so + /// The glass behind the field and the controls beside it, drawn as one layer apart from them so /// `$` splits from the field and joins back into it. The real controls sit above it, outside the /// container: in one, the glass composites above sibling content and covers the typed text. @ViewBuilder @@ -347,18 +341,17 @@ struct ConversationBottomBar: View { case .cancelEdit: // The button's size, so the field's glass starts where the field does. Color.clear.frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) - case .cash: - if cashIsShown { - Color.clear - .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) - .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) - } - case .none: + case .cash, .none: EmptyView() } Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .composerGlass(in: field, id: "field", namespace: composerGlassNamespace) + if leadingControl == .cash, cashIsShown { + Color.clear + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) + } } if #available(iOS 26, *) { GlassEffectContainer(spacing: Self.leadingSpacing) { layout } @@ -381,12 +374,7 @@ struct ConversationBottomBar: View { switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - case .cash: - if cashIsShown { - ComposerCashButton(symbol: symbol, action: onSendCash) - .transition(.scale(scale: 0.4).combined(with: .opacity)) - } - case .none: + case .cash, .none: EmptyView() } ConversationComposer( @@ -401,6 +389,10 @@ struct ConversationBottomBar: View { ) // Over the row's other controls, which the attach panel floats across. .zIndex(1) + if leadingControl == .cash, cashIsShown { + ComposerCashButton(symbol: symbol, action: onSendCash) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } } .background { composerGlass diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index 7498e77c4..c88d4aeb4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -177,7 +177,7 @@ struct AttachPanelPlacementTests { #expect(abs(host.barHeight - expected) < 1, "Bar is \(host.barHeight) tall, expected \(expected)") } - @Test("The menu's leading edge is the composer field's, past the compact margin and the `$` beside it") + @Test("The menu's leading edge is the composer field's, past the compact margin") func menu_linesUpWithField() async throws { let host = try AttachPanelHost() defer { host.tearDown() } @@ -185,7 +185,7 @@ struct AttachPanelPlacementTests { let plus = host.model.overKeyboard.plusFrame let menu = AttachSurfaceLayout.menuRect(plus: plus, size: CGSize(width: 280, height: 200), placement: .standsOnPlus) - let field = BarMetrics.compactInset + BarMetrics.contentHeight + ConversationBottomBar.leadingSpacing + let field = BarMetrics.compactInset #expect(abs(menu.minX - field) < 1, "Menu starts at \(menu.minX), the field at \(field)") }