diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index e7805c7ff..0dd1bf057 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -136,6 +136,87 @@ struct ChatCashCardCellTests { #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .sending), id: "3", state: .sentBack)) } + // MARK: - Lines that arrive after the card is drawn (send-back spec, orchestrator #117) + + private func card(id: String = "5", counterpart: String?, iconURL: URL? = nil, sendBack: ChatCashContent.SendBack? = nil) -> ChatMessage { + ChatMessage(id: id, content: .cash(ChatCashContent( + amount: "$5.00", token: "Cash", iconURL: iconURL, + lines: counterpart.map { .init(caption: "Noah Parker sent", counterpart: $0) }, + sendBack: sendBack + )), sender: .other) + } + + private static let icon = URL(fileURLWithPath: "/nonexistent/icon.png") + + private func counterpartLabel(in cell: ChatCashCardCell) throws -> UILabel { + try #require(labels(in: cell.contentView).first { $0.text?.hasPrefix("from") == true || $0.text?.hasPrefix("to ") == true }) + } + + private func coinIcon(in cell: ChatCashCardCell) throws -> UIImageView { + func images(in view: UIView) -> [UIImageView] { + view.subviews.flatMap { ($0 as? UIImageView).map { [$0] } ?? images(in: $0) } + } + return try #require(images(in: cell.contentView).first { $0.layer.cornerRadius == 10 }) + } + + private func fades(_ view: UIView) -> Bool { + view.layer.animation(forKey: ChatCashCardCell.lateArrivalKey) != nil + } + + @Test("A counterpart line known at first draw draws without animating") + func counterpart_firstDraw_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + #expect(!fades(try counterpartLabel(in: cell))) + } + + @Test("A counterpart line that arrives on a drawn card fades in", arguments: [nil, "from Someone"]) + func counterpart_lateArrival_animates(before: String?) throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: before), maxWidth: 250) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + #expect(fades(try counterpartLabel(in: cell))) + } + + @Test("A counterpart line that arrives on a drawn card grows its height") + func counterpart_lateArrival_growsCard() { + let cell = ChatCashCardCell(frame: .zero) + let before = measure(card(counterpart: nil), in: cell) + #expect(measure(card(counterpart: "from Noah Parker"), in: cell) > before) + } + + @Test("A token icon known at first draw draws without animating") + func icon_firstDraw_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker", iconURL: Self.icon), maxWidth: 250) + #expect(!fades(try coinIcon(in: cell))) + } + + @Test("A token icon that arrives on a drawn card fades in") + func icon_lateArrival_animates() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + cell.configure(with: card(counterpart: "from Noah Parker", iconURL: Self.icon), maxWidth: 250) + #expect(fades(try coinIcon(in: cell))) + } + + @Test("A redraw with nothing new, or a recycled cell, animates nothing") + func unchangedOrReused_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + let full = card(counterpart: "from Noah Parker", iconURL: Self.icon) + cell.configure(with: full, maxWidth: 250) + cell.configure(with: full, maxWidth: 250) + #expect(!fades(try counterpartLabel(in: cell))) + #expect(!fades(try coinIcon(in: cell))) + + let reused = ChatCashCardCell(frame: .zero) + reused.configure(with: card(id: "6", counterpart: nil), maxWidth: 250) + reused.prepareForReuse() + reused.configure(with: full, maxWidth: 250) + #expect(!fades(try counterpartLabel(in: reused))) + #expect(!fades(try coinIcon(in: reused))) + } + @Test("A cell recycled from an offered card to a plain one drops the row") func reusedCell_sendBackToPlain_measuresFresh() { let cell = ChatCashCardCell(frame: .zero) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 75bb13352..543109fab 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -48,9 +48,17 @@ 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?)? + /// What this cell last drew, so a line that arrives on a card already on screen (the Send Back + /// row, the counterpart, the token icon) can animate in while one known at first draw does not. + private var drawn: Drawn? + private struct Drawn { + let id: String + let sendBack: ChatCashContent.SendBack? + let counterpart: String? + let iconURL: URL? + } + /// The layer animation key a late-arriving line's fade runs under; read by tests. + static let lateArrivalKey = "cashCardLateArrival" /// 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. @@ -189,7 +197,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.cancelDownloadTask() coinIcon.image = nil reactionRow.prepareForReuse() - drawnSendBack = nil + drawn = nil } /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection @@ -208,12 +216,21 @@ public final class ChatCashCardCell: ChatColumnCell { // The total sits tight under the amount, the closest pair of lines on the card. centerStack.setCustomSpacing(total == nil ? Self.tokenSpacing : 0, after: amountLabel) + // A line that arrives on a card already drawn fades in while the transcript's reflow spring + // grows the card to fit it (send-back spec); one known at first draw appears as is. + let previous = drawn?.id == message.id ? drawn : nil + drawn = Drawn(id: message.id, sendBack: cash.sendBack, counterpart: cash.lines?.counterpart, iconURL: cash.iconURL) + tokenLabel.text = cash.token coinIcon.isHidden = cash.iconURL == nil coinIcon.kf.setImage(with: cash.iconURL) + if let previous, previous.iconURL == nil, cash.iconURL != nil { Self.fadeIn(coinIcon) } counterpartLabel.text = cash.lines?.counterpart counterpartLabel.isHidden = cash.lines == nil - applySendBack(cash.sendBack, id: message.id) + if let previous, let counterpart = cash.lines?.counterpart, previous.counterpart != counterpart { + Self.fadeIn(counterpartLabel) + } + applySendBack(cash.sendBack, previous: previous) card.apply( fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), @@ -235,6 +252,17 @@ public final class ChatCashCardCell: ChatColumnCell { card.alpha = isHighlighted ? 0.6 : 1 } + /// Fades `view`'s new content in over the reflow spring's duration, under `lateArrivalKey`. + private static func fadeIn(_ view: UIView) { + // An opacity animation, not a CATransition: Core Animation files a transition under its own + // `transition` key, whatever key it is given. + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 0 + fade.toValue = 1 + fade.duration = ChatMotion.reflow.duration + view.layer.add(fade, forKey: lateArrivalKey) + } + /// 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. @@ -253,9 +281,10 @@ public final class ChatCashCardCell: ChatColumnCell { /// 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) + private func applySendBack(_ state: ChatCashContent.SendBack?, previous: Drawn?) { + let arrivesLate = previous.map { previous in + Self.sendBackArrivesLate(drawn: (previous.id, previous.sendBack), id: previous.id, state: state) + } ?? false didAnimateSendBackIn = arrivesLate sendBackRow.isHidden = state == nil if let state { sendBackRow.apply(state) }