From b0be331696e08b99af3c70bd4a14eab86df83179 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:15:07 -0400 Subject: [PATCH] refactor(share): share profiles and groups through one sheet Sharing a group went through two sheets: Share opened a list with "Share on Flipcash" and Copy Link, and "Share on Flipcash" opened the invite sheet, which offered Share and Copy Invite Link again. Share on both profiles now opens that invite sheet directly, renamed ShareToChatsSheet and taking a group or a person. A person's version is titled Share Profile, copies their profile link and sends it with Send, and leaves your DM with them out of Recent Chats. The You tab keeps its own sheet, since it also offers Show Profile Card. It now has the "Add Money With" layout: left-aligned title, no username line, row cards in Add Money's fill, and a Dismiss button. Copy Link is gone from it, and Share Profile opens ShareToChatsSheet with your tip-code preview. ShareToChatsSheet is presented as a full-screen cover from every entry point, including in-chat Invite People. --- .../Conversation/ConversationScreen.swift | 4 +- .../Screens/Main/Tips/ChatActionRow.swift | 4 +- ...iteSheet.swift => ShareToChatsSheet.swift} | 115 +++++++++++++----- .../Core/Screens/Main/You/YouScreen.swift | 39 +++--- .../Screens/Profile/ChatProfileScreen.swift | 30 +---- .../Core/Screens/Profile/MuteChatSheet.swift | 4 +- .../Screens/Profile/ProfileShareSheet.swift | 72 +++-------- .../Screens/Profile/UserProfileScreen.swift | 33 ++--- 8 files changed, 139 insertions(+), 162 deletions(-) rename Flipcash/Core/Screens/Main/Tips/New Group/{GroupInviteSheet.swift => ShareToChatsSheet.swift} (82%) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index d99e6ad3f..867e42e65 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -733,9 +733,9 @@ struct ConversationScreen: View { .sheet(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } - .sheet(isPresented: $isInviting) { + .fullScreenCover(isPresented: $isInviting) { if let conversationID { - GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) { chatID in + ShareToChatsSheet(subject: .group(conversationID), isPresented: $isInviting) { chatID in router.push(.tipConversation(chatID)) } } diff --git a/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift b/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift index a20f38517..8bbcffe4d 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift @@ -15,6 +15,8 @@ struct ChatActionRow: View { let icon: Image let title: String + /// The card's fill: ``Color/backgroundRow`` over the main background, lighter on a lifted sheet. + var background: Color = .backgroundRow let accessibilityIdentifier: String let action: () -> Void @@ -36,7 +38,7 @@ struct ChatActionRow: View { } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + .background(background, in: .rect(cornerRadius: Metrics.buttonRadius)) .contentShape(.rect(cornerRadius: Metrics.buttonRadius)) } .buttonStyle(.plain) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift similarity index 82% rename from Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift rename to Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift index c085c12a8..08f8a8d34 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift @@ -1,5 +1,5 @@ // -// GroupInviteSheet.swift +// ShareToChatsSheet.swift // Flipcash // @@ -8,14 +8,22 @@ import UIKit import FlipcashCore import FlipcashUI -/// "Invite People" (nodes 10330:19387, 10330:19549, 10329:12104): hand out a group's invite link by -/// Share or Copy, or post it straight into recent chats with an optional message. -/// -/// A group is not discoverable — the link built from its id is the only way in — so every path here -/// sends the same URL. -struct GroupInviteSheet: View { +/// What ``ShareToChatsSheet`` hands out. +enum ShareToChatsSubject { + /// A group's invite link. A group is not discoverable — the link built from its id is the only + /// way in. + case group(ConversationID) + /// A person's public profile link, the one their own You tab shares. `preview` is the tip-code + /// image for the system share sheet's card, when one is rendered. `directChatID` is the viewer's + /// DM with them, left out of the picker so nobody is sent their own profile. + case user(url: URL, displayName: String?, preview: TipCodePreview? = nil, directChatID: ConversationID?) +} + +/// "Invite People" (nodes 10330:19387, 10330:19549, 10329:12104): hand out a link by Share or Copy, +/// or post it straight into recent chats with an optional message. +struct ShareToChatsSheet: View { - let conversationID: ConversationID + let subject: ShareToChatsSubject @Binding var isPresented: Bool @@ -37,11 +45,52 @@ struct GroupInviteSheet: View { @State private var headerHeight: CGFloat = 0 @FocusState private var isMessageFocused: Bool - init(conversationID: ConversationID, isPresented: Binding, onInvited: @escaping (ConversationID) -> Void) { - self.conversationID = conversationID + init(subject: ShareToChatsSubject, isPresented: Binding, onInvited: @escaping (ConversationID) -> Void) { + self.subject = subject self._isPresented = isPresented self.onInvited = onInvited - self._model = State(initialValue: InvitePeopleViewModel(url: .groupChatInvite(for: conversationID))) + let url: URL = switch subject { + case .group(let id): .groupChatInvite(for: id) + case .user(let url, _, _, _): url + } + self._model = State(initialValue: InvitePeopleViewModel(url: url)) + } + + /// The group being invited to, nil when sharing a person. + private var groupID: ConversationID? { + switch subject { + case .group(let id): id + case .user: nil + } + } + + /// The chat the picker leaves out: the group itself, or the DM with the person shared. + private var excludedChatID: ConversationID? { + switch subject { + case .group(let id): id + case .user(_, _, _, let directChatID): directChatID + } + } + + private var sheetTitle: String { + switch subject { + case .group: "Invite People" + case .user: "Share Profile" + } + } + + private var copyTitle: String { + switch subject { + case .group: "Copy Invite Link" + case .user: "Copy Link" + } + } + + private var sendTitle: String { + switch subject { + case .group: "Invite" + case .user: "Send" + } } /// The group's own title, which names it in the shared message. @@ -55,13 +104,13 @@ struct GroupInviteSheet: View { } private var conversation: Conversation? { - conversationController.conversation(withID: conversationID) + groupID.flatMap(conversationController.conversation(withID:)) } - /// The chats the Chats tab lists, newest activity first: 1:1 chats and joined groups, less the - /// group being invited to. Search and people outside recent chats are out of scope. + /// The chats the Chats tab lists, newest activity first: 1:1 chats and joined groups, less + /// ``excludedChatID``. Search and people outside recent chats are out of scope. private var recentChats: [Conversation] { - conversationController.chatListConversations.filter { $0.id != conversationID } + conversationController.chatListConversations.filter { $0.id != excludedChatID } } /// The group's picture for the share sheet's own preview card. @@ -70,11 +119,21 @@ struct GroupInviteSheet: View { /// every render of a sheet that shows it nowhere. Nil while the avatar hasn't loaded or the /// group has none — the card then carries the name alone. private func icon() -> UIImage? { - sessionContainer.profileAvatars - .data(for: .chat(conversationID)) + groupID + .flatMap { sessionContainer.profileAvatars.data(for: .chat($0)) } .flatMap(UIImage.init(data:)) } + /// What Share hands the system share sheet: the group's preview card, or the person's. + private func shareItem() -> UIActivityItemSource { + switch subject { + case .group: + GroupInviteShareItem(url: model.url, title: title, icon: icon()) + case .user(let url, let displayName, let preview, _): + TipCodeShareItem.profile(url: url, displayName: displayName, preview: preview) + } + } + var body: some View { Background(color: .backgroundMain) { VStack(spacing: 0) { @@ -136,21 +195,21 @@ struct GroupInviteSheet: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .presentationDetents([.large]) .presentationBackground(Color.backgroundMain) .interactiveDismissDisabled(model.isSending) .task { // Idempotent: the store returns without a round trip once the bytes are in memory or // on disk. Loading here rather than relying on the presenting screen keeps the preview // card's icon independent of which screen opened the sheet. - await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) + guard let groupID else { return } + await sessionContainer.profileAvatars.load(.chat(groupID), picture: conversation?.picture) } } /// The sheet's own title bar: a centred title and a glass close button on the right. private var header: some View { ZStack { - Text("Invite People") + Text(sheetTitle) .font(.appBarButton) .foregroundStyle(Color.textMain) @@ -173,14 +232,12 @@ struct GroupInviteSheet: View { .resizable() .scaledToFit() } action: { - Analytics.groupInviteShared(method: .share) - ShareSheet.present( - activityItem: GroupInviteShareItem(url: model.url, title: title, icon: icon()) - ) { _ in } + if groupID != nil { Analytics.groupInviteShared(method: .share) } + ShareSheet.present(activityItem: shareItem()) { _ in } } .accessibilityIdentifier("group-invite-send") - ShareTile(title: didCopy ? "Copied" : "Copy Invite Link") { + ShareTile(title: didCopy ? "Copied" : copyTitle) { if didCopy { Image.system(.circleCheck) .resizable() @@ -230,7 +287,7 @@ struct GroupInviteSheet: View { Button(action: invite) { ZStack { - Text("Invite").opacity(model.isSending ? 0 : 1) + Text(sendTitle).opacity(model.isSending ? 0 : 1) if model.isSending { ProgressView().tint(Color.textAction) } @@ -272,7 +329,7 @@ struct GroupInviteSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(minHeight: BarMetrics.fieldMinHeight) - Text("Invite") + Text(sendTitle) .font(.default(size: 17, weight: .medium)) .padding(.horizontal, 14) .frame(height: BarMetrics.fieldMinHeight) @@ -301,7 +358,7 @@ struct GroupInviteSheet: View { } private func copy() { - Analytics.groupInviteShared(method: .theCopy) + if groupID != nil { Analytics.groupInviteShared(method: .theCopy) } UIPasteboard.general.string = model.url.absoluteString withAnimation(.easeInOut(duration: 0.15)) { didCopy = true @@ -338,7 +395,7 @@ private struct ShareTile: View { .padding(.vertical, 16) .padding(.horizontal, 8) .frame(maxWidth: .infinity, minHeight: 95) - .background(Color.white.opacity(0.05)) + .background(Color.backgroundRow) .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index a61610813..414b3e0e1 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -24,7 +24,6 @@ struct YouScreen: View { @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router @Environment(RatesController.self) private var ratesController - @Environment(ToastController.self) private var toasts /// Warms the share-sheet preview image ahead of the share tap so it never /// lands on the tap; keyed by user. @@ -35,6 +34,7 @@ struct YouScreen: View { @State private var isShowingShare = false @State private var shareChoice: ProfileShareChoice? + @State private var isSharingToChats = false @State private var isShowingCardDownload = false /// The gap the page keeps between its last row and the tab bar. @@ -116,7 +116,20 @@ struct YouScreen: View { } .dialog(item: $usernameDialog) .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { - ProfileShareSheet(subtitle: shareSubtitle, offersCard: true) { shareChoice = $0 } + ProfileShareSheet { shareChoice = $0 } + } + .fullScreenCover(isPresented: $isSharingToChats) { + ShareToChatsSheet( + subject: .user( + url: url, + displayName: displayName, + preview: previewCache.preview(for: sessionContainer.session.userID), + directChatID: nil + ), + isPresented: $isSharingToChats + ) { chatID in + router.push(.tipConversation(chatID)) + } } } @@ -312,24 +325,10 @@ struct YouScreen: View { TipCode.Payload(userID: sessionContainer.session.userID).codeData() } - private var shareSubtitle: String? { - guard let displayName else { return nil } - return [displayName, username?.handle].compactMap { $0 }.joined(separator: " · ") - } - private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } // MARK: - Actions - - private func shareTipCard() { - let item = TipCodeShareItem.profile( - url: url, - displayName: displayName, - preview: previewCache.preview(for: sessionContainer.session.userID) - ) - ShareSheet.present(activityItem: item) { _ in } - } - /// Shows the card; it runs its own scan-style reveal, and the toolbar glyph fades to Download alongside. private func showProfileCard() { withAnimation(ProfileCardScreen.fade) { router.isShowingProfileCard = true } @@ -338,18 +337,12 @@ struct YouScreen: View { private func handleShareChoice() { defer { shareChoice = nil } switch shareChoice { - case .share: shareTipCard() + case .share: isSharingToChats = true case .showCard: showProfileCard() - case .copyLink: copyLink() case nil: break } } - private func copyLink() { - UIPasteboard.general.string = url.absoluteString - toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) - } - /// Opens the claim screen once the balance clears the minimum, and the /// balance gate until then. private func claimUsername() { diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 31f8982a9..3cc25e79f 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -28,8 +28,6 @@ struct ChatProfileScreen: View { @Environment(ToastController.self) private var toasts @State private var isInviting = false - @State private var isShowingShare = false - @State private var shareChoice: ProfileShareChoice? @State private var isPickingMuteDuration = false @State private var isReporting = false @State private var isShowingE2ee = false @@ -171,16 +169,8 @@ struct ChatProfileScreen: View { } } .dialog(item: $dialogItem) - .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { - ProfileShareSheet( - title: "Share Group", - subtitle: title, - shareRow: ("Share on Flipcash", Image(systemName: "paperplane")), - offersCard: false - ) { shareChoice = $0 } - } - .sheet(isPresented: $isInviting) { - GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) { chatID in + .fullScreenCover(isPresented: $isInviting) { + ShareToChatsSheet(subject: .group(conversationID), isPresented: $isInviting) { chatID in router.push(.tipConversation(chatID)) } } @@ -430,26 +420,12 @@ struct ChatProfileScreen: View { private var shareButton: some View { ProfileActionCircle(image: Image.asset(.shareOS)) { - isShowingShare = true + openInvite() } .accessibilityLabel("Share group") .accessibilityIdentifier("chat-profile-share") } - /// Runs once the share sheet is gone, so a following sheet doesn't present over it. - private func handleShareChoice() { - defer { shareChoice = nil } - switch shareChoice { - case .share: - openInvite() - case .copyLink: - UIPasteboard.general.string = URL.groupChatInvite(for: conversationID).absoluteString - toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) - case .showCard, nil: - break - } - } - private func openInvite() { Analytics.groupInviteSheetOpened( source: .profile, diff --git a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift index 7df0ab016..8eaa0717b 100644 --- a/Flipcash/Core/Screens/Profile/MuteChatSheet.swift +++ b/Flipcash/Core/Screens/Profile/MuteChatSheet.swift @@ -11,7 +11,7 @@ import FlipcashCore import FlipcashUI /// How long to silence a chat's notifications for. Drawn as the chats flow's action rows, the way -/// ``GroupInviteSheet`` draws its two. +/// ``ShareToChatsSheet`` draws its two. /// /// The durations are the client's choice; the contract admits only two mute shapes — until a /// timestamp, or forever — so every timed option is the same shape with a different offset. @@ -112,7 +112,7 @@ struct MuteChatSheet: View { } /// The sheet's own title bar — ``PartialSheet`` sizes itself to its content, so there is no - /// navigation bar to hang a title on. Mirrors ``GroupInviteSheet/header``. + /// navigation bar to hang a title on. Mirrors ``ShareToChatsSheet/header``. private var header: some View { ZStack { Text("Mute Notifications") diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift index 29df310d6..922da9a4a 100644 --- a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -10,77 +10,45 @@ import FlipcashUI enum ProfileShareChoice { case share case showCard - case copyLink } -/// The bottom sheet behind a profile's Share button: share the link, show the profile card, or copy the link. +/// The bottom sheet behind the You tab's Share button: share the profile, or show the profile card. +/// Drawn as the "Add Money With" sheet is. Other people's profiles and groups share through +/// ``ShareToChatsSheet`` directly. /// /// A row dismisses the sheet and reports its choice through `onChoose`; the presenter acts on it from -/// the sheet's `onDismiss`, so a share sheet or cover never presents over a sheet that is leaving. -/// `offersCard` false drops the card row, for a profile that has no card to show. +/// the sheet's `onDismiss`, so a following sheet or cover never presents over a sheet that is leaving. struct ProfileShareSheet: View { - var title: String = "Share User Profile" - let subtitle: String? - /// The first row, which reports ``ProfileShareChoice/share``. - var shareRow: (title: String, icon: Image) = ("Share Profile", Image.asset(.shareOS)) - let offersCard: Bool let onChoose: (ProfileShareChoice) -> Void @Environment(\.dismiss) private var dismiss var body: some View { - PartialSheet(background: .backgroundMain) { - VStack(spacing: 20) { - header - - if let subtitle { - Text(subtitle) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - .multilineTextAlignment(.center) - // Same floor as ``MuteChatSheet``'s caption: `PartialSheet` can propose less - // height than the text needs while it corrects to its measured size. - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) - } - - VStack(spacing: 12) { - row(icon: shareRow.icon, title: shareRow.title, identifier: "profile-share-row", choice: .share) - if offersCard { - row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) - } - row(icon: Image.asset(.chainLink), title: "Copy Link", identifier: "profile-copy-link-row", choice: .copyLink) + PartialSheet { + VStack(spacing: 12) { + HStack { + Text("Share User Profile") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + Spacer() } - } - .padding(.horizontal, 16) - .padding(.top, 20) - .padding(.bottom, bottomPadding) - } - } + .padding(.vertical, 20) - /// Centred title with a close button, the same title bar as ``MuteChatSheet``. - private var header: some View { - ZStack { - Text(title) - .font(.appBarButton) - .foregroundStyle(Color.textMain) + row(icon: Image.asset(.shareOS), title: "Share Profile", identifier: "profile-share-row", choice: .share) + row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) - HStack { - Spacer() - CloseButton(style: .glass) { dismiss() } + Button("Dismiss") { dismiss() } + .buttonStyle(.subtle) } + .padding(.horizontal) + .padding(.top) } } - /// 16 under the last row, less what the sheet already holds back for the home indicator. - private var bottomPadding: CGFloat { - let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - return max(0, 16 - reserved) - } - private func row(icon: Image, title: String, identifier: String, choice: ProfileShareChoice) -> some View { - ChatActionRow(icon: icon, title: title, accessibilityIdentifier: identifier) { + // The Add Money rows' fill, which reads on this sheet's lighter background. + ChatActionRow(icon: icon, title: title, background: Color.white.opacity(0.1), accessibilityIdentifier: identifier) { onChoose(choice) dismiss() } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index be7c46770..b0085370d 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -55,7 +55,6 @@ private struct UserProfileContent: View { let origin: UserProfileOrigin @State private var isShowingShare = false - @State private var shareChoice: ProfileShareChoice? @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? @State private var isPickingMuteDuration = false @@ -175,8 +174,13 @@ private struct UserProfileContent: View { } } } - .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { - ProfileShareSheet(subtitle: shareSubtitle, offersCard: false) { shareChoice = $0 } + .fullScreenCover(isPresented: $isShowingShare) { + ShareToChatsSheet( + subject: .user(url: model.shareURL, displayName: model.name, directChatID: dmID), + isPresented: $isShowingShare + ) { chatID in + router.push(.tipConversation(chatID)) + } } .dialog(item: $dialogItem) .sheet(isPresented: $isPickingMuteDuration) { @@ -270,23 +274,6 @@ private struct UserProfileContent: View { .accessibilityIdentifier("profile-share") } - private func handleShareChoice() { - defer { shareChoice = nil } - switch shareChoice { - case .share: - model.share() - case .copyLink: - UIPasteboard.general.string = model.shareURL.absoluteString - toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) - case .showCard, nil: - break - } - } - - private var shareSubtitle: String? { - [model.displayName, model.handle].compactMap { $0 }.joined(separator: " · ") - } - private func perform(_ item: ProfileMenuItem) { switch item { case .mute: @@ -535,12 +522,6 @@ final class UserProfileViewModel { /// This person's public link, the one their own You tab shares. var shareURL: URL { .tipcard(for: userID, username: username) } - /// Opens the share sheet on this person's public link, the one their own You tab shares. - func share() { - let item = TipCodeShareItem.profile(url: shareURL, displayName: name) - ShareSheet.present(activityItem: item) { _ in } - } - /// Lifts the block; the badge and pinned button follow the blocklist. A failure says so. func unblock() async { do {