diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index b8c6f17ba..dc418b041 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -538,12 +538,12 @@ public struct ChatCashContent: Hashable, Sendable, Codable { } /// A return of a DM's opening payment: "You sent back" / "to Alex" to its sender, "Alex sent - /// back" / "to you" to its receiver. An unknown name reads "Someone". + /// back" / "to You" to its receiver. An unknown name reads "Someone". public static func sentBack(isFromSelf: Bool, counterpartName: String) -> Lines { let name = counterpartName.isEmpty ? "Someone" : counterpartName return isFromSelf ? .init(caption: "You sent back", counterpart: "to \(name)") - : .init(caption: "\(name) sent back", counterpart: "to you") + : .init(caption: "\(name) sent back", counterpart: "to You") } } diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index 65ee64135..e7805c7ff 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -61,13 +61,79 @@ struct ChatCashCardCellTests { } } - @Test("The Send Back row is 44 tall under the card's padding; the Sent Back footer is 36 in place of it") + @Test("The Send Back row is 44 and the Sent Back footer 36, both under the card's bottom padding") func sendBackRow_sizes() { let plain = measure(offered(nil)) #expect((measure(offered(.available)) - plain).rounded() == 44) #expect((measure(offered(.sending)) - plain).rounded() == 44) - // 36 for the footer, less the 16 bottom padding it replaces. - #expect((measure(offered(.sentBack)) - plain).rounded() == 20) + #expect((measure(offered(.sentBack)) - plain).rounded() == 36) + } + + @Test("Only the Sent Back footer draws its top divider") + func sendBackRow_dividerOnFooterOnly() { + let row = SendBackRowView() + row.apply(.available) + #expect(!row.isDividerVisible) + row.apply(.sending) + #expect(!row.isDividerVisible) + row.apply(.sentBack) + #expect(row.isDividerVisible) + row.apply(.available) + #expect(!row.isDividerVisible) + } + + /// The card's laid-out width at `maxWidth`, as the transcript sizes it. + private func cardWidth(_ message: ChatMessage, maxWidth: CGFloat) throws -> CGFloat { + let cell = ChatCashCardCell(frame: CGRect(x: 0, y: 0, width: Self.width, height: 400)) + cell.frame.size.height = measure(message, in: cell, maxWidth: maxWidth) + cell.layoutIfNeeded() + let amount = try #require(labels(in: cell.contentView).first { $0.attributedText?.string == "$5.00" }) + let card = try #require(amount.superview?.superview) + // The row spans the card edge to edge, never past it. + if let row = card.subviews.first(where: { $0 is SendBackRowView }), !row.isHidden { + #expect(row.frame.minX == 0 && row.frame.width == card.bounds.width) + } + return card.bounds.width + } + + @Test("The Send Back row and footer never widen the card past its width", arguments: [189.44, 250.0]) + func sendBackRow_keepsCardWidth(maxWidth: CGFloat) throws { + let plain = try cardWidth(offered(nil), maxWidth: maxWidth) + // Layout snaps the card to the screen's pixel grid, so 189.44 lays out as 189.33 on 3x. + #expect(abs(plain - maxWidth) <= 1 / UIScreen.main.scale) + for state in [ChatCashContent.SendBack.available, .sending, .sentBack] { + #expect(try cardWidth(offered(state), maxWidth: maxWidth) == plain) + } + } + + @Test("A row that arrives on a drawn card animates in; one known at first draw does not") + func sendBackRow_animatesOnlyLateArrivals() { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: offered(.sentBack), maxWidth: 250) + #expect(!cell.didAnimateSendBackIn, "first draw") + cell.configure(with: offered(.sentBack), maxWidth: 250) + #expect(!cell.didAnimateSendBackIn, "same state redrawn") + + let late = ChatCashCardCell(frame: .zero) + late.configure(with: offered(nil), maxWidth: 250) + late.configure(with: offered(.available), maxWidth: 250) + #expect(late.didAnimateSendBackIn, "row arrives after the status check") + late.configure(with: offered(.sentBack), maxWidth: 250) + #expect(late.didAnimateSendBackIn, "footer replaces the row") + + late.prepareForReuse() + late.configure(with: offered(.available), maxWidth: 250) + #expect(!late.didAnimateSendBackIn, "reused cell draws fresh") + } + + @Test("Only the same message gaining a different, shown state counts as a late arrival") + func sendBackArrivesLate_rules() { + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: nil, id: "3", state: .available)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("2", nil), id: "3", state: .available)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .available), id: "3", state: nil)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .available), id: "3", state: .available)) + #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", nil), id: "3", state: .available)) + #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .sending), id: "3", state: .sentBack)) } @Test("A cell recycled from an offered card to a plain one drops the row") diff --git a/FlipcashTests/Chat/ChatSendBackStageTests.swift b/FlipcashTests/Chat/ChatSendBackStageTests.swift deleted file mode 100644 index b2f7b99e0..000000000 --- a/FlipcashTests/Chat/ChatSendBackStageTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ChatSendBackStageTests.swift -// FlipcashTests -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Testing -import FlipcashCore -@testable import FlipcashUI - -/// A Send Back row or footer appears without animating its bubble: the transcript applies those -/// cards' changes first, in place and with animation off, before the rest of the push. -@MainActor -@Suite("Chat Send Back stage") -struct ChatSendBackStageTests { - - private func cash(_ id: String, _ sendBack: ChatCashContent.SendBack?, sender: ChatMessage.Sender = .other) -> ChatItem { - .message(ChatMessage(id: id, content: .cash(ChatCashContent( - amount: "$5.00", token: "Cash", - lines: .init(caption: "Noah Parker sent", counterpart: "to you"), - sendBack: sendBack - )), sender: sender)) - } - - private func text(_ id: String) -> ChatItem { - .message(ChatMessage(id: id, text: "text-\(id)", sender: .me)) - } - - @Test("The status check resolving to Available stages the whole push, which reconfigures in place") - func rowAppears_stagesWholePush() { - let before = [cash("a", nil), text("b")] - let after = [cash("a", .available), text("b")] - #expect(ChatViewController.sendBackStage(from: before, to: after) == after) - } - - @Test("A return arriving with the original's footer stages the footer alone, keeping the old rows") - func footerWithArrival_stagesFooterOnly() { - let before = [cash("a", nil, sender: .me)] - let after = [cash("a", .sentBack, sender: .me), cash("r", nil)] - #expect(ChatViewController.sendBackStage(from: before, to: after) == [cash("a", .sentBack, sender: .me)]) - } - - @Test("Sending turning to Sent Back, a resize, is staged") - func sendingToSentBack_isStaged() { - #expect(ChatViewController.sendBackStage(from: [cash("a", .sending)], to: [cash("a", .sentBack)]) != nil) - } - - @Test("A push with no Send Back change stages nothing") - func otherChange_stagesNothing() { - let before = [cash("a", .available), text("b")] - let after = [cash("a", .available), text("b"), text("c")] - #expect(ChatViewController.sendBackStage(from: before, to: after) == nil) - } -} diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index 2eb760537..eccf57c9c 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -132,11 +132,17 @@ struct SendBackCaptionTests { #expect(line?.counterpart == "to Alex Morgan") } - @Test("9: the receiver of a return reads ' sent back' / 'to you'") + @Test("9: the receiver of a return reads ' sent back' / 'to You'") func returnReceiver() { let line = lines(cash(2, from: alex, to: me, returning(1))) #expect(line?.caption == "Alex Morgan sent back") - #expect(line?.counterpart == "to you") + #expect(line?.counterpart == "to You") + } + + @Test("Ordinary received cash keeps its own counterpart, not the return's 'to You'") + func ordinaryReceived_unchanged() { + let line = lines(cash(1, from: alex, to: me)) + #expect(line?.counterpart == "from Alex Morgan") } @Test("A return draws no Send Back row of its own") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 5252844e6..75bb13352 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -48,6 +48,11 @@ public final class ChatCashCardCell: ChatColumnCell { private let counterpartLabel = UILabel() private let centerStack = UIStackView() private let sendBackRow = SendBackRowView() + /// The message and Send Back state this cell last drew, so a row that arrives on a card already + /// on screen can animate in while one known at first draw does not. + private var drawnSendBack: (id: String, state: ChatCashContent.SendBack?)? + /// Whether the last configure faded the Send Back row in; read by tests. + private(set) var didAnimateSendBackIn = false /// The counterpart line's gap to the card's foot, or to the Send Back row when one is drawn. private var counterpartToCardBottom: NSLayoutConstraint! private var counterpartToSendBackRow: NSLayoutConstraint! @@ -184,6 +189,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.cancelDownloadTask() coinIcon.image = nil reactionRow.prepareForReuse() + drawnSendBack = nil } /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection @@ -207,7 +213,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.setImage(with: cash.iconURL) counterpartLabel.text = cash.lines?.counterpart counterpartLabel.isHidden = cash.lines == nil - applySendBack(cash.sendBack) + applySendBack(cash.sendBack, id: message.id) card.apply( fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), @@ -229,13 +235,34 @@ public final class ChatCashCardCell: ChatColumnCell { card.alpha = isHighlighted ? 0.6 : 1 } + /// Whether `state` arrives on a card this cell has already drawn for message `id`: the same + /// message, a different Send Back state, and a row to show. A first draw, or a cell reused for + /// another message, has nothing to animate from. + static func sendBackArrivesLate( + drawn: (id: String, state: ChatCashContent.SendBack?)?, + id: String, + state: ChatCashContent.SendBack? + ) -> Bool { + guard let drawn, drawn.id == id, let state else { return false } + return drawn.state != state + } + /// Shows the Send Back row for `state`, or none. Read from the message on every configure, so a /// reused cell never keeps another row's spinner. - private func applySendBack(_ state: ChatCashContent.SendBack?) { + /// + /// A row or footer that arrives on a card already drawn (the status check resolving, a return + /// landing) fades in while the transcript's reflow spring grows the card to fit it. One known + /// when the card is first drawn appears with no animation. + private func applySendBack(_ state: ChatCashContent.SendBack?, id: String) { + let arrivesLate = Self.sendBackArrivesLate(drawn: drawnSendBack, id: id, state: state) + drawnSendBack = (id, state) + didAnimateSendBackIn = arrivesLate sendBackRow.isHidden = state == nil if let state { sendBackRow.apply(state) } - // The Sent Back footer replaces the card's bottom padding; the action row sits under it. - counterpartToSendBackRow.constant = state == .sentBack ? 0 : -Self.verticalInset + if arrivesLate { + UIView.performWithoutAnimation { sendBackRow.alpha = 0 } + ChatMotion.reflow.animate { [sendBackRow] in sendBackRow.alpha = 1 } + } // Deactivate before activating, so the two never hold the label at once. counterpartToCardBottom.isActive = false counterpartToSendBackRow.isActive = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b3f38feb4..2c0bac47c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -540,13 +540,6 @@ public final class ChatViewController: UICollectionViewController { return } - // A Send Back row or footer appears without animating its bubble (send-back spec): those - // cards change first, in a pass of their own with animation off, and anything else in this - // push (the return message arriving, say) then rides the spring as usual. - if let staged = Self.sendBackStage(from: items, to: newItems) { - applyWithoutAnimation(staged) - } - let changeset = StagedChangeset(source: items, target: newItems) guard !changeset.isEmpty else { items = newItems @@ -687,38 +680,6 @@ public final class ChatViewController: UICollectionViewController { /// - Parameter old: the rows before the update. When the update only removes the group-ready /// card, the rows below slide into its space on `inviteCardExit`: an arrival's bounce would read /// as the conversation lurching rather than settling. - /// `old` with every cash card whose Send Back state differs in `new` swapped for its `new` - /// version, or `nil` when no card's Send Back state changed. Same rows in the same order as - /// `old`, so applying it is an in-place reconfigure only. - static func sendBackStage(from old: [ChatItem], to new: [ChatItem]) -> [ChatItem]? { - guard !old.isEmpty else { return nil } - let newByID = Dictionary(new.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - var changed = false - let staged = old.map { item -> ChatItem in - guard case .message(let before) = item, case .cash(let beforeCash) = before.content, - let next = newByID[item.id], case .message(let after) = next, case .cash(let afterCash) = after.content, - beforeCash.sendBack != afterCash.sendBack - else { return item } - changed = true - return next - } - return changed ? staged : nil - } - - /// Reconfigures rows in place with UIKit animation off, so a resized card snaps to its new height. - private func applyWithoutAnimation(_ staged: [ChatItem]) { - let changeset = StagedChangeset(source: items, target: staged) - guard !changeset.isEmpty else { return } - UIView.performWithoutAnimation { - collectionView.reload( - using: changeset, - reconfiguring: { [weak self] in self?.isReconfiguring = $0 }, - setData: { [weak self] data in self?.items = data } - ) - collectionView.layoutIfNeeded() - } - } - static func batchSpring(for changeset: StagedChangeset<[ChatItem]>, from old: [ChatItem] = []) -> ChatSpring { if removesOnlyInviteCard(changeset, from: old) { return ChatMotion.inviteCardExit } let changesRows = changeset.contains { stage in diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift index c9727a462..0d6cbdf8a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -11,13 +11,14 @@ import SwiftUI import FlipcashCore /// The full-width row a cash card attaches to its bottom edge under a DM's opening payment: a white -/// "Send Back" action, a spinner while the return is sending, or a quiet "Sent Back" footer. The +/// "Send Back" action, a spinner while the return is sending, or a quiet "Sent Back" footer under a +/// full-width hairline. The /// card's shape mask rounds its lower corners. final class SendBackRowView: UIControl { /// The action row's height, Available and Sending (spec: 44). static let actionHeight: CGFloat = 44 - /// The Sent Back footer's height. It takes the place of the card's bottom padding (spec: 36). + /// The Sent Back footer's height, under the card's bottom padding like the action row (spec: 36). static let footerHeight: CGFloat = 36 static func height(for state: ChatCashContent.SendBack) -> CGFloat { @@ -29,12 +30,17 @@ final class SendBackRowView: UIControl { /// The card's caption style, so "Sent Back" reads as one of the card's lines. private static let footerFont = UIFont.appTextHeading private static let footerColor = ChatCashCardCell.secondaryColor + /// The footer's top edge: the app's list-row divider (`Row`'s `vSeparator`), one pixel thick. + private static let dividerColor = UIColor(Color.rowSeparator) private let label = UILabel() private let spinner = UIActivityIndicatorView(style: .medium) + private let divider = UIView() private var heightConstraint: NSLayoutConstraint! /// The state last applied; read by tests. private(set) var displayedState: ChatCashContent.SendBack = .available + /// Whether the footer's top divider shows; read by tests. + var isDividerVisible: Bool { !divider.isHidden } override init(frame: CGRect) { super.init(frame: frame) @@ -43,7 +49,11 @@ final class SendBackRowView: UIControl { spinner.color = Self.actionText spinner.hidesWhenStopped = true spinner.translatesAutoresizingMaskIntoConstraints = false - [label, spinner].forEach(addSubview) + divider.backgroundColor = Self.dividerColor + divider.isUserInteractionEnabled = false + divider.isHidden = true + divider.translatesAutoresizingMaskIntoConstraints = false + [label, spinner, divider].forEach(addSubview) isAccessibilityElement = true heightConstraint = heightAnchor.constraint(equalToConstant: Self.actionHeight) @@ -55,6 +65,10 @@ final class SendBackRowView: UIControl { label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), spinner.centerXAnchor.constraint(equalTo: centerXAnchor), spinner.centerYAnchor.constraint(equalTo: centerYAnchor), + divider.topAnchor.constraint(equalTo: topAnchor), + divider.leadingAnchor.constraint(equalTo: leadingAnchor), + divider.trailingAnchor.constraint(equalTo: trailingAnchor), + divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), ]) } @@ -69,6 +83,7 @@ final class SendBackRowView: UIControl { func apply(_ state: ChatCashContent.SendBack) { displayedState = state heightConstraint.constant = Self.height(for: state) + divider.isHidden = state != .sentBack switch state { case .available: backgroundColor = Self.actionFill