diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 3fe9d56e8..3fc423d84 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 Liquid Glass floating clear of the bar. If disabled, it is an opaque 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 true } } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index a896e898f..e787b1869 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -10,25 +10,56 @@ 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, 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 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. + 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. - private static let ruleWidth: CGFloat = 4 + /// 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 + } + + /// 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 + /// 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 /// rather than as a second element the line has to make room for. @@ -38,21 +69,14 @@ 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: 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) - + HStack(alignment: .center, spacing: Self.gutter) { VStack(alignment: .leading, spacing: 2) { Text(target.authorName) .font(.appTextHeading) .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 @@ -76,12 +100,34 @@ 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) - .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) + // 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 + // 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") } + /// 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 +171,49 @@ 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 +/// 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: + quote.background(Color.backgroundSecondary, in: Self.shape) + case .glass: + // The background form, not the wrapping one: the glass has to stay outside the clip. + quote.glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 86a2294cb..e0c2a1405 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -28,15 +28,18 @@ 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 - /// 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) @@ -105,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(isReplying: composer.replyTarget != nil)) .animation(replySpring, value: composer.replyTarget) } } @@ -389,8 +393,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 +413,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 +430,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 +459,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 +472,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/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