From fc78668754ca141df44b2ee5932d576a921c5310 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 00:17:43 -0400 Subject: [PATCH 1/4] feat(chat): move the reply's elevation off the bar and onto the quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replying lifted the whole bar slab to backgroundSecondary (37,37,38). The slab already bleeds past the safe area and runs behind the keyboard, so the hard line across the screen was never a gap — it was a colour step against the keyboard's own container, which paints within a level of `background` (25,25,26) and which we cannot repaint. At rest the two matched and there was no line; the reply fill was the line. So the slab keeps one paint in every state, and the ground that sets a reply apart is drawn on the quote instead, inset from the bar's edges. Two styles, because it isn't obvious which reads better against a keyboard: an opaque panel on the bar's surface, and a Liquid Glass capsule floating clear of it. The `glassReplyQuote` developer flag picks between them; panel is the default. The strip's edge-to-edge rule goes with the flush layout it belonged to. The panel clips its rule to the corner radius; the capsule insets it, since a clip would shave the specular edge off the glass. --- Flipcash/Core/Controllers/BetaFlags.swift | 7 ++ .../Conversation/ComposerReplyStrip.swift | 98 ++++++++++++++++--- .../Conversation/ConversationBottomBar.swift | 62 +++++------- .../Conversation/ConversationScreen.swift | 2 +- .../Views/Containers/GlassBackground.swift | 15 +++ 5 files changed, 128 insertions(+), 56 deletions(-) diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 3fe9d56e8..d2973e127 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -153,6 +153,7 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase case walletDepositArrival + case glassReplyQuote var id: String { localizedTitle @@ -166,6 +167,8 @@ extension BetaFlags { return "Enable Coinbase" case .walletDepositArrival: return "Show deposits arriving in the wallet" + case .glassReplyQuote: + return "Glass reply quote" } } @@ -177,6 +180,8 @@ extension BetaFlags { return "If enabled, Coinbase onramp will be available regardless of region" case .walletDepositArrival: return "If enabled, Put in Wallet opens the wallet and shows the balance rising and any new card arriving. If disabled, the bill is dismissed where it stands" + case .glassReplyQuote: + return "If enabled, the quote above the composer is a floating Liquid Glass capsule. If disabled, it is an inset panel on the bar's own surface" } } @@ -186,6 +191,7 @@ extension BetaFlags { case .vibrateOnScan: return .developer case .enableCoinbase: return .developer case .walletDepositArrival: return .developer + case .glassReplyQuote: return .developer } } @@ -196,6 +202,7 @@ extension BetaFlags { case .vibrateOnScan: return false case .enableCoinbase: return false case .walletDepositArrival: return true + case .glassReplyQuote: return false } } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index a896e898f..874112ac6 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -10,26 +10,46 @@ import FlipcashCore import FlipcashUI /// The quoted original above the composer while a reply is being written: a rule in the author's own -/// colour flush against the screen's leading edge, their name over one or two lines of what they -/// said, and the way out on the trailing edge. Dismissing it takes back the target without touching -/// the draft. +/// colour, the author's name over one or two lines of what they said, and the way out on the +/// trailing edge. Dismissing it takes back the target without touching the draft. /// -/// No card and no tinted ground. The strip is not a thing sitting on the bar, it *is* the top of the -/// bar — the rule runs the full height of the region the bar grew by, edge to edge, which is what -/// makes the growth read as the bar getting taller rather than as a panel arriving. Matches -/// WhatsApp, measured: 4pt rule at x=0, text 13pt in, no inset of any kind. +/// The quote carries the reply's elevation, because the bar cannot. The bar's slab has to stay the +/// chat background to match the keyboard it sits on — see `BarSurfaceBackground` — so the ground +/// that sets a reply apart is drawn here, inset from the bar's edges, in one of two ``Style``s. /// /// The colour is the person's, not the surface's — `ComplementaryPalette` derives it from their user /// id, so the same person is the same colour here, inside a sent bubble, and on Android. struct ComposerReplyStrip: View { + /// The container the quote is drawn in. + /// + /// Both keep the bar's slab flat, so neither draws a line against the keyboard; they differ in + /// whether the quote reads as part of the bar or as something floating over it. + enum Style { + /// An opaque panel on the bar's own surface, one step up from it. The conservative reading + /// of WhatsApp: a reply makes the bar taller and puts a card in the space it gained. + case panel + /// A Liquid Glass capsule floating clear of the bar, sampling the transcript behind it. + /// Falls back to an ultra-thin material below iOS 26. + case glass + } + let target: ComposerModel.ReplyTarget let onDismiss: () -> Void - /// Flush at the screen's leading edge, so it reads as a citation mark on the bar rather than a - /// border on a card. + /// Prototype switch, read from the shared instance rather than the environment: the bar is + /// hosted inside a `UIHostingController`, which does not inherit the app's SwiftUI environment. + /// `BetaFlags` is `@Observable`, so reading it in `body` still re-renders on the toggle. + private var style: Style { + BetaFlags.shared.hasEnabled(.glassReplyQuote) ? .glass : .panel + } + private static let ruleWidth: CGFloat = 4 + /// How far the quote's ground is held off the bar's own edges, matching the composer row's + /// horizontal padding below it so the two stack up on one margin. + private static let inset: CGFloat = 12 + /// Sized to the cap height of the amount beside it, so the flag reads as a mark on the line /// rather than as a second element the line has to make room for. private static let flagDiameter: CGFloat = 16 @@ -39,12 +59,7 @@ struct ComposerReplyStrip: View { let name = ComplementaryPalette.color(.middle, for: target.authorID) HStack(alignment: .center, spacing: 9) { - // Unpadded and unclipped: a `Rectangle` is flexible vertically, so in this stack it - // takes the strip's whole height, and with no leading padding on the row it starts at - // the screen edge. - Rectangle() - .fill(rule) - .frame(width: Self.ruleWidth) + authorRule(rule) VStack(alignment: .leading, spacing: 2) { Text(target.authorName) @@ -76,12 +91,40 @@ struct ComposerReplyStrip: View { .accessibilityLabel("Cancel reply") .accessibilityIdentifier("cancel-reply-button") } - .padding(.trailing, 8) + .padding(.trailing, style == .glass ? 12 : 8) .frame(maxWidth: .infinity, alignment: .leading) + .modifier(QuoteGround(style: style)) + .padding(.horizontal, Self.inset) + // Clear of the composer row below and of the bar's top edge above, so the ground reads as a + // thing on the bar rather than as the bar's own top. + .padding(.vertical, 8) .accessibilityElement(children: .contain) .accessibilityIdentifier("composer-reply-strip") } + /// The author's colour down the leading edge of the quote. + /// + /// A `Rectangle` is flexible vertically, so in the row it takes the quote's whole height. The + /// panel clips it to the corner radius, which is what keeps it flush; the capsule cannot clip a + /// square rule against a curve without it reading as a chip out of the glass, so there it is a + /// rounded rule held inside the curve. + @ViewBuilder + private func authorRule(_ color: Color) -> some View { + switch style { + case .panel: + Rectangle() + .fill(color) + .frame(width: Self.ruleWidth) + case .glass: + Capsule() + .fill(color) + .frame(width: Self.ruleWidth) + .padding(.vertical, 8) + .padding(.leading, 12) + } + } + + /// The quoted original itself. One step under the bubble body's 16, and in the same weight: the /// quote is the subject of the strip, so it is read, not glanced at. `textMain` for the same /// reason — dimming it made it look like placeholder text for the field below. @@ -125,3 +168,26 @@ struct ComposerReplyStrip: View { } } } + +/// What the quote sits on, and the clip that goes with it. +/// +/// The panel has to clip its content as well as fill behind it: the author's rule runs flush to the +/// leading edge, and unclipped it squares off the two corners it passes. The capsule must *not* +/// clip — `glassEffect` draws its specular edge and shadow outside its own bounds, and a clip +/// shaves them off, leaving a flat grey pill. The rule is inset for the capsule for the same reason. +private struct QuoteGround: ViewModifier { + + let style: ComposerReplyStrip.Style + + @ViewBuilder + func body(content: Content) -> some View { + switch style { + case .panel: + content + .background(Color.backgroundSecondary) + .clipShape(.rect(cornerRadius: BarMetrics.cornerRadius)) + case .glass: + content.glassCapsuleBackground() + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 86a2294cb..3f005f62f 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -28,10 +28,10 @@ private let barMorphSpring = ChatMotion.swap.animation /// place and back. private let replySpring = ChatMotion.replySurface.animation -/// Metrics shared by the field and the button beside it so their heights can't -/// desync. Deliberately not `Metrics.buttonHeight`/`buttonRadius` — beside the -/// field the controls are field-sized, not standard-button-sized. -private enum BarMetrics { +/// Metrics shared by the field, the button beside it, and the reply quote above them, so their +/// heights and corners can't desync. Deliberately not `Metrics.buttonHeight`/`buttonRadius` — beside +/// the field the controls are field-sized, not standard-button-sized. +enum BarMetrics { static let fieldMinHeight: CGFloat = 34 static let fieldVerticalPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 @@ -109,7 +109,7 @@ struct ConversationBottomBar: View { } // Inside the animation modifier, not outside it: the surface is sized by the stack above, // so both its geometry and the strip's height resolve on the one curve. - .modifier(BarSurfaceBackground(isReplying: composer.replyTarget != nil)) + .modifier(BarSurfaceBackground()) .animation(replySpring, value: composer.replyTarget) } } @@ -389,8 +389,15 @@ private struct CancelEditButton: View { /// appeared behind the bar and left again, and no amount of curve-matching stopped it reading as a /// second object crossfading over the messages — because it *was* one. Nothing here mounts or /// unmounts: the slab is always drawn, always full width, always pinned to the bottom. A reply -/// changes two things about it — how tall it is, and what it is painted with — and both resolve on -/// the one spring, so what moves is the bar itself rather than something arriving over the messages. +/// changes one thing about it — how tall it is — so what moves is the bar itself rather than +/// something arriving over the messages. +/// +/// The colour is one of the things a reply must *not* change. `background` is (25,25,26) and the +/// keyboard's own container paints within a level of that, which is why the slab and the keyboard +/// read as one surface. Lifting the slab to `backgroundSecondary` (37,37,38) for a reply drew a hard +/// horizontal line across the screen at the keyboard's top edge — not a gap in the bleed, which +/// already runs past the safe area, but a colour step against a system surface we cannot repaint. So +/// the elevation a reply needs goes on the quote instead; see ``ComposerReplyStrip/Style``. private struct BarSurfaceBackground: ViewModifier { /// How far the surface paints below the bar's own bottom edge. @@ -402,20 +409,11 @@ private struct BarSurfaceBackground: ViewModifier { /// so overshooting the radius costs nothing. private static let keyboardCornerBleed: CGFloat = 32 - let isReplying: Bool - func body(content: Content) -> some View { // Top-aligned so the negative padding hangs the extra height below the bar rather than // splitting it, which would paint over the transcript. content.background(alignment: .top) { - ZStack(alignment: .top) { - BarSurface.restingFade - // Crossfaded over the fade on identical geometry — same width, same edges, same - // bottom — so what changes is the paint, not the cast: there is no second object - // to read as arriving over the messages. - BarSurface.replyFill - .opacity(isReplying ? 1 : 0) - } + BarSurface.restingFade // Absorbed by the fade's opaque tail, so the dissolve at the top edge keeps its height // whatever the bleed is. .padding(.bottom, -Self.keyboardCornerBleed) @@ -428,17 +426,15 @@ private struct BarSurfaceBackground: ViewModifier { } } -/// What the bar's surface is made of, which depends on whether a reply is being written. +/// What the bar's surface is made of. /// -/// At rest the slab is not a slab at its top edge: it ramps from the chat background up to nothing -/// over ``fadeHeight``, so a message scrolling under the bar dissolves into it rather than meeting a -/// hard line. That dissolve is the composer's resting look and it stays — opaque at rest, the bar -/// reads as a toolbar bolted across the transcript. Replying paints over it with the -/// elevated-surface token, which is what draws the top edge and sets the quote apart from the -/// messages above it. +/// The slab is not a slab at its top edge: it ramps from the chat background up to nothing over +/// ``fadeHeight``, so a message scrolling under the bar dissolves into it rather than meeting a hard +/// line. Opaque instead, the bar reads as a toolbar bolted across the transcript. /// -/// The reply fill is painted in two places — the bar draws it, and the screen paints the same colour -/// below the bar so it reaches the bottom of the display. See `BarSurfaceFloor`. +/// One paint, in every state — see `BarSurfaceBackground` for why a reply may not change it. It is +/// painted in two places: the bar draws it, and the screen paints the same colour below the bar so +/// it reaches the bottom of the display. See `BarSurfaceFloor`. enum BarSurface { /// How far the resting surface takes to ramp from nothing to the chat background — half the @@ -459,12 +455,6 @@ enum BarSurface { } } - /// The surface a reply lifts the bar to. - static let replyFill = Color.backgroundSecondary - - static func fill(isReplying: Bool) -> Color { - isReplying ? replyFill : .backgroundMain - } } /// The bar surface's continuation below the bar, painted by the screen. @@ -478,18 +468,12 @@ enum BarSurface { /// the inset instead does not work: `ignoresSafeArea` grows the region offered to a *flexible* view, /// and a view already fixed to a height keeps that height and stays inside the safe area. /// -/// It carries its own animation because it is a sibling of the bar, not a child: the bar's spring -/// covers the bar's subtree only, and a floor that snapped to the new colour while the bar eased into -/// it would put a visible seam across the bottom of the screen. struct BarSurfaceFloor: View { - let isReplying: Bool - var body: some View { - BarSurface.fill(isReplying: isReplying) + Color.backgroundMain .ignoresSafeArea(.container, edges: .bottom) .allowsHitTesting(false) - .animation(ChatMotion.replySurface.animation, value: isReplying) } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 0fab0ce23..f58f227eb 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -232,7 +232,7 @@ struct ConversationScreen: View { // it the rest of the way down, so the bar reads as running off the bottom of the display // rather than as a card with an edge above the home indicator. .background { - BarSurfaceFloor(isReplying: composer.replyTarget != nil) + BarSurfaceFloor() } .background(Color.backgroundMain) .navigationTitle("") diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index cec2b44d2..01c3e0b01 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -17,6 +17,21 @@ extension View { } } + /// The app's standard glass surface in a capsule: Liquid Glass on iOS 26, an + /// ultra-thin material below. + /// + /// Wraps rather than backgrounds, which is safe for a capsule of static + /// content. Don't reach for it around an editable text field — see + /// ``glassFieldBackground(cornerRadius:)`` for why. + @ViewBuilder + public func glassCapsuleBackground() -> some View { + if #available(iOS 26, *) { + glassEffect(.regular, in: .capsule) + } else { + background(.ultraThinMaterial, in: .capsule) + } + } + /// The glass surface as a background layer *behind* the content, rather than /// wrapping it. Use for a surface that hosts its own touch-tracking control /// (a text field): applying `glassEffect` to the control reparents its text From c98428cfe7eb73cd2ac744fc040c2363c56a13c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 01:01:22 -0400 Subject: [PATCH 2/4] fix(chat): shape the reply quote like the rest of the bar The glass variant was a capsule while the field and the Send Cash button under it are 14pt rounded rects, so the quote read as a different kind of object from the controls it sits above. Both variants now take BarMetrics.cornerRadius, and the only thing left between them is the material. Matching the corner also gets the author's rule back onto the edge. The clip moves onto the content and the ground goes behind it: the rule is rounded off at the two corners it passes, while the glass stays outside the clip and keeps the specular edge a clip was shaving off. So the rule sits flush in either style, the way it did before the quote had a ground at all. --- .../Conversation/ComposerReplyStrip.swift | 62 ++++++++----------- .../Views/Containers/GlassBackground.swift | 15 ----- 2 files changed, 25 insertions(+), 52 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 874112ac6..5ef54ee7b 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -23,14 +23,16 @@ struct ComposerReplyStrip: View { /// The container the quote is drawn in. /// - /// Both keep the bar's slab flat, so neither draws a line against the keyboard; they differ in - /// whether the quote reads as part of the bar or as something floating over it. + /// Both keep the bar's slab flat, so neither draws a line against the keyboard, and both take + /// `BarMetrics.cornerRadius` — the field's and the Send Cash button's — so the quote is the same + /// shape as everything else on the bar. What differs is the material, and so what the quote + /// reads as: part of the bar, or something floating over it. enum Style { /// An opaque panel on the bar's own surface, one step up from it. The conservative reading /// of WhatsApp: a reply makes the bar taller and puts a card in the space it gained. case panel - /// A Liquid Glass capsule floating clear of the bar, sampling the transcript behind it. - /// Falls back to an ultra-thin material below iOS 26. + /// Liquid Glass floating clear of the bar, sampling the transcript behind it. Falls back to + /// an ultra-thin material below iOS 26. case glass } @@ -59,7 +61,12 @@ struct ComposerReplyStrip: View { let name = ComplementaryPalette.color(.middle, for: target.authorID) HStack(alignment: .center, spacing: 9) { - authorRule(rule) + // Unpadded and unclipped here: a `Rectangle` is flexible vertically, so in this stack it + // takes the quote's whole height, and with no leading padding on the row it starts at + // the quote's edge. ``QuoteGround`` rounds off the two corners it passes. + Rectangle() + .fill(rule) + .frame(width: Self.ruleWidth) VStack(alignment: .leading, spacing: 2) { Text(target.authorName) @@ -91,7 +98,7 @@ struct ComposerReplyStrip: View { .accessibilityLabel("Cancel reply") .accessibilityIdentifier("cancel-reply-button") } - .padding(.trailing, style == .glass ? 12 : 8) + .padding(.trailing, 8) .frame(maxWidth: .infinity, alignment: .leading) .modifier(QuoteGround(style: style)) .padding(.horizontal, Self.inset) @@ -102,28 +109,6 @@ struct ComposerReplyStrip: View { .accessibilityIdentifier("composer-reply-strip") } - /// The author's colour down the leading edge of the quote. - /// - /// A `Rectangle` is flexible vertically, so in the row it takes the quote's whole height. The - /// panel clips it to the corner radius, which is what keeps it flush; the capsule cannot clip a - /// square rule against a curve without it reading as a chip out of the glass, so there it is a - /// rounded rule held inside the curve. - @ViewBuilder - private func authorRule(_ color: Color) -> some View { - switch style { - case .panel: - Rectangle() - .fill(color) - .frame(width: Self.ruleWidth) - case .glass: - Capsule() - .fill(color) - .frame(width: Self.ruleWidth) - .padding(.vertical, 8) - .padding(.leading, 12) - } - } - /// The quoted original itself. One step under the bubble body's 16, and in the same weight: the /// quote is the subject of the strip, so it is read, not glanced at. `textMain` for the same @@ -169,25 +154,28 @@ struct ComposerReplyStrip: View { } } -/// What the quote sits on, and the clip that goes with it. +/// What the quote sits on. One radius — the bar's — and two materials. /// -/// The panel has to clip its content as well as fill behind it: the author's rule runs flush to the -/// leading edge, and unclipped it squares off the two corners it passes. The capsule must *not* -/// clip — `glassEffect` draws its specular edge and shadow outside its own bounds, and a clip -/// shaves them off, leaving a flat grey pill. The rule is inset for the capsule for the same reason. +/// The clip goes on the content and the ground goes behind it, rather than one clip over both. Both +/// halves need that. The author's rule runs flush to the leading edge and squares off the two +/// corners it passes unless something rounds it, and `glassEffect` draws its specular edge outside +/// its own bounds and loses it to a clip — so the rule is clipped, the ground is not, and the rule +/// can sit on the edge in either style. private struct QuoteGround: ViewModifier { let style: ComposerReplyStrip.Style + private static let shape = RoundedRectangle(cornerRadius: BarMetrics.cornerRadius) + @ViewBuilder func body(content: Content) -> some View { + let quote = content.clipShape(Self.shape) switch style { case .panel: - content - .background(Color.backgroundSecondary) - .clipShape(.rect(cornerRadius: BarMetrics.cornerRadius)) + quote.background(Color.backgroundSecondary, in: Self.shape) case .glass: - content.glassCapsuleBackground() + // The background form, not the wrapping one: the glass has to stay outside the clip. + quote.glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 01c3e0b01..cec2b44d2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -17,21 +17,6 @@ extension View { } } - /// The app's standard glass surface in a capsule: Liquid Glass on iOS 26, an - /// ultra-thin material below. - /// - /// Wraps rather than backgrounds, which is safe for a capsule of static - /// content. Don't reach for it around an editable text field — see - /// ``glassFieldBackground(cornerRadius:)`` for why. - @ViewBuilder - public func glassCapsuleBackground() -> some View { - if #available(iOS 26, *) { - glassEffect(.regular, in: .capsule) - } else { - background(.ultraThinMaterial, in: .capsule) - } - } - /// The glass surface as a background layer *behind* the content, rather than /// wrapping it. Use for a surface that hosts its own touch-tracking control /// (a text field): applying `glassEffect` to the control reparents its text From d0e7880a1f9db967d427a2d58f0a4d770fb33427 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 01:11:24 -0400 Subject: [PATCH 3/4] fix(chat): size the reply quote to the bar's controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote was whatever the two stacked fonts and 8pt of padding came to — about 49pt against the Send Cash button's 50, near enough to read as a mistake rather than as a deliberately different size. Pin it to `BarMetrics.contentHeight`, as a minimum so a snippet that wraps to a second line can still grow. Its margin was 12 at the sides and 8 top and bottom, and the 8 underneath stacked with the bar's own row padding, so the gap below the quote was twice the gap above it. Both are now `inset`, with the bar's padding subtracted from the bottom so the three gaps match. Also names the Send Cash button in the flag description, which still called the glass variant a capsule from before the shape matched the bar's. --- Flipcash/Core/Controllers/BetaFlags.swift | 2 +- .../Conversation/ComposerReplyStrip.swift | 21 ++++++++++++------- .../Conversation/ConversationBottomBar.swift | 9 +++++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index d2973e127..a3ce849ee 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -181,7 +181,7 @@ extension BetaFlags { case .walletDepositArrival: return "If enabled, Put in Wallet opens the wallet and shows the balance rising and any new card arriving. If disabled, the bill is dismissed where it stands" case .glassReplyQuote: - return "If enabled, the quote above the composer is a floating Liquid Glass capsule. If disabled, it is an inset panel on the bar's own surface" + return "If enabled, the quote above the composer is Liquid Glass floating clear of the bar. If disabled, it is an opaque panel on the bar's own surface" } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 5ef54ee7b..3bb715b91 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -48,8 +48,9 @@ struct ComposerReplyStrip: View { private static let ruleWidth: CGFloat = 4 - /// How far the quote's ground is held off the bar's own edges, matching the composer row's - /// horizontal padding below it so the two stack up on one margin. + /// The margin around the quote's ground, on every side: matching the composer row's horizontal + /// padding below it, so the two stack up on one margin and the quote is inset by the same amount + /// from the bar's edges as it is from the controls. private static let inset: CGFloat = 12 /// Sized to the cap height of the amount beside it, so the flag reads as a mark on the line @@ -74,7 +75,7 @@ struct ComposerReplyStrip: View { .foregroundStyle(name) quoteLine } - .padding(.vertical, 8) + .padding(.vertical, BarMetrics.fieldVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) // Combined here rather than on the row, so the quote reads as one element while the // dismiss button stays a button of its own — a row-level combine folds the button into @@ -99,12 +100,18 @@ struct ComposerReplyStrip: View { .accessibilityIdentifier("cancel-reply-button") } .padding(.trailing, 8) - .frame(maxWidth: .infinity, alignment: .leading) + // The Send Cash button's height, so a one-line quote is the same size box as the controls + // below it. Only a minimum: a snippet that wraps to a second line has to grow, and the two + // fonts stacked here land a point under the controls on their own, which is close enough to + // read as a mistake rather than as a different size. + .frame(maxWidth: .infinity, minHeight: BarMetrics.contentHeight, alignment: .leading) .modifier(QuoteGround(style: style)) .padding(.horizontal, Self.inset) - // Clear of the composer row below and of the bar's top edge above, so the ground reads as a - // thing on the bar rather than as the bar's own top. - .padding(.vertical, 8) + // One margin all the way round: the quote sits ``inset`` from the bar's top edge and the same + // distance off the controls below. The bar already pads its own row, so only the remainder is + // added here — padding both by ``inset`` would leave the gap underneath twice the one above. + .padding(.top, Self.inset) + .padding(.bottom, Self.inset - BarMetrics.contentPadding) .accessibilityElement(children: .contain) .accessibilityIdentifier("composer-reply-strip") } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 3f005f62f..fe247cbe7 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -35,8 +35,11 @@ enum BarMetrics { static let fieldMinHeight: CGFloat = 34 static let fieldVerticalPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 - /// The height of every bar control: a single-line field plus its padding. + /// The height of every bar control: a single-line field plus its padding, and the height the + /// Send Cash button morphs at while there is a composer beside it. static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + /// The bar's own margin around its controls, above and below. + static let contentPadding: CGFloat = 8 } /// The unified bottom bar: Send Cash (morphing) beside the message field. @@ -90,8 +93,8 @@ struct ConversationBottomBar: View { } } .padding(.horizontal, 12) - .padding(.top, 8) - .padding(.bottom, 8) + .padding(.top, BarMetrics.contentPadding) + .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) From b85e54853114783970f61a690c1b4c9beea73133 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 6 Sep 2026 08:39:04 -0400 Subject: [PATCH 4/4] feat(chat): make the reply quote Liquid Glass over the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar's surface was painted behind the whole stack, so the 33pt ramp that dissolves the transcript into the composer anchored to whatever the bar's top edge was. Opening a reply moved that dissolve 50pt up the screen and put the opaque tail behind the quote. It is now painted behind the composer row alone: the ramp stays at the input, and the quote sits over live transcript. That is what the glass variant needed to have anything to sample, so it ships on — `glassReplyQuote` is now default-on. The flag and the opaque card stay behind it, toggleable from the developer section, because which of the two reads better against a scrolling transcript is a call for product review. The author's rule goes to 6pt and moves to an overlay on the pinned frame. Standing in the row it took the row's natural height, which the frame then centred, leaving it a fraction short at both ends. Width alone cannot make it look rectangular — the 14pt radius leaves the leading edge straight for only `contentHeight - 28`, and thickening lengthens the taper rather than the straight core — but 6pt carries enough mass for the ends to read as caps. On glass it takes a tint rather than a solid fill, via a new `glassBackground(cornerRadius:tint:)`, so it is part of the surface instead of a sticker on it. The bubble's quote goes to two lines, matching the composer's strip. One line truncated most quoted sentences mid-clause, which left the reply pointing at something the reader still had to open to understand. --- Flipcash/Core/Controllers/BetaFlags.swift | 2 +- .../Conversation/ComposerReplyStrip.swift | 53 +++++++++++++++---- .../Conversation/ConversationBottomBar.swift | 9 ++-- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 9 ++-- .../Views/Containers/GlassBackground.swift | 17 ++++++ 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index a3ce849ee..3fc423d84 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -202,7 +202,7 @@ extension BetaFlags { case .vibrateOnScan: return false case .enableCoinbase: return false case .walletDepositArrival: return true - case .glassReplyQuote: return false + case .glassReplyQuote: return true } } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 3bb715b91..e787b1869 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -28,8 +28,9 @@ struct ComposerReplyStrip: View { /// shape as everything else on the bar. What differs is the material, and so what the quote /// reads as: part of the bar, or something floating over it. enum Style { - /// An opaque panel on the bar's own surface, one step up from it. The conservative reading - /// of WhatsApp: a reply makes the bar taller and puts a card in the space it gained. + /// An opaque card over the transcript, one step up from the chat background. The + /// conservative reading of WhatsApp, minus its slab: the bar's surface stops at the composer + /// row, so the card is what the reply adds. case panel /// Liquid Glass floating clear of the bar, sampling the transcript behind it. Falls back to /// an ultra-thin material below iOS 26. @@ -46,7 +47,14 @@ struct ComposerReplyStrip: View { BetaFlags.shared.hasEnabled(.glassReplyQuote) ? .glass : .panel } - private static let ruleWidth: CGFloat = 4 + /// Wider than the 4pt a blockquote rule usually takes, because the quote's corner radius is the + /// bar's 14: the leading edge is straight for only `contentHeight - 14 * 2` of its run, and the + /// corners taper the rest away. Thickness can't lengthen that straight core — it only gives the + /// tapered ends enough mass to read as caps rather than as a clipping accident. + private static let ruleWidth: CGFloat = 6 + + /// The gap after the rule, and between the quote and the button that dismisses it. + private static let gutter: CGFloat = 9 /// The margin around the quote's ground, on every side: matching the composer row's horizontal /// padding below it, so the two stack up on one margin and the quote is inset by the same amount @@ -61,14 +69,7 @@ struct ComposerReplyStrip: View { let rule = ComplementaryPalette.color(.start, for: target.authorID) let name = ComplementaryPalette.color(.middle, for: target.authorID) - HStack(alignment: .center, spacing: 9) { - // Unpadded and unclipped here: a `Rectangle` is flexible vertically, so in this stack it - // takes the quote's whole height, and with no leading padding on the row it starts at - // the quote's edge. ``QuoteGround`` rounds off the two corners it passes. - Rectangle() - .fill(rule) - .frame(width: Self.ruleWidth) - + HStack(alignment: .center, spacing: Self.gutter) { VStack(alignment: .leading, spacing: 2) { Text(target.authorName) .font(.appTextHeading) @@ -99,12 +100,22 @@ struct ComposerReplyStrip: View { .accessibilityLabel("Cancel reply") .accessibilityIdentifier("cancel-reply-button") } + // Where the rule and its gutter used to stand in the row. + .padding(.leading, Self.ruleWidth + Self.gutter) .padding(.trailing, 8) // The Send Cash button's height, so a one-line quote is the same size box as the controls // below it. Only a minimum: a snippet that wraps to a second line has to grow, and the two // fonts stacked here land a point under the controls on their own, which is close enough to // read as a mistake rather than as a different size. .frame(maxWidth: .infinity, minHeight: BarMetrics.contentHeight, alignment: .leading) + // Overlaid on the pinned frame rather than standing in the row, so it spans the quote's real + // height. As a row member it took the row's natural height instead, which the frame above then + // centred inside `contentHeight` — leaving the rule a fraction short at both ends. + // ``QuoteGround`` rounds off the two corners it passes. + .overlay(alignment: .leading) { + QuoteRule(color: rule, style: style) + .frame(width: Self.ruleWidth) + } .modifier(QuoteGround(style: style)) .padding(.horizontal, Self.inset) // One margin all the way round: the quote sits ``inset`` from the bar's top edge and the same @@ -161,6 +172,26 @@ struct ComposerReplyStrip: View { } } +/// The author's colour down the quote's leading edge, in the same material as the ground behind it: +/// a solid fill on the opaque card, tinted Liquid Glass on the glass. A solid bar over glass reads as +/// a sticker stuck to the surface rather than as part of it. +private struct QuoteRule: View { + + let color: Color + let style: ComposerReplyStrip.Style + + @ViewBuilder + var body: some View { + switch style { + case .panel: + Rectangle().fill(color) + case .glass: + // Square-cornered: ``QuoteGround``'s clip rounds the two corners this rule passes. + Color.clear.glassBackground(cornerRadius: 0, tint: color) + } + } +} + /// What the quote sits on. One radius — the bar's — and two materials. /// /// The clip goes on the content and the ground goes behind it, rather than one clip over both. Both diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index fe247cbe7..e0c2a1405 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -108,11 +108,12 @@ struct ConversationBottomBar: View { // already drives this state in both directions, and wrapping the dismissal in a second // transaction gave the exit a curve the entry never had. ComposerReplyReveal(target: composer.replyTarget) { composer.endReplying() } - content + // On the composer row alone, not on the stack. The surface's job is to dissolve the + // transcript into the input; anchoring it to the stack moved the dissolve up to the reply + // strip's top edge, so a reply slid the fade 50pt up the screen and put an opaque slab + // behind the quote. The quote is meant to sit over the transcript, not over the slab. + content.modifier(BarSurfaceBackground()) } - // Inside the animation modifier, not outside it: the surface is sized by the stack above, - // so both its geometry and the strip's height resolve on the one curve. - .modifier(BarSurfaceBackground()) .animation(replySpring, value: composer.replyTarget) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index b038d9ef8..7251e38eb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -10,7 +10,7 @@ import UIKit import FlipcashCore /// The quoted original drawn inside a reply's bubble, above the body: a leading rule, the author, -/// and one or two lines of the original. Tapping it asks to jump to that message — but only when +/// and up to two lines of the original. Tapping it asks to jump to that message — but only when /// there is a row to jump to, which `ChatQuote.isJumpable` decides. final class ChatQuotePanelView: UIView { @@ -73,10 +73,9 @@ final class ChatQuotePanelView: UIView { snippetLabel.font = .default(size: 12, weight: .medium) snippetLabel.textColor = Self.snippetColor - // One line in the bubble, per the spec: the panel is a citation, not a second message, and - // a two-line panel over a one-line reply reads as the wrong thing being the point. The - // composer's strip allows two, because there the quote *is* the subject. - snippetLabel.numberOfLines = 1 + // Two, matching the composer's strip: one line truncated most quoted sentences mid-clause, + // which left the reply pointing at something the reader still had to go and open. + snippetLabel.numberOfLines = 2 snippetLabel.lineBreakMode = .byTruncatingTail flagView.contentMode = .scaleAspectFill diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index cec2b44d2..ec2414458 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -17,6 +17,23 @@ extension View { } } + /// The app's glass surface carrying a colour: Liquid Glass takes the tint natively on iOS 26, + /// and below it the colour is laid over an ultra-thin material. + /// + /// For a surface whose colour is the point. Non-interactive — a tinted rule or badge, not a + /// control, so it has no touch response to track. + @ViewBuilder + public func glassBackground(cornerRadius: CGFloat, tint: Color) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius)) + } else { + // Short of opaque, so the material still reads as a material, but saturated enough that + // a few points of it still carry a recognisable colour. + background(tint.opacity(0.6), in: .rect(cornerRadius: cornerRadius)) + .background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) + } + } + /// The glass surface as a background layer *behind* the content, rather than /// wrapping it. Use for a surface that hosts its own touch-tracking control /// (a text field): applying `glassEffect` to the control reparents its text