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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
72 changes: 69 additions & 3 deletions FlipcashTests/Chat/ChatCashCardCellTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
55 changes: 0 additions & 55 deletions FlipcashTests/Chat/ChatSendBackStageTests.swift

This file was deleted.

10 changes: 8 additions & 2 deletions FlipcashTests/Chat/SendBackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,17 @@ struct SendBackCaptionTests {
#expect(line?.counterpart == "to Alex Morgan")
}

@Test("9: the receiver of a return reads '<Name> sent back' / 'to you'")
@Test("9: the receiver of a return reads '<Name> 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")
Expand Down
35 changes: 31 additions & 4 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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
Expand Down
39 changes: 0 additions & 39 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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),
])
}

Expand All @@ -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
Expand Down
Loading