Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ extension BetaFlags {
case vibrateOnScan
case enableCoinbase
case walletDepositArrival
case glassReplyQuote

var id: String {
localizedTitle
Expand All @@ -166,6 +167,8 @@ extension BetaFlags {
return "Enable Coinbase"
case .walletDepositArrival:
return "Show deposits arriving in the wallet"
case .glassReplyQuote:
return "Glass reply quote"
}
}

Expand All @@ -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"
}
}

Expand All @@ -186,6 +191,7 @@ extension BetaFlags {
case .vibrateOnScan: return .developer
case .enableCoinbase: return .developer
case .walletDepositArrival: return .developer
case .glassReplyQuote: return .developer
}
}

Expand All @@ -196,6 +202,7 @@ extension BetaFlags {
case .vibrateOnScan: return false
case .enableCoinbase: return false
case .walletDepositArrival: return true
case .glassReplyQuote: return true
}
}
}
Expand Down
132 changes: 112 additions & 20 deletions Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
78 changes: 33 additions & 45 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
Loading
Loading