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
14 changes: 5 additions & 9 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ struct ConversationScreen: View {
@State private var isJoiningChat = false
/// Memoizes the head-carded transcript — see ``TranscriptHead``.
@State private var transcriptHead = TranscriptHead()
/// Whether the invite sheet is up. A link is the only way into a group, so the empty group's
/// card hands one out (node 10127:118280).
/// Whether the invite sheet is up. A link is the only way into a group, so a member's head card
/// hands one out.
@State private var isInviting = false
@State private var messageReport: MessageReportRequest?
/// Whether `startSendCash` has been acted on, so a re-render or a return from the sheet it
Expand Down Expand Up @@ -396,15 +396,11 @@ struct ConversationScreen: View {
)
}

/// Whether the head card offers the invite link: a group the viewer belongs to that nobody else
/// has joined. Once someone else is in, the invite lives on the chat's profile instead, so it
/// stops competing with the transcript (node 10127:118280).
///
/// Counted from ``ConversationRosterSummary/memberCount`` for the reason ``titleSubtitle`` is —
/// an embedded roster is a subset.
/// Whether the head card offers the invite link: any group the viewer belongs to, however many
/// have joined. A viewer who hasn't joined has no link to hand out, so their card has no button.
private var showsGroupInvite: Bool {
guard let group = groupConversation else { return false }
return conversationController.isMember(of: group) && group.rosterSummary.memberCount <= 1
return conversationController.isMember(of: group)
}

/// The chat's entry rule as the card states it (node 10125:19164), or nil when the chat states
Expand Down
3 changes: 1 addition & 2 deletions Flipcash/Core/Screens/Profile/ChatProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import FlipcashUI
/// chat's head card or its navigation title, the way a DM's title opens the counterpart's profile.
///
/// It carries the actions a member has over a group: handing out the invite link, silencing its
/// notifications, and leaving. The head card offers the invite as well, but only while the group is
/// still empty (node 10127:118280), so once anyone else has joined this is the only way to the link.
/// notifications, and leaving. The head card at the top of the transcript offers the invite as well.
struct ChatProfileScreen: View {

let conversationID: ConversationID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public struct ChatGroupCard: Hashable, Sendable, Codable {
public var avatarID: String

/// "1 person" / "12 people" — the same wording the group's profile and its transcript's
/// invite-link card both use, so the empty-state card reads identically to either.
/// invite-link card both use, so the head card reads identically to either.
public var memberCount: String

/// The chat picture's thumbnail bytes; nil renders the BlurHash or the monogram.
Expand All @@ -34,9 +34,8 @@ public struct ChatGroupCard: Hashable, Sendable, Codable {
/// a member too (node 10125:19256).
public var requirement: String?

/// Whether the card offers "Invite People" under the requirement (node 10127:118280).
/// Set for a group the viewer is a member of that no one else has joined yet: a link is the only
/// way into a group, so an empty one needs its own way to hand that link out.
/// Whether the card offers "Invite People" under the requirement. Set for any group the viewer is
/// a member of: a link is the only way into a group, and a member is who has one to hand out.
public var showsInvite: Bool

public init(title: String, avatarID: String, memberCount: String = "", imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false) {
Expand Down
133 changes: 30 additions & 103 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,140 +32,67 @@ public final class ChatGroupCardCell: UICollectionViewCell {
}
}

/// The card body, per node 10125:19157.
/// The card body: the same card a group's invite link renders as in a transcript (node
/// 10330:19164), with CTA "Invite People" for a member, opening the invite sheet, and no button for
/// anyone else.
struct GroupCardView: View {

let card: ChatGroupCard
/// The invite card's width: a link card's width in the transcript (node 10330:19164).
/// The card's width: a link card's width in the transcript (node 10330:19164).
var inviteCardWidth: CGFloat = 290
/// Opens the chat's own profile; nil leaves the card inert.
var onTap: (() -> Void)?
/// Hands out the chat's invite link. Drawn only when the card asks for it.
/// Hands out the chat's invite link. The button is drawn only when the card asks for it.
var onInvite: (() -> Void)?

var body: some View {
Group {
if card.showsInvite, let onInvite {
// The same card a group's invite link renders as elsewhere (node 10330:19164) —
// CTA "Invite People" rather than "View", opening the invite sheet rather than the
// chat itself.
LinkGroupCardContent(
state: .resolved(.init(
title: card.title,
memberCount: card.memberCount,
avatarID: card.avatarID,
imageData: card.imageData,
blurHash: card.blurhash,
requirement: card.requirement
)),
ctaTitle: LinkGroupCardContent.Copy.invite,
onAction: onInvite,
ctaAccessibilityIdentifier: "group-card-invite",
onTapCard: onTap
)
.frame(width: inviteCardWidth)
} else {
cardBody
}
}
LinkGroupCardContent(
state: .resolved(.init(
title: card.title,
memberCount: card.memberCount,
avatarID: card.avatarID,
imageData: card.imageData,
blurHash: card.blurhash,
requirement: card.requirement
)),
ctaTitle: showsInvite ? LinkGroupCardContent.Copy.invite : nil,
onAction: onInvite ?? {},
ctaAccessibilityIdentifier: "group-card-invite",
onTapCard: onTap
)
.frame(width: inviteCardWidth)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}

/// The bordered card itself: everything the group states about itself. The invite button sits
/// outside it — it is an action the viewer takes, not a fact about the chat (node 10127:118280).
private var cardBody: some View {
VStack(spacing: 0) {
header

if let requirement = card.requirement {
Text(requirement)
.font(.default(size: 15, weight: .medium))
.foregroundStyle(Color.textMain.opacity(0.5))
.multilineTextAlignment(.center)
.padding(.top, Layout.requirementGap)
}
}
.padding(.horizontal, Layout.horizontalPadding)
.padding(.top, Layout.topPadding)
.padding(.bottom, Layout.bottomPadding)
// Min, not fixed: the app fonts scale with Dynamic Type, so the card grows past its design
// height at accessibility sizes instead of clipping.
.frame(width: Layout.width)
.frame(minHeight: Layout.minHeight)
.background {
RoundedRectangle(cornerRadius: Layout.radius, style: .continuous)
.strokeBorder(Color.white.opacity(Layout.borderOpacity))
}
.accessibilityElement(children: .combine)
}

/// The picture and the title, with the chevron that says the card opens something (node
/// 10127:116723). The requirement line below stays outside the tap target — it states a rule,
/// it does not lead anywhere.
@ViewBuilder private var header: some View {
let content = VStack(spacing: 0) {
ContactAvatarView(
id: card.avatarID,
displayName: card.title,
imageData: card.imageData,
blurhash: card.blurhash,
size: Layout.avatar
)
.overlay { Circle().strokeBorder(Color.white.opacity(Layout.borderOpacity)) }
.accessibilityHidden(true)

HStack(spacing: 6) {
Text(card.title)
.font(.appTextLarge)
.foregroundStyle(Color.textMain)
.lineLimit(1)
if onTap != nil {
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Color.textSecondary)
}
}
.padding(.top, Layout.titleGap)
}
if let onTap {
Button(action: onTap) { content }.buttonStyle(.plain)
} else {
content
}
private var showsInvite: Bool {
card.showsInvite && onInvite != nil
}

/// Node 10125:19157 — a 210×228 card, its column starting 31pt down, 13pt under the 80pt
/// picture and the requirement line centred at 177pt.
/// Internal so the group invite link card sizes itself from the same values.
/// Node 10125:19157's metrics, which the link card still sizes itself from.
enum Layout {
static let width: CGFloat = 210
static let minHeight: CGFloat = 228
static let radius: CGFloat = 12
static let borderOpacity: Double = 0.1
static let avatar: CGFloat = 80
static let topPadding: CGFloat = 31
static let bottomPadding: CGFloat = 24
static let horizontalPadding: CGFloat = 12
static let titleGap: CGFloat = 13
static let requirementGap: CGFloat = 11
}
}

#Preview("Gated / open") {
#Preview("Member / not a member") {
VStack(spacing: 12) {
GroupCardView(card: ChatGroupCard(
title: "Ballers",
avatarID: "ballers",
requirement: "Balance Requirement:\n$100.00 of $BadBoys"
), onTap: {})
GroupCardView(card: ChatGroupCard(title: "Flipcash Staff", avatarID: "staff"))
GroupCardView(card: ChatGroupCard(
title: "BadBoys",
avatarID: "badboys",
requirement: "Balance Requirement:\n$100.00 of $BadBoys",
showsInvite: true
), onTap: {}, onInvite: {})
GroupCardView(card: ChatGroupCard(
title: "Ballers",
avatarID: "ballers",
requirement: "Balance Requirement:\n$100.00 of $BadBoys"
), onTap: {})
GroupCardView(card: ChatGroupCard(title: "Flipcash Staff", avatarID: "staff"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.backgroundMain)
Expand Down
41 changes: 27 additions & 14 deletions FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ final class LinkGroupCardView: UIView {
///
/// Shared by two hosts rather than forked between them: ``LinkGroupCardView`` draws it wherever a
/// group's invite link appears in a transcript (CTA "View", into the group), and
/// ``ChatGroupCardCell``'s ``GroupCardView`` draws it at the head of a group's own transcript while
/// it is still empty (CTA "Invite People", into the invite sheet). The CTA's label and action are
/// the only things that differ between the two, so they are the only things passed in.
/// ``ChatGroupCardCell``'s ``GroupCardView`` draws it at the head of a group's own transcript (CTA
/// "Invite People" for a member, into the invite sheet; no button for anyone else). The CTA's label
/// and action are the only things that differ between the two, so they are the only things passed in.
struct LinkGroupCardContent: View {

let state: LinkCard.Group.State
Expand All @@ -163,9 +163,9 @@ struct LinkGroupCardContent: View {
bottomTrailing: GroupCardView.Layout.radius,
topTrailing: GroupCardView.Layout.radius
)
/// The button's label — "View" from a transcript link, "Invite People" from a group's own
/// empty-state head card.
var ctaTitle: String = Copy.view
/// The button's label — "View" from a transcript link, "Invite People" from a group's own head
/// card. Nil leaves the button out of a resolved card, which is then as tall as its content.
var ctaTitle: String? = Copy.view
/// Called when the button is tapped.
var onAction: () -> Void = {}
/// The button's UI-test handle, which differs by host.
Expand Down Expand Up @@ -193,7 +193,7 @@ struct LinkGroupCardContent: View {
enum Copy {
/// A group's invite link, met in a transcript.
static let view = "View"
/// A group's own empty-state head card.
/// A group's own head card, for a member.
static let invite = "Invite People"
static let unavailable = "Group Unavailable"
}
Expand All @@ -210,11 +210,15 @@ struct LinkGroupCardContent: View {
.fixedSize(horizontal: false, vertical: true)
}

Spacer(minLength: Layout.buttonGap)
if showsButton {
Spacer(minLength: Layout.buttonGap)

button
.padding(.horizontal, GroupCardView.Layout.horizontalPadding)
.padding(.bottom, GroupCardView.Layout.horizontalPadding)
button
.padding(.horizontal, GroupCardView.Layout.horizontalPadding)
.padding(.bottom, GroupCardView.Layout.horizontalPadding)
} else {
Spacer(minLength: GroupCardView.Layout.horizontalPadding)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous))
Expand Down Expand Up @@ -275,16 +279,25 @@ struct LinkGroupCardContent: View {
}
}

/// An unavailable card always says so on its disabled button; a resolved one has a button only
/// when it's given a label.
private var showsButton: Bool {
if case .resolved = state { return ctaTitle != nil }
return true
}

@ViewBuilder private var button: some View {
switch state {
case .unavailable:
Button(Copy.unavailable) {}
.buttonStyle(.filled20Compact)
.disabled(true)
case .resolved:
Button(ctaTitle, action: onAction)
.buttonStyle(.filledCompact)
.accessibilityIdentifier(ctaAccessibilityIdentifier)
if let ctaTitle {
Button(ctaTitle, action: onAction)
.buttonStyle(.filledCompact)
.accessibilityIdentifier(ctaAccessibilityIdentifier)
}
}
}

Expand Down
Loading