diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ee9bf6f92..0c43c9f10 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -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 @@ -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 diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 65305b848..d1dbeac0b 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -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 diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift index c85bfc5a6..bf1fa287f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift @@ -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. @@ -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) { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift index 56e69b710..58ba1e0e5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift @@ -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) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift index e79de4d24..2af18d750 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift @@ -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 @@ -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. @@ -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" } @@ -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)) @@ -275,6 +279,13 @@ 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: @@ -282,9 +293,11 @@ struct LinkGroupCardContent: View { .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) + } } }