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 {