diff --git a/Flipcash/Core/Screens/Conversation/AttachCard.swift b/Flipcash/Core/Screens/Conversation/AttachCard.swift index 3ccfec088..4e32be06b 100644 --- a/Flipcash/Core/Screens/Conversation/AttachCard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -55,6 +55,8 @@ final class AttachCard { /// That chip's frame in window coordinates, once the bar has laid it out. private(set) var landingChipFrame: CGRect? + /// The photo the card is shrinking into the chip, drawn by the surface for the whole landing. + private(set) var landingImage: UIImage? /// Whether the keyboard coming up takes the card down. Off while the card is drawn over the /// keyboard, where the keyboard never left, and an input view swap announces it again. @@ -105,9 +107,10 @@ final class AttachCard { /// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has /// laid that chip out. - func beginLanding(on chipID: ComposerChip.ID) { + func beginLanding(on chipID: ComposerChip.ID, image: UIImage? = nil) { landingChipID = chipID landingChipFrame = nil + landingImage = image } /// Records where the landing chip was laid out. Returns whether the card is still up and can now @@ -127,6 +130,7 @@ final class AttachCard { withTransaction(transaction) { landingChipID = nil landingChipFrame = nil + landingImage = nil } } diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 2afa5bfe1..a39811645 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -35,8 +35,7 @@ enum AttachMenuItem: Equatable { } } -/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the -/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over +/// The `+` circle in the composer field's bottom row. Opens the attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over /// the field and collapses back into it — and reports where it is laid out, for the surface to grow /// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard. struct AttachMenu: View { @@ -61,14 +60,13 @@ struct AttachMenu: View { panel.animate { $0.toggle() } } label: { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) - .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) - .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(AttachSurfaceLayout.plusFill, in: Circle()) + .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: BarMetrics.cornerRadius) - .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .accessibilityLabel("Attach") .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") .accessibilityIdentifier("attach-menu-button") diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift index 3598fa809..2acf7fd9d 100644 --- a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -80,6 +80,10 @@ final class AttachOverKeyboard { /// `+`'s frame in window coordinates, as last laid out. var plusFrame: CGRect = .zero + /// How far left of the composer field the bar's row starts: `$`'s width and gap while it shows, + /// else zero. The menu opens out to there, so it covers the row it opens from. + var menuLeadingReach: CGFloat = 0 + /// Whether the photo card opens the full library as it next mounts in the bar. Set when All /// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover. var opensLibraryInBar = false @@ -101,13 +105,21 @@ nonisolated enum AttachOverlayLayout { /// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset. static let cardInset: CGFloat = 12 - /// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s + /// The panel's frame for a panel `size` big: its leading edge on the composer field's, which is + /// the field's padding out from `+`'s, and centred on `+`'s /// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower /// half lies over the keys that blur through it and its upper half over the field. static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect { - CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height) + // Centred on the field's bottom edge, a short menu tops out just under the field's top and + // leaves its corner showing; lift it to clear the top by `fieldTopOverhang`. + let fieldTop = plusFrame.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + let y = min(plusFrame.maxY - size.height / 2, fieldTop - fieldTopOverhang) + return CGRect(x: plusFrame.minX - BarMetrics.fieldPadding, y: y, width: size.width, height: size.height) } + /// How far the open menu reaches above the composer field's top edge. + static let fieldTopOverhang: CGFloat = 8 + /// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and /// collapse back into. static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint { diff --git a/Flipcash/Core/Screens/Conversation/AttachPanel.swift b/Flipcash/Core/Screens/Conversation/AttachPanel.swift index 369aca1bc..9fd0c4281 100644 --- a/Flipcash/Core/Screens/Conversation/AttachPanel.swift +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -48,9 +48,11 @@ final class AttachPanel { } /// Runs `change` on the panel's spring, and releases the room above the bar once the motion it - /// started has logically finished. + /// started has finished: logically for an open, fully settled for a close, so the surface is not + /// taken down before it has reached `+`. func animate(_ change: (AttachPanel) -> Void) { - withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) { + let closes = isOpen + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: closes ? .removed : .logicallyComplete) { change(self) } completion: { [weak self] in self?.exitDidFinish() diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 9862f055f..06f1580e3 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -67,28 +67,41 @@ nonisolated struct AttachSurfaceShape: Equatable { /// Where the attach surface stands in each phase, in its host's coordinates. enum AttachSurfaceLayout { - /// `+`'s radius, which the surface grows out of and collapses back into. - static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius - static let menuCornerRadius: CGFloat = 40 + /// Matches the composer field's corner, so the menu reads as part of it. + static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius + /// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`. + static let plusFill = Color.white.opacity(0.10) + /// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass. + static let plusBlendDistance: CGFloat = 40 static let cardCornerRadius: CGFloat = 24 /// A staged chip's radius, which a landing ends on. static let chipCornerRadius: CGFloat = 10 - /// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen. - static let menuWidth: CGFloat = 280 + /// The menu's width: room for an icon and a short label, well short of the send button. + static let menuWidth: CGFloat = 220 /// A menu row's height, sized to the system's own attachment menus. static let menuRowHeight: CGFloat = 62 static let menuVerticalPadding: CGFloat = 10 + /// The rows' blur and scale while hidden, which they sharpen and grow out of as the menu opens. + static let rowsBloomBlur: CGFloat = 8 + static let rowsBloomScale: CGFloat = 0.85 /// The menu's size before its rows are measured. static func estimatedMenuSize(rowCount: Int) -> CGSize { CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2) } - /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. - static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. Either way its + /// leading edge is the composer field's, ``BarMetrics/fieldPadding`` out from `+`'s, moved out a + /// further `leadingReach` at the same width; standing on `+`, its bottom edge is the field's too. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement, leadingReach: CGFloat = 0) -> CGRect { + let rect = fieldAlignedMenuRect(plus: plus, size: size, placement: placement) + return CGRect(x: rect.minX - leadingReach, y: rect.minY, width: rect.width, height: rect.height) + } + + private static func fieldAlignedMenuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { switch placement { case .standsOnPlus: - CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height) + CGRect(x: plus.minX - BarMetrics.fieldPadding, y: plus.maxY + BarMetrics.fieldPadding - size.height, width: size.width, height: size.height) case .straddlesPlus: AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) } @@ -100,11 +113,16 @@ enum AttachSurfaceLayout { CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height) } + /// Returns the radius of the surface collapsed onto `plus`, which is a circle: half its height. + static func collapsedCornerRadius(plus: CGRect) -> CGFloat { + plus.height / 2 + } + /// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's. static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape { switch phase { case .collapsed: - AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius) + AttachSurfaceShape(rect: plus, cornerRadius: collapsedCornerRadius(plus: plus)) case .menu: AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius) case .card: @@ -162,13 +180,14 @@ struct AttachSurface: View { let menu = AttachSurfaceLayout.menuRect( plus: plus, size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count), - placement: menuPlacement + placement: menuPlacement, + leadingReach: model.overKeyboard.menuLeadingReach ) let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing) // Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead. let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed) let showsPlusGlyph = !appeared || phase == .collapsed - AttachSurfaceFrame(shape: shape) { rect in + AttachSurfaceFrame(shape: shape, plus: plus, blendsIntoPlus: phase != .landing) { rect in ZStack(alignment: .topLeading) { plusGlyph .offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY) @@ -176,7 +195,19 @@ struct AttachSurface: View { cardLayer(phase: phase) .frame(width: card.width, height: card.height) .offset(x: card.minX - rect.minX, y: card.minY - rect.minY) - rows(phase: phase) + // From the hand-off on, filling the surface at every step: the viewfinder holds the + // capture, and it collapses into the chip rather than an empty surface flying there. + if let image = model.attachCard.landingImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: rect.width, height: rect.height) + .clipped() + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.identity) + } + rows(phase: phase, menu: menu, motion: motion) .frame(width: menu.width, height: menu.height, alignment: .topLeading) .offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY) } @@ -207,21 +238,33 @@ struct AttachSurface: View { /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. private var plusGlyph: some View { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) .frame(width: plus.width, height: plus.height) .accessibilityHidden(true) } - private func rows(phase: AttachSurfacePhase) -> some View { + /// The rows sharpen out of a blur and grow from `+` as the surface opens, and go back into it as + /// it closes, so they read as poured out of `+` rather than faded over it. + private func rows(phase: AttachSurfacePhase, menu: CGRect, motion: AttachMotion) -> some View { let panel = model.attachPanel + let shown = phase.showsRows + let blooms = motion.animatesGeometry + let anchor = UnitPoint( + x: menu.width > 0 ? (plus.midX - menu.minX) / menu.width : 0, + y: menu.height > 0 ? (plus.midY - menu.minY) / menu.height : 1 + ) return AttachMenuRows(items: items, onSelect: { item in panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos) }) { panel.animate { $0.dismiss() } } .onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 } - .attachLayer(isShown: phase.showsRows) + .animation(ChatMotion.attachPanel.animation) { + $0.blur(radius: shown || !blooms ? 0 : AttachSurfaceLayout.rowsBloomBlur) + .scaleEffect(shown || !blooms ? 1 : AttachSurfaceLayout.rowsBloomScale, anchor: anchor) + } + .attachLayer(isShown: shown) } /// The camera and the photo picker, each mounted from the surface's first frame at the card's @@ -271,6 +314,10 @@ private extension View { private struct AttachSurfaceFrame: View, Animatable { var shape: AttachSurfaceShape + /// `+`'s frame, which the surface takes on the look of as it shrinks to `+`'s size. + let plus: CGRect + /// Whether the surface turns into `+`'s fill near `+`'s size; not while it lands on a chip. + let blendsIntoPlus: Bool let content: (CGRect) -> Content var animatableData: AnimatablePair { @@ -284,10 +331,16 @@ private struct AttachSurfaceFrame: View, Animatable { var body: some View { let rect = shape.rect let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous) + let glass = glassAmount(at: rect) ZStack(alignment: .topLeading) { + clip + .fill(AttachSurfaceLayout.plusFill) + .frame(width: rect.width, height: rect.height) + .opacity(1 - glass) Color.clear .frame(width: rect.width, height: rect.height) .panelGlassBackground(cornerRadius: shape.cornerRadius) + .opacity(glass) content(rect) } .frame(width: rect.width, height: rect.height, alignment: .topLeading) @@ -295,4 +348,12 @@ private struct AttachSurfaceFrame: View, Animatable { .contentShape(clip) .offset(x: rect.minX, y: rect.minY) } + + /// How much of the panel's glass shows at `rect`, against `+`'s flat fill: none at `+`'s size, + /// all of it once the surface is a few dozen points bigger, so the two meet without a colour jump. + private func glassAmount(at rect: CGRect) -> CGFloat { + guard blendsIntoPlus else { return 1 } + let growth = max(rect.width - plus.width, rect.height - plus.height) + return min(max(growth / AttachSurfaceLayout.plusBlendDistance, 0), 1) + } } diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 95a4e1124..617515449 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -241,7 +241,7 @@ struct ChatCameraSheet: View { } } -/// A round control over the viewfinder: a white glyph on a dimmed disc. +/// A round control over the viewfinder: a white glyph on dark-tinted glass. private struct ChatCameraControlLabel: View { let systemImage: String @@ -251,7 +251,7 @@ private struct ChatCameraControlLabel: View { .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.white) .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + .overlayGlassBackground(in: Circle()) .contentShape(Circle()) } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift index 3f14e2245..7b79c123c 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -131,7 +131,7 @@ struct ChatPhotosCard: View { .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Circle()) .clipShape(Circle()) .accessibilityLabel("Back to attach menu") .accessibilityIdentifier("attach-photos-back") @@ -155,7 +155,7 @@ struct ChatPhotosCard: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Capsule()) .clipShape(Capsule()) .accessibilityIdentifier("attach-photos-all") .transition(.opacity) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 30ee0d332..d040c30c8 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -429,7 +429,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen?.focusComposer() return } - card.beginLanding(on: chipID) + let chip = composer.chips.first { $0.id == chipID } + card.beginLanding(on: chipID, image: chip.map { $0.preview ?? $0.image }) screen?.focusComposer() // A chip the bar never lays out leaves nothing to land on, so the card goes anyway. DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) { @@ -473,13 +474,14 @@ private struct MeasuredBarHeight: ViewModifier { content .fixedSize(horizontal: false, vertical: true) .overlayPreferenceValue(BarAccessoriesKey.self) { accessories in - GeometryReader { proxy in - Color.clear.onChange( - of: BarReport(height: proxy.size.height, accessories: accessories), - initial: true - ) { _, new in - report(new.height, new.accessories) - } + // `onGeometryChange`, not a `GeometryReader`: under an animated layout change the + // reader reports the height the animation starts from and then goes quiet, which + // leaves the clip short of the content it settles at. + Color.clear.onGeometryChange( + for: BarReport.self, + of: { BarReport(height: $0.size.height, accessories: accessories) } + ) { new in + report(new.height, new.accessories) } } // Sit on the host's bottom edge rather than in the middle of it. The two heights are @@ -493,7 +495,7 @@ private struct MeasuredBarHeight: ViewModifier { } /// One measurement of the bar: its height and the cards that make it up. -private struct BarReport: Equatable { +private nonisolated struct BarReport: Equatable, Sendable { let height: CGFloat let accessories: BarAccessories } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 6a75e2bcb..88e09ed97 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -155,12 +155,19 @@ private let replySpring = ChatMotion.replySurface.animation /// 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 + nonisolated static let fieldMinHeight: CGFloat = 34 + nonisolated static let fieldVerticalPadding: CGFloat = 8 + /// The field's padding on every side: where `+` stands in from the field's leading edge, and so + /// how far the open attach menu reaches back past `+` to line up with the field. + nonisolated static let fieldPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 + /// The composer field's corner, rounder than the bar's other controls. + nonisolated static let fieldCornerRadius: CGFloat = 28 /// 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 + nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. + nonisolated static let accessorySize: CGFloat = 34 /// The bar's own margin around its controls, above and below. static let contentPadding: CGFloat = 8 /// The margin between the bar's controls and the screen's sides while the keyboard is up. @@ -171,35 +178,57 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// What stands in the bar's leading slot, beside the message field. +/// What stands outside the message field, beside it. enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit /// The full-width Send Cash call to action, alone in the bar before the chat exists. case sendCash - /// The `+` menu with these rows. - case attach([AttachMenuItem]) - /// Nothing: the chat takes neither cash nor media from this user. + /// The round `$` beside the field once the chat exists, shown while the draft is empty. + case cash + /// Nothing beside the field. case none - /// Returns the control for the bar's state. An edit wins; before the chat exists the only - /// control is the Send Cash call to action; after, it is the attach menu, when it has a row. - init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool, acceptsMedia: Bool, attachedCount: Int) { + /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to + /// action before the chat exists and as the round `$` after. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { if isEditing { self = .cancelEdit - } else if !chatExists { - self = showsSendCash ? .sendCash : .none + } else if !showsSendCash { + self = .none } else { - let items = AttachMenuItem.items( - showsCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) - self = items.isEmpty ? .none : .attach(items) + self = chatExists ? .cash : .sendCash } } } +/// What the field's row holds beside the send button: the `+` menu. +struct ConversationBarBottomRow: Equatable { + /// The rows of the `+` menu; empty hides `+`. Never holds Cash, which the `$` beside the field + /// takes over. + let plusItems: [AttachMenuItem] + + /// A row with nothing in it. + static let empty = ConversationBarBottomRow(plusItems: []) + + /// Whether there is nothing for `+` to open. + var isEmpty: Bool { plusItems.isEmpty } + + /// Returns the row for the bar's state: empty during an edit and before the chat exists; + /// otherwise `+` while it has a row to open. + init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing, chatExists else { + self.init(plusItems: []) + return + } + self.init(plusItems: AttachMenuItem.items(showsCash: false, acceptsMedia: acceptsMedia, attachedCount: attachedCount)) + } + + private init(plusItems: [AttachMenuItem]) { + self.plusItems = plusItems + } +} + /// The unified bottom bar: the attach menu beside the message field. /// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { @@ -241,6 +270,20 @@ struct ConversationBottomBar: View { /// The composer row's measured height, reported as part of what the mention list's room is /// measured without. @State private var composerRowHeight: CGFloat = 0 + /// Whether the round `$` shows: while the draft is empty. Set in a transaction after the text + /// 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, .sendCash, .none: + 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 /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, @@ -264,6 +307,11 @@ struct ConversationBottomBar: View { /// The curve the bar narrows and widens on as the keyboard goes and comes. 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 + /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. + static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat @@ -299,6 +347,37 @@ struct ConversationBottomBar: View { } } + /// The glass behind the leading control and the field, 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 + private var composerGlass: some View { + let field = RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous) + let layout = HStack(alignment: .bottom, spacing: Self.leadingSpacing) { + switch leadingControl { + 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 .sendCash, .none: + EmptyView() + } + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .composerGlass(in: field, id: "field", namespace: composerGlassNamespace) + } + if #available(iOS 26, *) { + GlassEffectContainer(spacing: Self.leadingSpacing) { layout } + } else { + layout + } + } + private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft @@ -307,13 +386,12 @@ struct ConversationBottomBar: View { // sprang underneath it. let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { - HStack(alignment: .bottom, spacing: 10) { + HStack(alignment: .bottom, spacing: Self.leadingSpacing) { // An edit takes over the bar: the leading control becomes the way out of it and the - // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. + // field's bottom row steps aside until it resolves, the way WhatsApp hides its accessory controls. switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - .hiddenUnderAttachCard(showsCard) case .sendCash: SendCashMorphButton( symbol: symbol, @@ -330,38 +408,54 @@ struct ConversationBottomBar: View { expandedTitle: isTipDm ? startChattingTitle : nil, action: onSendCash ) - .hiddenUnderAttachCard(showsCard) - case .attach(let items): - AttachMenu( - items: items, - panel: model.attachPanel, - hidesButton: showsCard, + case .cash: + if cashIsShown { + ComposerCashButton(symbol: symbol, action: onSendCash) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + case .none: + EmptyView() + } + if chatExists { + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, // Swapped out in one frame for the surface, which is drawn as `+` where it stands. - isStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, - onOpen: onAttachOpen, - onPlusFrame: { model.overKeyboard.plusFrame = $0 } + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen ) - // Over the field beside it, which its panel floats across. + // Over the row's other controls, which the attach panel floats across. .zIndex(1) - case .none: - EmptyView() + .transition(.opacity) } + } + .background { if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) - .hiddenUnderAttachCard(showsCard) - .transition(.opacity) + composerGlass } } + // A draft already there when the chat opens hides `$` without animating it out. + .onAppear { cashIsShown = composer.draft.isEmpty } + .onChange(of: composer.draft.isEmpty) { _, isEmpty in + guard cashIsShown != isEmpty else { return } + withAnimation(Self.cashSpring) { cashIsShown = isEmpty } + } + .onChange(of: menuLeadingReach, initial: true) { _, reach in + model.overKeyboard.menuLeadingReach = reach + } // Kept mounted under the card, so `+` is there for the surface to shrink back into and the - // field is there to take focus the moment the card closes. Each control fades on its own - // rather than the row, which holds the surface. An open panel lets a touch on the row — + // field is there to take focus the moment the card closes. Left visible, not faded: the + // card grows over it and shrinks back onto it, so it is never seen missing. An open panel lets a touch on the row — // send included — fall through to the dismiss area behind the bar, so it only closes the panel. .allowsHitTesting(!showsCard && !model.attachPanel.isOpen) .accessibilityHidden(showsCard) // After the row's own opacity, so the surface does not fade with it. .overlay(alignment: .topLeading) { - if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, case .attach(let items) = leadingControl { - attachSurface(items: items) + if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, !bottomRow.plusItems.isEmpty { + attachSurface(items: bottomRow.plusItems) } } } @@ -552,19 +646,21 @@ struct ConversationBottomBar: View { /// Whether the camera should be warm: while the open panel offers it, or its card shows it. private var wantsCameraWarm: Bool { let showsCamera = model.attachCard.content == .camera - switch leadingControl { - case .attach(let items): - return showsCamera || (model.attachPanel.isOpen && items.contains(.camera)) - case .cancelEdit, .sendCash, .none: - return showsCamera - } + return showsCamera || (model.attachPanel.isOpen && bottomRow.plusItems.contains(.camera)) } private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, chatExists: chatExists, - showsSendCash: showsSendCash, + showsSendCash: showsSendCash + ) + } + + private var bottomRow: ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: composer.isEditing, + chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: composer.chips.count ) @@ -938,64 +1034,177 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel @Bindable var composer: ComposerModel + /// What the row holds beside the text: `+`. + var bottomRow = ConversationBarBottomRow.empty + /// Whether `+` is hidden under the camera or photo card. + var hidesPlus = false + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var plusIsStoodInFor = false + /// Fired as `+` opens the attach panel with the given rows. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + /// The text's and chips' inset from the field's leading edge. private static let leadingInset: CGFloat = 14 - private static let trailingInset: CGFloat = 8 + /// The stacked text's inset from the field's leading edge, matched to its inset from the top so + /// the first line sits evenly in the corner. + private static let stackedLeadingInset: CGFloat = 12 + /// The gap between the row's controls and the text. + private static let controlSpacing: CGFloat = 8 + /// The move between the one-row and stacked layouts. + /// The clip around the bar follows a shrink on `replySurface`, so the glass shares it. + private static let stackSpring = ChatMotion.replySurface.animation + + /// Whether the text spans the field with the controls in a row under it. + @State private var isStacked = false + /// The text's own layout, switched without animation: an animated width re-lays the text + /// every frame, and UIKit chases each new caret position on its own timing. + @State private var textIsStacked = false + /// The text's offset from its new place, sprung back to zero so it travels as one piece. + @State private var textShift: CGFloat = 0 + /// Whether send is up; follows `showsSubmit` in its own transaction so the pop animates. + @State private var submitIsShown = false + /// The text field's width while it sits inline between the controls. + /// The row's width, which stacking does not change, so the inline text width can be derived + /// in either layout rather than measured from a field that is about to move. + @State private var rowWidth: CGFloat = 0 + /// The draft's width laid out on one line. + @State private var draftLineWidth: CGFloat = 0 + + private var hasPlus: Bool { !bottomRow.plusItems.isEmpty } + + private var inlineLeadingPadding: CGFloat { + hasPlus ? BarMetrics.accessorySize + Self.controlSpacing : Self.leadingInset - BarMetrics.fieldPadding + } + + private func textLeadingPadding(stacked: Bool) -> CGFloat { + stacked ? Self.stackedLeadingInset - BarMetrics.fieldPadding : inlineLeadingPadding + } + + private var inlineTrailingPadding: CGFloat { BarMetrics.accessorySize + Self.controlSpacing } + + /// Stacks once the draft wraps or takes a newline, and unstacks only when it is cleared, so + /// deleting back under one line does not bounce the layout. + private func updateStacking() { + let stacked = Self.stacks( + draft: composer.draft, + wasStacked: textIsStacked, + draftLineWidth: draftLineWidth, + inlineWidth: rowWidth > 0 ? rowWidth - inlineLeadingPadding - inlineTrailingPadding : 0 + ) + // Its own transaction, after the text update has landed: the whole bar follows the field up + // or down, while the text update itself stays unanimated. + guard stacked != textIsStacked else { return } + let shift = textLeadingPadding(stacked: textIsStacked) - textLeadingPadding(stacked: stacked) + var snap = Transaction() + snap.disablesAnimations = true + withTransaction(snap) { + textIsStacked = stacked + textShift = shift + } + // A frame later, so the shift has rendered before it springs back with the bar. + Task { @MainActor in + withAnimation(Self.stackSpring) { + isStacked = stacked + textShift = 0 + } + } + } + + /// Whether the composer stacks for `draft`, given whether it was stacked and the draft's + /// one-line width against the inline field's. + nonisolated static func stacks(draft: String, wasStacked: Bool, draftLineWidth: CGFloat, inlineWidth: CGFloat) -> Bool { + guard !draft.isEmpty else { return false } + if wasStacked || draft.contains(where: \.isNewline) { return true } + return inlineWidth > 0 && draftLineWidth > inlineWidth + } @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let motion = AttachMotion(reduceMotion: reduceMotion) - let textRow = HStack(alignment: .bottom, spacing: 10) { - TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) - .font(.appTextMessage) - .foregroundStyle(Color.textMain) - .tint(.white) - .lineLimit(1...5) - .focused($isFocused) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: BarMetrics.fieldMinHeight) - // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the - // moment there is a draft, so a test that types and then reads the field back finds - // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a - // text-field automation type, so the query has to be identifier-based, not type-based. - .accessibilityIdentifier("composer-message-field") - - // The spring is scoped to the button, not to the row. On the row it took the field - // into the transaction as well, and `showsSubmit` falls on the same update that empties - // the draft — so the field's text update ran as an animated one against its text view, - // where it can be coalesced away. That leaves the sent text on screen with the binding - // already empty, and an unchanged binding never pushes it again. - Group { - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) + let textField = TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .tint(.white) + .lineLimit(1...5) + .focused($isFocused) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: BarMetrics.fieldMinHeight) + // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the + // moment there is a draft, so a test that types and then reads the field back finds + // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a + // text-field automation type, so the query has to be identifier-based, not type-based. + .accessibilityIdentifier("composer-message-field") + + // One row: `+`, the text, `$` until there is a draft, and send. Once the text wraps or takes + // a newline the row stacks: the text spans the field and the controls drop to a row under + // it, until the draft is cleared. The text field never moves in the hierarchy, only its + // insets do, so it keeps focus and the keyboard through the change. + let controls = HStack(alignment: .bottom, spacing: 0) { + if hasPlus { + AttachMenu( + items: bottomRow.plusItems, + panel: model.attachPanel, + hidesButton: hidesPlus, + isStoodInFor: plusIsStoodInFor, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } + ) + } + Spacer(minLength: 0) + // The open menu covers this row; only `+` stays, for the menu to collapse back into. + sendButton + .animation(Self.sendButtonSpring) { + $0.opacity(model.attachPanel.isOpen ? 0 : 1) } + } + + let row = ZStack(alignment: .bottomLeading) { + textField + .background(alignment: .leading) { + // The draft's width on one line, to tell when the inline field would wrap. + Text(composer.draft.isEmpty ? " " : composer.draft) + .font(.appTextMessage) + .lineLimit(1) + .fixedSize() + .hidden() + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + draftLineWidth = width + updateStacking() + } + } + // The text snaps to its new layout and `textShift` carries it there on the bar's spring. + .padding(.leading, textLeadingPadding(stacked: textIsStacked)) + .padding(.trailing, textIsStacked ? 0 : inlineTrailingPadding) + .offset(x: textShift) + // Position only, so it can spring with the bar without re-laying the text. + .padding(.bottom, isStacked ? BarMetrics.accessorySize + BarMetrics.fieldVerticalPadding : 0) + controls + } + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + rowWidth = width + updateStacking() + } + .onChange(of: composer.draft) { updateStacking() } + // An empty field is small beside the space send will take; a tap anywhere right of `+`, + // out to the field's edges, focuses it. Behind the row, so `+` keeps its own taps. + .background { + if composer.draft.isEmpty { + Color.clear + .padding(.vertical, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } } - .animation(Self.sendButtonSpring, value: showsSubmit) } - // The staged photos ride inside the field, above the text, so the field reads as one message. - let field = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { + // The staged photos ride inside the field, above the row, so the field reads as one message. + let content = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { ComposerChipStrip( chips: composer.chips, @@ -1006,22 +1215,18 @@ struct ConversationComposer: View { edgeInset: Self.leadingInset ) // Out to the field's own edges, so chips scroll under a fade rather than a hard margin. - .padding(.leading, -Self.leadingInset) - .padding(.trailing, -Self.trailingInset) + .padding(.leading, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) } - textRow + row } - return field - .padding(.leading, Self.leadingInset) - .padding(.trailing, Self.trailingInset) - .padding(.vertical, BarMetrics.fieldVerticalPadding) - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + return content + .padding(BarMetrics.fieldPadding) + // The field's glass is drawn by the bar, behind the whole row, so `$` can split from it. + .composerRim(in: RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous)) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -1037,6 +1242,45 @@ struct ConversationComposer: View { } } + /// The confirm button. The spring is scoped to it, not to the row it sits in. + private var sendButton: some View { + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + // A ZStack, not a Group: a Group hands its modifiers to its children, so with the button + // gone the slot's frame and the callbacks below would attach to nothing. + ZStack { + if submitIsShown { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(Color.white, in: Circle()) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + // The slot stays, so the text does not reflow as the button comes and goes. + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + // Its own transaction after the text update: an implicit animation keyed on `showsSubmit` + // rode the text field's update, which carries no animation, so the pop never played. + .onAppear { submitIsShown = showsSubmit } + .onChange(of: showsSubmit) { _, shows in + withAnimation(Self.sendButtonSpring) { submitIsShown = shows } + } + } + /// The hint names what the field will send. An edit arrives with the existing text already in /// the field, so its hint is never on screen and stays the new-message one. private var fieldPrompt: String { @@ -1107,6 +1351,27 @@ struct ConversationComposer: View { /// The way out of an edit: the bar's leading control while the field holds an existing message, /// standing where Send Cash stands the rest of the time. Field-sized and glass, so the swap reads /// as the same control changing job rather than a foreign button arriving. +/// The round `$` beside the field. Its glass is drawn by the bar, joined to the field's. +private struct ComposerCashButton: View { + + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(symbol) + .font(.appTextXL) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .composerRim(in: Circle()) + .accessibilityLabel("Send Cash") + .accessibilityIdentifier("send-cash-button") + } +} + private struct CancelEditButton: View { let onCancel: () -> Void diff --git a/FlipcashTests/Chat/AttachOverlayModeTests.swift b/FlipcashTests/Chat/AttachOverlayModeTests.swift index 61012b968..02359f029 100644 --- a/FlipcashTests/Chat/AttachOverlayModeTests.swift +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -81,13 +81,15 @@ struct AttachOverlayModeTests { #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: false, canReplaceInputView: true) == .inputView) } - @Test("The panel straddles the composer's bottom edge and grows out of +") + @Test("The panel straddles the composer's bottom edge, clears its top, and grows out of +") func panelStraddlesTheComposerEdge() { let plus = CGRect(x: 12, y: 500, width: 50, height: 50) let size = CGSize(width: 240, height: 144) let frame = AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) - #expect(frame.minX == plus.minX) - #expect(frame.midY == plus.maxY) + #expect(frame.minX == plus.minX - BarMetrics.fieldPadding) + #expect(frame.minY < frame.maxY && frame.minY <= plus.maxY && frame.maxY >= plus.maxY) + let fieldTop = plus.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + #expect(frame.minY <= fieldTop - AttachOverlayLayout.fieldTopOverhang) let anchor = AttachOverlayLayout.panelAnchor(plusFrame: plus, size: size) #expect(frame.minX + anchor.x * size.width == plus.midX) diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index ab69196ff..9a54eb6f4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -76,9 +76,10 @@ struct AttachPanelHost { /// The bar's measured height, as last reported to the screen. var barHeight: CGFloat { screenBox.barHeight } - /// The window-space point `distance` above the bar's top edge, over `+`'s column. + /// The window-space point `distance` above the bar's top edge, over `+`'s column: `+` is the + /// first control in the field's bottom row, past the compact bar's margin and the field's padding. func pointAboveBar(by distance: CGFloat) -> CGPoint { - CGPoint(x: 40, y: window.bounds.height - barHeight - distance) + CGPoint(x: model.overKeyboard.plusFrame.midX, y: window.bounds.height - barHeight - distance) } /// The window's rendered colour at `point`. @@ -158,7 +159,7 @@ struct AttachPanelPlacementTests { // Inside the panel's rows, above the bar: before the fix the panel hung down from `+`'s top // and nothing of the bar reached here. let point = host.pointAboveBar(by: 40) - let transcript = host.renderedColor(at: CGPoint(x: 40, y: 100), backdrop: .black) + let transcript = host.renderedColor(at: CGPoint(x: point.x, y: 100), backdrop: .black) let probe = host.renderedColor(at: point, backdrop: .black) #expect(!probe.isClose(to: transcript), "Panel is not drawn above the bar") @@ -167,6 +168,28 @@ struct AttachPanelPlacementTests { #expect(hit.isDescendant(of: host.barHost.view), "Taps above the bar miss the panel") } + @Test("The bar is one row tall: +, the text, and the controls beside it") + func barHeight_isOneRow() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let expected = BarMetrics.contentHeight + BarMetrics.contentPadding * 2 + #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") + func menu_linesUpWithField() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + 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 + #expect(abs(menu.minX - field) < 1, "Menu starts at \(menu.minX), the field at \(field)") + } + @Test("The card stands on the composer over the transcript, takes taps on itself, and lets taps above it through") func card_overlaysTranscript() async throws { let host = try AttachPanelHost() diff --git a/FlipcashTests/Chat/AttachPanelTests.swift b/FlipcashTests/Chat/AttachPanelTests.swift index 72c8bf029..4634660f0 100644 --- a/FlipcashTests/Chat/AttachPanelTests.swift +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import UIKit import FlipcashUI @testable import Flipcash @@ -170,6 +171,21 @@ struct AttachSurfaceTests { #expect(!model.attachSurfaceIsMounted) } + @Test("A landing holds the staged photo from the hand-off until it has landed") + func landingHoldsImage() { + let model = ConversationBarModel() + model.attachCard.open(.camera, screenHeight: 874) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3)).image { _ in } + + model.attachCard.beginLanding(on: UUID(), image: image) + #expect(model.attachCard.landingImage === image) + model.attachCard.close() + #expect(model.attachCard.landingImage === image, "The surface still draws it as it shrinks") + + model.attachCard.endLanding() + #expect(model.attachCard.landingImage == nil) + } + @Test("Only the menu shows rows, and only a card shows card content") func contentPerPhase() { #expect(AttachSurfacePhase.menu.showsRows) @@ -188,7 +204,8 @@ struct AttachSurfaceTests { let chip = CGRect(x: 20, y: 640, width: 56, height: 56) let collapsed = AttachSurfaceLayout.shape(for: .collapsed, plus: plus, menu: menu, card: card, landing: chip) - #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: AttachSurfaceLayout.plusCornerRadius)) + #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: plus.height / 2)) + #expect(AttachSurfaceLayout.collapsedCornerRadius(plus: plus) == 22) let open = AttachSurfaceLayout.shape(for: .menu, plus: plus, menu: menu, card: card, landing: nil) #expect(open == AttachSurfaceShape(rect: menu, cornerRadius: AttachSurfaceLayout.menuCornerRadius)) let carded = AttachSurfaceLayout.shape(for: .card(.camera), plus: plus, menu: menu, card: card, landing: nil) @@ -204,9 +221,23 @@ struct AttachSurfaceTests { let plus = CGRect(x: 16, y: 700, width: 44, height: 44) let size = CGSize(width: 240, height: 144) let standing = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .standsOnPlus) - #expect(standing == CGRect(x: 16, y: 600, width: 240, height: 144)) + #expect(standing == CGRect(x: 16 - BarMetrics.fieldPadding, y: 600 + BarMetrics.fieldPadding, width: 240, height: 144)) + #expect(standing.minX == plus.minX - BarMetrics.fieldPadding, "The menu lines up with the field's edge") + #expect(standing.maxY == plus.maxY + BarMetrics.fieldPadding, "The menu covers the field's bottom edge") let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + #expect(straddling.minX == plus.minX - BarMetrics.fieldPadding, "The over-keyboard menu lines up with the field's edge") + } + + @Test("With $ beside the field the menu moves out over it at the same width", arguments: [AttachMenuPlacement.standsOnPlus, .straddlesPlus]) + func menuReachesOverCash(placement: AttachMenuPlacement) { + let plus = CGRect(x: 60, y: 700, width: 34, height: 34) + let size = CGSize(width: 280, height: 144) + let aligned = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement) + let reaching = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement, leadingReach: 44) + #expect(reaching.minX == aligned.minX - 44) + #expect(reaching.width == aligned.width) + #expect(reaching.minY == aligned.minY && reaching.height == aligned.height) } @Test("The bar's card stands on the row's bottom edge, out to the keyboard-up margin") diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index b8379cf2a..9f6471cb9 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -106,6 +106,24 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow + secondLine) } + @Test("A composer shrinking holds the clip until its spring is done, and reserves the new room at once") + func composerShrink_holdsTheClip() async throws { + let (screen, bar, window) = makeScreen() + let stacked = composerRow + 48 + + report(screen, composerRow) + report(screen, stacked) + let transcript = try #require(screen.view.firstSubview(of: UICollectionView.self)) + let insetBefore = transcript.contentInset.bottom + + report(screen, composerRow) + #expect(transcript.contentInset.bottom == insetBefore - 48) + #expect(clipHeight(bar, window) == stacked, "The clip keeps the text and caret uncovered on the way down") + + try await Task.sleep(for: .seconds(ChatMotion.replySurface.duration + 0.2)) + #expect(clipHeight(bar, window) == composerRow) + } + @Test("The mention list stacks over an open reply and the clip reaches the whole bar") func mentionsOverReply_clipReachesTheWholeBar() { let (screen, bar, window) = makeScreen() @@ -184,3 +202,12 @@ struct ChatScreenBarClipTests { #expect(rooms.last.map { $0 - strip } == withStrip) } } + +private extension UIView { + func firstSubview(of type: T.Type) -> T? { + for subview in subviews { + if let match = subview as? T ?? subview.firstSubview(of: type) { return match } + } + return nil + } +} diff --git a/FlipcashTests/Chat/ComposerStackingTests.swift b/FlipcashTests/Chat/ComposerStackingTests.swift new file mode 100644 index 000000000..41fe2de16 --- /dev/null +++ b/FlipcashTests/Chat/ComposerStackingTests.swift @@ -0,0 +1,44 @@ +// +// ComposerStackingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import Flipcash + +@Suite("Composer stacking") +struct ComposerStackingTests { + + @Test("A draft that fits on one line stays inline") + func fitsInline() { + #expect(!ConversationComposer.stacks(draft: "Hi", wasStacked: false, draftLineWidth: 20, inlineWidth: 200)) + } + + @Test("A draft wider than the inline field stacks") + func wrapStacks() { + #expect(ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 201, inlineWidth: 200)) + } + + @Test("A newline stacks, however short the draft") + func newlineStacks() { + #expect(ConversationComposer.stacks(draft: "H\n", wasStacked: false, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Deleting back under one line stays stacked") + func staysStacked() { + #expect(ConversationComposer.stacks(draft: "H", wasStacked: true, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Clearing the draft unstacks") + func clearUnstacks() { + #expect(!ConversationComposer.stacks(draft: "", wasStacked: true, draftLineWidth: 0, inlineWidth: 200)) + } + + @Test("Before the field is measured, only a newline stacks") + func unmeasured() { + #expect(!ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 500, inlineWidth: 0)) + } +} diff --git a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift index a0f792d3f..c166c3969 100644 --- a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift +++ b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift @@ -15,51 +15,82 @@ struct ConversationBarLeadingControlTests { private func control( isEditing: Bool = false, chatExists: Bool = true, - showsSendCash: Bool = true, - acceptsMedia: Bool = true, - attachedCount: Int = 0 + showsSendCash: Bool = true ) -> ConversationBarLeadingControl { - ConversationBarLeadingControl( - isEditing: isEditing, - chatExists: chatExists, - showsSendCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) + ConversationBarLeadingControl(isEditing: isEditing, chatExists: chatExists, showsSendCash: showsSendCash) } - @Test("Before the chat exists, Send Cash stays the full-width call to action", arguments: [true, false]) - func callToActionBeforeChatExists(acceptsMedia: Bool) { - #expect(control(chatExists: false, acceptsMedia: acceptsMedia) == .sendCash) + @Test("Before the chat exists, Send Cash stays the full-width call to action") + func callToActionBeforeChatExists() { + #expect(control(chatExists: false) == .sendCash) } - @Test("Before the chat exists with nothing to send, the slot is empty") + @Test("Before the chat exists with nothing to send, nothing stands outside the field") func emptyBeforeChatExistsWithoutCash() { #expect(control(chatExists: false, showsSendCash: false) == .none) } - @Test("Once the chat exists, the slot is the attach menu with every row") - func attachMenuOnceChatExists() { - #expect(control() == .attach([.cash, .camera, .photos])) + @Test("Once the chat exists, Send Cash is the round `$` beside the field") + func cashOnceChatExists() { + #expect(control() == .cash) } - @Test("An E2EE chat keeps the attach menu with Cash alone") - func attachMenuCashOnlyWithoutMedia() { - #expect(control(acceptsMedia: false) == .attach([.cash])) + @Test("Once the chat exists with nothing to send, nothing stands beside the field") + func noneOnceChatExistsWithoutCash() { + #expect(control(showsSendCash: false) == .none) } - @Test("A chat that takes neither cash nor media leaves the slot empty") - func emptyWithoutCashOrMedia() { - #expect(control(showsSendCash: false, acceptsMedia: false) == .none) + @Test("An edit takes the slot whatever else is true", arguments: [true, false]) + func cancelWhileEditing(chatExists: Bool) { + #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) } +} + +@MainActor +@Suite("Conversation bar bottom row") +struct ConversationBarBottomRowTests { - @Test("A full composer in a chat without cash leaves the slot empty") - func emptyWhenFullWithoutCash() { - #expect(control(showsSendCash: false, attachedCount: ComposerModel.maxAttachments) == .none) + private func row( + isEditing: Bool = false, + chatExists: Bool = true, + acceptsMedia: Bool = true, + attachedCount: Int = 0 + ) -> ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: isEditing, + chatExists: chatExists, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) } - @Test("An edit takes the slot whatever else is true", arguments: [true, false]) - func cancelWhileEditing(chatExists: Bool) { - #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + @Test("Where the chat takes media, the row holds `+` with the camera and photos") + func plus() { + let row = row() + #expect(row.plusItems == [.camera, .photos]) + #expect(!row.isEmpty) + } + + @Test("Cash is never a menu row: the `$` beside the field takes it") + func cashLeavesTheMenu() { + #expect(!row().plusItems.contains(.cash)) + #expect(!row(acceptsMedia: false).plusItems.contains(.cash)) + } + + @Test("With no menu rows left, `+` goes", arguments: [false, true]) + func noPlus(isFull: Bool) { + let row = row(acceptsMedia: isFull, attachedCount: isFull ? ComposerModel.maxAttachments : 0) + #expect(row.plusItems.isEmpty) + #expect(row.isEmpty) + } + + @Test("An edit hides the bottom row") + func hiddenWhileEditing() { + #expect(row(isEditing: true).isEmpty) + } + + @Test("Before the chat exists there is no bottom row") + func hiddenBeforeChatExists() { + #expect(row(chatExists: false).isEmpty) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift index 3e0873de5..bb6f31551 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -8,7 +8,7 @@ import CoreGraphics /// The cards stacked above the composer row, as the bar reports them alongside its height. -public struct BarAccessories: Equatable, Sendable { +public nonisolated struct BarAccessories: Equatable, Sendable { /// A card that can stand above the composer row. public enum Kind: Hashable, Sendable { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index ad5c7a0c1..0e559c536 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -780,6 +780,10 @@ public final class ChatScreenViewController: UIViewController { holdForInPlaceResize(clipHeight: clipHeight) return } + if clipHeight < previousClip, !toggled, !isFirst, view.window != nil { + holdForShrink(clipHeight: clipHeight) + return + } transcriptCover = nil fadeTopConstraint?.constant = 0 @@ -804,6 +808,33 @@ public final class ChatScreenViewController: UIViewController { } } + /// Shrinks the bar's room for a composer folding back to one row or dropping its chips. + /// + /// The transcript takes the new inset in this frame, unanimated, so a send's insertion moves its + /// rows on its own spring alone. The clip stays at its old height until the glass's spring is + /// done: cut at once, it would hide the text and caret still travelling down to their place. + private func holdForShrink(clipHeight: CGFloat) { + let heldClip = barClipHeightConstraint.constant + transcriptCover = clipHeight + fadeTopConstraint.constant = heldClip - clipHeight + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + let height = measuredBarHeight + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.replySurface.duration) { [weak self] in + // A later report may already have moved the bar on; only the one still current lands. + guard let self, self.measuredBarHeight == height, + self.barClipHeightConstraint.constant == heldClip else { return } + self.barClipHeightConstraint.constant = clipHeight + self.transcriptCover = nil + self.fadeTopConstraint.constant = 0 + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } + } + } + /// Makes room for a change the bar draws itself, without animating anything. /// /// The glass grows and shrinks on its own spring, its bottom on the composer, and a second diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 8ccb29c49..8a2217c80 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,18 @@ extension View { } } + /// Dark-tinted glass for a control floating over a photo or viewfinder, where clear glass takes + /// on the image behind it and washes the glyph out. + @ViewBuilder + public func overlayGlassBackground(in shape: some Shape) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(.black.opacity(0.45)).interactive(), in: shape) + } else { + background(Color.black.opacity(0.45), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + /// The app's glass surface for a panel that holds its own buttons: Liquid Glass on iOS 26, an /// ultra-thin material below. Non-interactive, because `.interactive()` on the container competes /// with its buttons for the tap. @@ -73,4 +85,34 @@ extension View { background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) } } + + /// The chat composer's glass in `shape`, as a layer of its own: Liquid Glass with a light white + /// tint, joined by `id` to the other composer glass in an enclosing container. A tinted material + /// before iOS 26. + @ViewBuilder + public func composerGlass(in shape: some Shape, id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(Color.white.opacity(0.03)).interactive(), in: shape) + .glassEffectID(id, in: namespace) + } else { + background(Color.white.opacity(0.03), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + + /// The chat composer's light rim around `shape`, brightest along the top. + public func composerRim(in shape: some InsettableShape) -> some View { + overlay { + shape + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.18), Color.white.opacity(0.04)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } }