diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 366400250..312fb1a3e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1691,6 +1691,12 @@ final class ConversationController { } } + /// The ID of the tip DM with `userID`, or nil when the chat hasn't started. The single "chat + /// started" rule: the visible DM. Hidden chats are excluded, and a block takes precedence anyway. + func tipDMID(withUserID userID: UserID) -> ConversationID? { + tipDM(withUserID: userID)?.id + } + private func contactName(for conversationID: ConversationID) -> String? { guard let name = contactNaming.contactDisplayName(forDMChat: conversationID), !name.isEmpty else { diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 1941c09bf..7fd7ac122 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -267,6 +267,19 @@ final class AppRouter { return StackPosition(stack: stack, depth: max(self[stack].count - 1, 0)) } + /// The topmost stack and how many destinations it holds — the position of the screen on top of it. + /// `nil` when there is no topmost stack. + func positionOfTopmost() -> StackPosition? { + guard let stack = topmostStack else { return nil } + return StackPosition(stack: stack, depth: self[stack].count) + } + + /// Whether `position` is still the topmost stack's top, so nothing has been pushed over it and no + /// other tab or sheet is in front. + func isTopmost(_ position: StackPosition) -> Bool { + positionOfTopmost() == position + } + /// Pops `position.stack` back down to `position.depth`, leaving whatever sat beneath intact. func popTo(_ position: StackPosition) { popLast(self[position.stack].count - position.depth, on: position.stack) diff --git a/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift b/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift index 7fec0ebde..79215f782 100644 --- a/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift +++ b/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift @@ -33,11 +33,15 @@ struct StartChattingSheet: View { /// opened for once the send lands and the floor no longer applies. let fee: FiatAmount + /// Called once the payment lands, before the sheet's success hold and dismissal. + var didSucceed: () -> Void = {} + var body: some View { StartChattingSheetContent( sessionContainer: sessionContainer, target: target, - fee: fee + fee: fee, + onSuccess: didSucceed ) } } @@ -51,15 +55,18 @@ private struct StartChattingSheetContent: View { @State private var didSucceed: Bool = false private let fee: FiatAmount + private let onSuccess: () -> Void // MARK: - Init - init( sessionContainer: SessionContainer, target: SendTarget, - fee: FiatAmount + fee: FiatAmount, + onSuccess: @escaping () -> Void ) { self.fee = fee + self.onSuccess = onSuccess _viewModel = State(initialValue: SendAmountViewModel( sessionContainer: sessionContainer, target: target @@ -108,6 +115,7 @@ private struct StartChattingSheetContent: View { switch await viewModel.submit(entered: amount.value) { case .success: didSucceed = true + onSuccess() case .recipientNotFound: dismiss() throw StartChattingDismissed() diff --git a/Flipcash/Core/Screens/Main/You/ProfileTutorial.swift b/Flipcash/Core/Screens/Main/You/ProfileTutorial.swift deleted file mode 100644 index 513eb13d4..000000000 --- a/Flipcash/Core/Screens/Main/You/ProfileTutorial.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// ProfileTutorial.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// A chore in the You tab's "Finish Your Profile" checklist (Figma node -/// 9544:18140). -nonisolated enum ProfileTutorialItem: TutorialItemPresentable { - case displayName(isCompleted: Bool) - case profilePicture(isCompleted: Bool) - case minimumTipAmount(isCompleted: Bool) - - var id: String { title } - - var isCompleted: Bool { - switch self { - case .displayName(let done), .profilePicture(let done), .minimumTipAmount(let done): return done - } - } - - var title: String { - switch self { - case .displayName: return "Add your display name" - case .profilePicture: return "Add a profile picture" - case .minimumTipAmount: return "Set your minimum to chat" - } - } - - var subtitle: String { - switch self { - case .displayName: return "Choose the name people see in chat" - case .profilePicture: return "Select a photo from your gallery" - case .minimumTipAmount: return "Decide what someone must send to start chatting" - } - } - - @MainActor var icon: Image { - switch self { - case .displayName: return .asset(.pencil) - case .profilePicture: return .asset(.peopleCircle) - case .minimumTipAmount: return .asset(.coins) - } - } -} - -/// Whether the You tab draws the profile checklist, and with which chores -/// checked off. -/// -/// Every chore is read straight off the profile rather than from a local -/// dismissal flag, so a name, picture or fee that disappears across a profile -/// refresh puts the card back on its own. -struct ProfileTutorialState: Equatable { - - /// Withholds the card until a profile has loaded, so a session that has not - /// fetched one yet does not flash an all-incomplete checklist. - let hasProfile: Bool - let hasDisplayName: Bool - let hasProfilePicture: Bool - let hasMinimumTipAmount: Bool - - init(hasProfile: Bool, hasDisplayName: Bool, hasProfilePicture: Bool, hasMinimumTipAmount: Bool) { - self.hasProfile = hasProfile - self.hasDisplayName = hasDisplayName - self.hasProfilePicture = hasProfilePicture - self.hasMinimumTipAmount = hasMinimumTipAmount - } - - init(profile: Profile?) { - self.init( - hasProfile: profile != nil, - hasDisplayName: profile?.displayName?.isEmpty == false, - hasProfilePicture: profile?.profilePicture != nil, - hasMinimumTipAmount: profile?.minDmChatInitFee != nil - ) - } - - var items: [ProfileTutorialItem] { - [ - .displayName(isCompleted: hasDisplayName), - .profilePicture(isCompleted: hasProfilePicture), - .minimumTipAmount(isCompleted: hasMinimumTipAmount), - ] - } - - var isComplete: Bool { hasDisplayName && hasProfilePicture && hasMinimumTipAmount } - - var isVisible: Bool { hasProfile && !isComplete } -} diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 5ebc56671..06c400a5a 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -9,11 +9,12 @@ import FlipcashCore import FlipcashUI /// The "You" tab: the user's own profile — cover, avatar, name, handle and bio — with the stats -/// card, an Edit Profile button, and the finish-your-profile checklist. +/// card. /// -/// The banner carries two controls: Share, whose menu shares the profile link or presents the -/// tip card full screen (`ProfileCardScreen`), and the gear that pushes Settings. Settings pushes -/// onto the tab's `.you` stack, so it never touches the v1 scanner's Settings sheet. +/// The navigation bar carries the gear that pushes Settings; the action row beside the +/// avatar carries Edit Profile and Share, whose sheet shares the profile link, presents the tip card +/// full screen (`ProfileCardScreen`), or copies the link. Settings pushes onto the tab's `.you` +/// stack, so it never touches the v1 scanner's Settings sheet. /// /// A profile with no display name has no card: the page then shows the add-your-name invitation /// in place of the name block and drops Share, but still renders — the gear is this account's only @@ -23,6 +24,7 @@ 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. @@ -31,18 +33,12 @@ struct YouScreen: View { /// The balance gate, raised when the claim row is tapped below the minimum. @State private var usernameDialog: DialogItem? - /// The top safe-area inset, which sizes the hand-drawn top fade. - @State private var safeAreaTop: CGFloat = 0 - - /// The page's horizontal inset. - private static let horizontalInset: CGFloat = 20 + @State private var isShowingShare = false + @State private var shareChoice: ProfileShareChoice? /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 - /// How far below the safe area the top fade runs out. Matches `WalletScreen`. - private static let topFadeLength: CGFloat = 20 - /// Bottom inset for the scrolling content. Mirrors `WalletScreen`: the iOS 26 /// tab bar sits in the safe area, so the gap is the whole inset there; the /// legacy pill floats over the content and has to be cleared on top of it, @@ -59,11 +55,11 @@ struct YouScreen: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { header - .padding(.top, 8) if displayName == nil { setupPrompt .padding(.top, 24) + .padding(.horizontal, ProfileHeaderView.inset) } else { ProfileStatsCard( minimumToChat: StartChattingFee.amount( @@ -73,46 +69,21 @@ struct YouScreen: View { ), joinedAt: profile?.joinedAt ) - .padding(.top, 24) - - // The Edit Profile slice repoints this to `.editProfile`. - Button("Edit Profile") { - router.push(.settings) - } - .buttonStyle(.filled05) - .padding(.top, 12) - .accessibilityIdentifier("you-edit-profile") - } - - if profileTutorialState.isVisible { - TutorialChecklistCard( - title: "Finish Your Profile", - items: profileTutorialState.items, - onTap: handleProfileTutorialTap - ) - .padding(.top, 32) - .accessibilityIdentifier("you-profile-tutorial-card") + .padding(.top, 19) } } - .padding(.horizontal, Self.horizontalInset) .padding(.bottom, bottomContentInset) } - // The app-wide soft edge effect is drawn from a bar's own - // background, and this tab has no navigation bar at all, so it - // covers the bottom (tab bar) but not the top — what scrolls up - // into the status bar has to be faded by hand. Same treatment as - // `WalletScreen`, the app's other bar-less tab. - .overlay(alignment: .top) { - LinearGradient( - colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: safeAreaTop + Self.topFadeLength) - .ignoresSafeArea(edges: .top) - .allowsHitTesting(false) + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) + // The blur only belongs once the banner has scrolled up under the bar. + .hidesTopScrollEdge(untilOffset: ProfileCoverBanner.height / 2) + } + // The system bar carries the gear and, on iOS 26, the soft edge the banner scrolls under. + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + settingsGear } - .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } } .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { ProfileCardScreen(previewCache: previewCache) @@ -131,6 +102,9 @@ struct YouScreen: View { if isShowing { router.isShowingProfileCard = false } } .dialog(item: $usernameDialog) + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet(subtitle: shareSubtitle, offersCard: true) { shareChoice = $0 } + } } // MARK: - Header - @@ -144,14 +118,16 @@ struct YouScreen: View { avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), avatarBlurhash: profilePicture?.thumbnailBlurhash, coverPicture: profile?.coverPicture, - customization: profile?.tipCardCustomization, - statusChip: nil, - bannerActions: { - HStack(spacing: 8) { - if displayName != nil { - shareMenu - } - settingsGear + bannerControls: { EmptyView() }, + rowActions: { + ProfileEditCapsule { + // The Edit Profile slice repoints this to `.editProfile`. + router.push(.settings) + } + .accessibilityIdentifier("you-edit-profile") + + if displayName != nil { + shareButton } }, underHandle: { @@ -160,7 +136,7 @@ struct YouScreen: View { Text("Claim your username ›") .font(.appTextSmall) .foregroundStyle(Color.textMain) - .padding(.vertical, 4) + .frame(minHeight: 22) } .buttonStyle(.plain) .accessibilityIdentifier("you-claim-username") @@ -169,12 +145,9 @@ struct YouScreen: View { ) } - private var shareMenu: some View { - Menu { - Button("Share Profile", action: shareTipCard) - Button("Show Profile Card") { router.isShowingProfileCard = true } - } label: { - bannerControl(systemName: "square.and.arrow.up") + private var shareButton: some View { + ProfileActionCircle(image: Image.asset(.shareOS)) { + isShowingShare = true } .accessibilityLabel("Share") .accessibilityIdentifier("you-share") @@ -184,23 +157,12 @@ struct YouScreen: View { Button { router.push(.settings) } label: { - bannerControl(systemName: "gearshape") + Image(systemName: "gearshape") } - .buttonStyle(.plain) .accessibilityLabel("Settings") .accessibilityIdentifier("you-settings") } - /// A glyph on a dark disc, legible over any cover picture. - private func bannerControl(systemName: String) -> some View { - Image(systemName: systemName) - .font(.system(size: 16)) - .foregroundStyle(Color.white) - .frame(width: 36, height: 36) - .background(Color.black.opacity(0.4), in: Circle()) - .contentShape(Circle()) - } - // MARK: - No name - /// Stands in for the name block until the profile has a display name: the card the @@ -298,7 +260,6 @@ struct YouScreen: View { private var profile: Profile? { sessionContainer.session.profile } private var profilePicture: ProfilePicture? { profile?.profilePicture } - private var profileTutorialState: ProfileTutorialState { .init(profile: profile) } private var displayName: String? { guard let name = profile?.displayName, !name.isEmpty else { return nil } @@ -322,6 +283,11 @@ 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 - @@ -335,6 +301,21 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } + private func handleShareChoice() { + defer { shareChoice = nil } + switch shareChoice { + case .share: shareTipCard() + case .showCard: router.isShowingProfileCard = true + 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() { @@ -346,17 +327,6 @@ struct YouScreen: View { } } - private func handleProfileTutorialTap(_ item: ProfileTutorialItem) { - switch item { - case .displayName: - router.push(.changeDisplayName) - case .profilePicture: - router.push(.changeProfilePicture) - case .minimumTipAmount: - router.push(.setMinimumTip(isSetupStep: true)) - } - } - /// Names the minimum and offers the way to meet it. The row draws no /// shortfall, so the dialog is where the squatting rule gets stated. private func presentBalanceGate(minimum: FiatAmount) { diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 240d5e337..63dec8c83 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -28,6 +28,9 @@ import FlipcashUI struct ChatMuteStatusLabel: View { let conversationID: ConversationID + /// Whether the label keeps its height while there is no mute. Off where the surrounding row + /// already fixes the height, so an empty label takes no space. + var reservesSpace = true @Environment(ConversationController.self) private var conversationController @@ -65,8 +68,10 @@ struct ChatMuteStatusLabel: View { // Holds the line's height whether or not there is a mute, so the rows below don't move // the moment one lapses or is cleared — that reads as the screen re-laying itself out // rather than one fact going away. Never drawn, never read aloud. - chipLabel("Muted") - .hidden() + if reservesSpace { + chipLabel("Muted") + .hidden() + } if let text { chipLabel(text) @@ -81,7 +86,7 @@ struct ChatMuteStatusLabel: View { .id(text) } } - .padding(.top, 3) + .padding(.top, reservesSpace ? 3 : 0) // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — // so it gets the overshoot; going away is either their unmute or a deadline passing, and // neither wants drawing attention to. Read against the new state, so each direction picks @@ -103,10 +108,6 @@ struct ChatMuteStatusLabel: View { } private func chipLabel(_ string: String) -> some View { - HStack(spacing: 4) { - Image(systemName: "bell.slash") - Text(string) - } - .chip(.tinted(.warning, on: .warningSecondary)) + ProfileStatusChip(systemImage: "bell.slash", text: string, tint: .warning, fill: .warningSecondary) } } diff --git a/Flipcash/Core/Screens/Profile/PostPaymentChat.swift b/Flipcash/Core/Screens/Profile/PostPaymentChat.swift new file mode 100644 index 000000000..e3f1f9263 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PostPaymentChat.swift @@ -0,0 +1,48 @@ +// +// PostPaymentChat.swift +// Flipcash +// + +import FlipcashCore + +/// Tracks the hand-off from paying to start a chat to opening that chat, which spans the sheet's +/// dismissal and the DM record arriving over the feed in either order. +nonisolated struct PostPaymentChat: Equatable { + + /// Set when the payment lands, consumed by the sheet's dismissal. + private(set) var didPay = false + + /// True from the sheet's dismissal until the DM's record arrives, so the push happens once and + /// only for a chat this screen just paid for. + private(set) var isAwaitingChat = false + + /// Records that the payment succeeded. + mutating func paymentSucceeded() { + didPay = true + } + + /// Consumes the success flag and returns the chat to push now, or `nil` when the record hasn't + /// landed yet (the push waits for it) or the screen is no longer in front (the push is dropped). + mutating func sheetDismissed(dmID: ConversationID?, isScreenInFront: Bool) -> ConversationID? { + guard didPay else { return nil } + didPay = false + guard isScreenInFront else { return nil } + guard let dmID else { + isAwaitingChat = true + return nil + } + return dmID + } + + /// Returns the chat to push when its record arrives while one is awaited. + mutating func dmArrived(_ dmID: ConversationID?, isScreenInFront: Bool) -> ConversationID? { + guard isAwaitingChat, let dmID else { return nil } + isAwaitingChat = false + return isScreenInFront ? dmID : nil + } + + /// Stops waiting for a record that never came. + mutating func gaveUp() { + isAwaitingChat = false + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 9fe5ec8e1..452582a80 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -7,33 +7,34 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// A profile's 3:1 cover: the picture once it has loaded, the blurhash while it does, and the -/// profile-card colour when there is no picture. -struct ProfileCoverBanner: View { +/// A profile's full-bleed cover: the picture once it has loaded, the blurhash while it does, and a +/// flat gray when there is no picture. +/// +/// Sized from the top of the screen, so the caller lets it run under the status bar. +struct ProfileCoverBanner: View { @Environment(SessionContainer.self) private var sessionContainer let userID: UserID let coverPicture: ProfilePicture? - let customization: TipCardCustomization? - @ViewBuilder let actions: () -> Actions + @ViewBuilder let controls: () -> Controls - private static var aspectRatio: CGFloat { 3 } + static var height: CGFloat { 214 } var body: some View { Color.clear - .aspectRatio(Self.aspectRatio, contentMode: .fit) + .frame(height: Self.height) + .frame(maxWidth: .infinity) .overlay { cover } - .overlay(alignment: .topTrailing) { - actions() - .padding(8) + .clipped() + .overlay(alignment: .top) { + controls() } - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .task(id: coverPicture?.blobID) { await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture) } - // `.contain` keeps the Share and Settings buttons as their own elements; a bare - // identifier here would overwrite theirs. + // `.contain` keeps the banner's controls as their own elements; a bare identifier + // here would overwrite theirs. .accessibilityElement(children: .contain) .accessibilityIdentifier("profile-cover") } @@ -43,18 +44,20 @@ struct ProfileCoverBanner: View { if let coverPicture { let data = sessionContainer.profileAvatars.data(for: .cover(userID)) if let data, let image = ContactAvatarCache.shared.image(forKey: "cover-\(coverPicture.blobID)", data: data) { - Image(uiImage: image) - .resizable() - .scaledToFill() + fill(image) } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { - Image(uiImage: preview) - .resizable() - .scaledToFill() + fill(preview) } else { - ProfileCoverFill.color(for: customization) + Color.coverPlaceholder } } else { - ProfileCoverFill.color(for: customization) + Color.coverPlaceholder } } + + private func fill(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .scaledToFill() + } } diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift b/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift deleted file mode 100644 index baf76140b..000000000 --- a/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ProfileCoverFill.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The solid colour a cover banner shows when the profile has no cover picture. -enum ProfileCoverFill { - - /// The card tone used when a profile carries no colour, or one that doesn't parse. - static let fallback = Color.backgroundRow - - /// The customization's colour, or ``fallback`` when it is absent or not a `#RRGGBB` hex. - static func color(for customization: TipCardCustomization?) -> Color { - customization.flatMap { Color(hex: $0.colorHex) } ?? fallback - } -} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 50cc54705..1373cc514 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -7,10 +7,12 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// A profile's top block: cover, avatar overlapping it, name, handle and bio. +/// A profile's top block: a full-bleed cover, the avatar overlapping it, an action row, then name, +/// handle and bio, all left-aligned on a 24pt inset. /// -/// A nil `displayName` leaves the name block out, for a profile that has not named itself yet. -struct ProfileHeaderView: View { +/// The caller lets the view run under the status bar. A nil `displayName` leaves the name block out, +/// for a profile that has not named itself yet. +struct ProfileHeaderView: View { let userID: UserID let displayName: String? @@ -19,65 +21,124 @@ struct ProfileHeaderView: View { let avatarData: Data? let avatarBlurhash: String? let coverPicture: ProfilePicture? - let customization: TipCardCustomization? - let statusChip: AnyView? - @ViewBuilder let bannerActions: () -> BannerActions + @ViewBuilder let bannerControls: () -> BannerControls + @ViewBuilder let rowActions: () -> RowActions @ViewBuilder let underHandle: () -> UnderHandle - private static var avatarSize: CGFloat { 88 } + static var inset: CGFloat { 24 } + + private static var avatarSize: CGFloat { 84 } + /// How far the avatar rises over the banner. + private static var avatarOverlap: CGFloat { 42 } var body: some View { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { ProfileCoverBanner( userID: userID, coverPicture: coverPicture, - customization: customization, - actions: bannerActions + controls: bannerControls ) - ContactAvatarView( - id: userID.uuidString, - displayName: displayName ?? "", - imageData: avatarData, - blurhash: avatarBlurhash, - size: Self.avatarSize - ) - .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 4) } - .padding(.top, -Self.avatarSize / 2) + HStack(alignment: .top, spacing: 0) { + ContactAvatarView( + id: userID.uuidString, + displayName: displayName ?? "", + imageData: avatarData, + blurhash: avatarBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .padding(.top, -Self.avatarOverlap) + + Spacer(minLength: 8) + + HStack(spacing: 12) { + rowActions() + } + .padding(.top, 20) + } + .padding(.horizontal, Self.inset) if let displayName { - VStack(spacing: 4) { + VStack(alignment: .leading, spacing: 0) { Text(displayName) .font(.appDisplaySmall) .foregroundStyle(Color.textMain) - .multilineTextAlignment(.center) .accessibilityIdentifier("profile-name") if let handle { Text(handle) - .font(.appTextMedium) + .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .accessibilityIdentifier("profile-handle") } underHandle() - } - .padding(.top, 12) - if let statusChip { - statusChip - .padding(.top, 8) - } - - if let bio, !bio.isEmpty { - Text(bio) - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .multilineTextAlignment(.center) - .padding(.top, 12) - .accessibilityIdentifier("profile-bio") + if let bio, !bio.isEmpty { + Text(bio) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 12) + .accessibilityIdentifier("profile-bio") + } } + .padding(.top, 8) + .padding(.horizontal, Self.inset) } } } } + +/// The share button in the header's action row: an icon on a flat filled circle. +struct ProfileActionCircle: View { + + let image: Image + let action: () -> Void + + var body: some View { + Button(action: action) { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .frame(width: ProfileActionButtonStyle.height, height: ProfileActionButtonStyle.height) + } + .buttonStyle(ProfileActionButtonStyle()) + } +} + +/// The header's "Edit Profile" button, a flat filled capsule beside ``ProfileActionCircle``. +struct ProfileEditCapsule: View { + + let action: () -> Void + + var body: some View { + Button(action: action) { + Text("Edit Profile") + .font(.appTextSmall) + .padding(.horizontal, 18) + } + .buttonStyle(ProfileActionButtonStyle()) + } +} + +/// The header buttons' chrome: a capsule filled at 10% of the action colour, one step above the +/// row tone of the stats card beneath them. +private struct ProfileActionButtonStyle: ButtonStyle { + + static var height: CGFloat { 38 } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(Color.textMain) + .frame(height: Self.height) + .background(Capsule().fill(Color.action.opacity(0.1))) + .contentShape(Capsule()) + .opacity(configuration.isPressed ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift new file mode 100644 index 000000000..d4c79ee3a --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -0,0 +1,53 @@ +// +// ProfileMenuItems.swift +// Flipcash +// + +/// An entry in another user's overflow menu. +nonisolated enum ProfileMenuItem: Equatable { + case mute + case report + case block + case unblock + + /// The entry's label. + var title: String { + switch self { + case .mute: return "Mute Notifications" + case .report: return "Report" + case .block: return "Block" + case .unblock: return "Unblock" + } + } + + /// The SF Symbol drawn beside the label. + var systemImage: String { + switch self { + case .mute: return "bell.slash" + case .report: return "flag" + case .block: return "nosign" + case .unblock: return "checkmark.circle" + } + } + + /// Whether the entry is drawn as a destructive action. Unblock restores something, so it isn't. + var isDestructive: Bool { + switch self { + case .report, .block: return true + case .mute, .unblock: return false + } + } +} + +/// The entries of another user's overflow menu. +nonisolated enum ProfileMenuItems { + + /// Mute needs a DM to silence and is hidden while blocked, since the DM is hidden too. + static func resolve(isBlocked: Bool, hasDM: Bool) -> [ProfileMenuItem] { + if isBlocked { return [.report, .unblock] } + var items: [ProfileMenuItem] = [] + if hasDM { items.append(.mute) } + items.append(contentsOf: [.report, .block]) + return items + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift new file mode 100644 index 000000000..452a34a66 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift @@ -0,0 +1,38 @@ +// +// ProfilePinnedAction.swift +// Flipcash +// + +import FlipcashCore + +/// The one button pinned under another user's profile, decided from who the viewer is looking at. +nonisolated enum ProfilePinnedAction: Equatable { + case none + case unblock + case openChat(ConversationID) + /// Pays `FiatAmount` to open the chat. + case startChatting(FiatAmount) + /// The fee isn't known yet, so the tap opens the amount screen instead of the confirmation sheet. + case startChattingUnpriced + + /// Blocked beats everything but the viewer's own profile: a blocked person's DM is hidden, and + /// opening or starting a chat with them would undo the block. + static func resolve(isSelf: Bool, isBlocked: Bool, dmID: ConversationID?, fee: FiatAmount?) -> ProfilePinnedAction { + if isSelf { return .none } + if isBlocked { return .unblock } + if let dmID { return .openChat(dmID) } + if let fee { return .startChatting(fee) } + return .startChattingUnpriced + } + + /// The button's label, or nil when nothing is pinned. + var title: String? { + switch self { + case .none: return nil + case .unblock: return "Unblock" + case .openChat: return "Open Chat" + case .startChatting(let fee): return "Send \(fee.formatted()) to Start Chatting" + case .startChattingUnpriced: return "Start Chatting" + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift new file mode 100644 index 000000000..f44bb7bbf --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -0,0 +1,85 @@ +// +// ProfileShareSheet.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// What a row of ``ProfileShareSheet`` asks for. +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. +/// +/// 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. +struct ProfileShareSheet: View { + + let subtitle: String? + 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: Image.asset(.shareOS), title: "Share Profile", 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) + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// Centred title with a close button, the same title bar as ``MuteChatSheet``. + private var header: some View { + ZStack { + Text("Share User Profile") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + HStack { + Spacer() + CloseButton(style: .glass) { dismiss() } + } + } + } + + /// 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) { + onChoose(choice) + dismiss() + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift index 39ee5766b..85668f10b 100644 --- a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -18,32 +18,40 @@ struct ProfileStatsCard: View { stat( title: "Minimum to Chat", value: minimumToChat?.formatted() ?? "—", + valueFont: .appTextLarge, identifier: "profile-stat-minimum" ) + .frame(width: 176) Color.rowSeparator - .frame(width: 1, height: 32) + .frame(width: 1, height: 48) stat( title: "Date Joined", - value: joinedAt?.formatted(.dateTime.month(.abbreviated).year()) ?? "—", + value: joinedAt?.formatted(.dateTime.month(.wide).year()) ?? "—", + valueFont: .appTextLarge, identifier: "profile-stat-joined" ) + .frame(maxWidth: .infinity) } - .padding(.vertical, 16) - .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(height: 84) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .padding(.horizontal, ProfileHeaderView.inset) } - private func stat(title: String, value: String, identifier: String) -> some View { - VStack(spacing: 4) { - Text(value) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) + private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View { + VStack(alignment: .leading, spacing: 6) { Text(title) - .font(.appTextSmall) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) + Text(value) + .font(valueFont) + .foregroundStyle(Color.textMain) } - .frame(maxWidth: .infinity) + .padding(.leading, 16) + .padding(.top, 16) + // Top-aligned so both labels share a baseline even though the values differ in size. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .accessibilityElement(children: .combine) .accessibilityIdentifier(identifier) } diff --git a/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift b/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift new file mode 100644 index 000000000..0ead93e56 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift @@ -0,0 +1,24 @@ +// +// ProfileStatusChip.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// A tinted capsule stating one setting of the viewer's over a profile, such as muted or blocked. +struct ProfileStatusChip: View { + + let systemImage: String + let text: String + let tint: Color + let fill: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: systemImage) + Text(text) + } + .chip(.tinted(tint, on: fill)) + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 907dbd597..e337c7390 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -6,15 +6,15 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI -/// The counterpart's Flipcash profile, carrying the actions the viewer has over the person rather -/// than over one chat: muting the DM with them, and blocking them outright. +/// Another user's Flipcash profile: the shared header and stats, a ⋯ menu carrying the actions the +/// viewer has over the person, and one pinned button that starts, opens, or restores the chat. /// /// Reached from a tip DM's title, from a face in a group transcript, and from a person link. All land -/// here because all are the same question — who is this — so Block works on the person either way. The mute row -/// silences the DM with them, so it shows only when the profile was opened from that DM. +/// here because all are the same question — who is this — so Block works on the person either way. struct UserProfileScreen: View { let userID: UserID let origin: UserProfileOrigin @@ -26,15 +26,7 @@ struct UserProfileScreen: View { var body: some View { let seed = sessionContainer.conversationController.counterpartSeed(forUserID: userID) UserProfileContent( - // Nil until a tip creates the chat server-side, and re-read on every pass, so a DM that - // appears while the screen is open brings its mute row with it. - conversationID: origin.showsMute - ? sessionContainer.conversationController.tipDM(withUserID: userID)?.id - : nil, - showsChatActions: origin.showsChatActions( - profileUserID: userID, - selfUserID: sessionContainer.conversationController.selfUserID - ), + origin: origin, model: UserProfileViewModel( userID: userID, flipClient: sessionContainer.flipClient, @@ -52,154 +44,300 @@ struct UserProfileScreen: View { } private struct UserProfileContent: View { - /// The DM to mute, or nil when there is no chat with this person yet or the profile wasn't - /// opened from it. - let conversationID: ConversationID? - /// Whether to offer Message and Send Cash — see ``UserProfileOrigin/showsChatActions(profileUserID:selfUserID:)``. - let showsChatActions: Bool @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer @Environment(RatesController.self) private var ratesController @Environment(ConversationController.self) private var conversationController + @Environment(BlocklistController.self) private var blocklistController + @Environment(ToastController.self) private var toasts + + let origin: UserProfileOrigin + @State private var isShowingShare = false + @State private var shareChoice: ProfileShareChoice? @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? - - init(conversationID: ConversationID?, showsChatActions: Bool, model: UserProfileViewModel) { - self.conversationID = conversationID - self.showsChatActions = showsChatActions + @State private var isPickingMuteDuration = false + @State private var isReporting = false + @State private var startChattingRequest: StartChattingRequest? + @State private var postPayment = PostPaymentChat() + /// Where this screen sits in its stack, recorded when it first appears. + @State private var position: AppRouter.StackPosition? + + init(origin: UserProfileOrigin, model: UserProfileViewModel) { + self.origin = origin _model = State(initialValue: model) } + /// What the start-chatting sheet is opened for, snapshotted at the tap. + private struct StartChattingRequest: Identifiable { + let target: SendTarget + let fee: FiatAmount + + var id: SendTarget { target } + } + + // MARK: - Derived state - + + private var session: Session { sessionContainer.session } + + private var isSelf: Bool { model.userID == session.userID } + + private var isBlocked: Bool { blocklistController.isBlocked(model.userID) } + + private var dmID: ConversationID? { conversationController.tipDMID(withUserID: model.userID) } + + private var fee: FiatAmount? { + let currency = ratesController.balanceCurrency + return StartChattingFee.amount( + recipientFee: model.minDmChatInitFee, + presets: session.userFlags?.tipPresets(for: currency), + currency: currency, + rates: ratesController.cachedRates + ) + } + + private var pinnedAction: ProfilePinnedAction { + ProfilePinnedAction.resolve(isSelf: isSelf, isBlocked: isBlocked, dmID: dmID, fee: fee) + } + + private var menuItems: [ProfileMenuItem] { + guard !isSelf else { return [] } + return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil) + } + + /// Whether this profile is the visible top of its stack, with its tab or sheet active. + private var isScreenInFront: Bool { + guard let position else { return false } + return router.isTopmost(position) + } + + private var sendTarget: SendTarget { + .tip(TipRecipient(userID: model.userID, displayName: model.displayName, username: model.username, origin: .tipcard)) + } + + // MARK: - Body - + var body: some View { Background(color: .backgroundMain) { - VStack(spacing: 16) { - ContactAvatarView( - id: model.userID.uuidString, - displayName: model.displayName, - imageData: model.imageData, - blurhash: model.blurhash, - size: 88 - ) - .padding(.top, 40) - + ScrollView { VStack(spacing: 0) { - // The handle and join date read as a block under the name, so - // they group tighter than the screen's other spacing (node - // 9443:8928). - VStack(spacing: 5) { - Text(model.displayName) - .font(.appDisplaySmall) - .foregroundStyle(.textMain) - - if let handle = model.handle { - Text(handle) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } - - if let joined = model.joinedText { - Text(joined) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } - } - - // Centered between the join date and the first row's text, 25pt each side; - // the row's own top inset supplies the lower 25. - HStack(spacing: 0) { - if showsChatActions { - ProfileActionButton(title: "Message") { - Image(systemName: "bubble.left.fill") - .font(.appTextLarge) - } action: { - router.push(.tipConversationForUser(model.userID)) - } - .accessibilityIdentifier("profile-message") - - // Hidden for now; the destination and `startSendCash` stay wired, so - // bringing it back is uncommenting this. - // ProfileActionButton(title: "Send Cash") { - // // The chat's collapsed Send Cash style, so € and ¥ read the same here. - // Text(ratesController.balanceCurrency.compactSymbol) - // .font(.appTextXL) - // } action: { - // router.push(.tipConversationForUserSendingCash(model.userID)) - // } - // .accessibilityIdentifier("profile-send-cash") - } - - ProfileActionButton(title: "Share") { - Image.asset(.shareOS) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - } action: { - model.share() - } - .accessibilityIdentifier("profile-share") - } - .padding(.top, 25) - - // Under the actions rather than the name, so the actions sit at the same height on - // every profile; only a DM's profile, the one with a mute, holds the chip's line. - if let conversationID { - ChatMuteStatusLabel(conversationID: conversationID) - .padding(.top, 16) - } - - VStack(spacing: 0) { - // Mute, then report, then block: the reversible and routine first, then the - // one that asks someone else to look, then the one that ends the relationship. - // Same shape as a group's profile, where leaving holds the last place. - if let conversationID { - ChatMuteRow(conversationID: conversationID, insets: rowInsets) - } - - ReportRow(target: .user(model.userID), insets: rowInsets) - - Row(insets: rowInsets) { - Image(systemName: "nosign") - .frame(minWidth: 45) - Text("Block") - .foregroundStyle(.textMain) - Spacer() - } action: { - dialogItem = blockDialog() - } - .accessibilityIdentifier("chat-block") - } - .font(.appDisplayXS) + ProfileHeaderView( + userID: model.userID, + displayName: model.displayName, + handle: model.handle, + bio: model.bio, + avatarData: model.imageData, + avatarBlurhash: model.blurhash, + coverPicture: model.coverPicture, + bannerControls: { EmptyView() }, + rowActions: { + statusBadges + shareButton + }, + underHandle: { EmptyView() } + ) + + ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) + .padding(.top, 20) } - - Spacer() + .padding(.bottom, 24) } - .padding(.horizontal, 20) + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) + // The blur only belongs once the banner has scrolled up under the bar. + .hidesTopScrollEdge(untilOffset: ProfileCoverBanner.height / 2) } - .safeAreaInset(edge: .bottom) { - if showsE2eeFooter { - E2eeFooter(kind: .dm) + // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. + .scrollEdgeBar(.bottom) { + pinnedButton + // Toasts rise above the button rather than covering it. + .toastClearance(toasts) + } + // The system bar carries back and the overflow menu, and the soft edge the banner scrolls under. + .toolbar { + if !menuItems.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + overflowMenu + } } } - .navigationTitle("") - .toolbarTitleDisplayMode(.inline) + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet(subtitle: shareSubtitle, offersCard: false) { shareChoice = $0 } + } .dialog(item: $dialogItem) + .sheet(isPresented: $isPickingMuteDuration) { + if let dmID { + MuteChatSheet(conversationID: dmID, isPresented: $isPickingMuteDuration) + } + } + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: .user(model.userID)) + } + } + .sheet(item: $startChattingRequest, onDismiss: { + if let id = postPayment.sheetDismissed(dmID: dmID, isScreenInFront: isScreenInFront) { + router.push(.tipConversation(id)) + } + }) { request in + StartChattingSheet(target: request.target, fee: request.fee) { + postPayment.paymentSucceeded() + } + } + .onChange(of: dmID) { _, id in + if let id = postPayment.dmArrived(id, isScreenInFront: isScreenInFront) { + router.push(.tipConversation(id)) + } + } + // The record normally lands within a moment; past this the profile stays put and its + // button has already flipped to Open Chat. + .task(id: postPayment.isAwaitingChat) { + guard postPayment.isAwaitingChat else { return } + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + postPayment.gaveUp() + } + .onAppear { + if position == nil { position = router.positionOfTopmost() } + } .task { await model.loadProfile() } } + // MARK: - Status badges - + + /// Blocked and muted are separate settings, so each gets its own badge. They sit in the action + /// row, whose height the share button sets, so one coming or going never moves the bio below. + private var statusBadges: some View { + HStack(spacing: 8) { + if isBlocked { + ProfileStatusChip(systemImage: "nosign", text: "Blocked", tint: .warning, fill: .warningSecondary) + .accessibilityIdentifier("profile-blocked-badge") + } + if let dmID { + ChatMuteStatusLabel(conversationID: dmID, reservesSpace: false) + } + } + } + + // MARK: - Banner controls - + + private var overflowMenu: some View { + Menu { + ForEach(menuItems, id: \.title) { item in + Button(role: item.isDestructive ? .destructive : nil) { + perform(item) + } label: { + Label(item.title, systemImage: item.systemImage) + } + } + } label: { + Image.system(.ellipsis) + .renderingMode(.template) + } + .accessibilityLabel("More") + .accessibilityIdentifier("profile-overflow") + } + + private var shareButton: some View { + ProfileActionCircle(image: Image.asset(.shareOS)) { + isShowingShare = true + } + .accessibilityLabel("Share profile") + .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: + isPickingMuteDuration = true + case .report: + isReporting = true + case .block: + dialogItem = blockDialog() + case .unblock: + Task { await model.unblock() } + } + } + + // MARK: - Pinned action - + + @ViewBuilder + private var pinnedButton: some View { + if let title = pinnedAction.title { + VStack(spacing: 8) { + if showsE2eeFooter { + E2eeFooter(kind: .dm) + } + + CodeButton(style: .filled, title: title, action: tapPinned) + .accessibilityIdentifier("profile-pinned-action") + } + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.top, 12) + .padding(.bottom, 8) + } else if showsE2eeFooter { + E2eeFooter(kind: .dm) + } + } + + private func tapPinned() { + switch pinnedAction { + case .none: + break + case .unblock: + Task { await model.unblock() } + case .openChat(let id): + if origin.returnsToExistingDM { + router.popTopmost() + } else { + router.push(.tipConversation(id)) + } + case .startChattingUnpriced: + guard passesGiveCashGate() else { return } + router.presentSendAmount(sendTarget) + case .startChatting(let fee): + guard passesGiveCashGate() else { return } + startChattingRequest = StartChattingRequest(target: sendTarget, fee: fee) + } + } + + /// Shows the blocking dialog and returns false when the viewer can't pay yet. + private func passesGiveCashGate() -> Bool { + let rate = ratesController.rateForBalanceCurrency() + guard let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat, context: .sendTips) else { + return true + } + session.dialogItem = dialog + return false + } + /// Only a DM that will actually be encrypted claims to be — see ``E2eePolicy``. private var showsE2eeFooter: Bool { - guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) else { + guard let dmID, let conversation = conversationController.conversation(withID: dmID) else { return false } return E2eePolicy.shouldEncrypt(conversation) } - private var rowInsets: EdgeInsets { - .init(top: 25, leading: 0, bottom: 25, trailing: 0) - } - private func blockDialog() -> DialogItem { .alert( title: "Block \(model.displayName)?", @@ -224,22 +362,12 @@ nonisolated enum UserProfileOrigin: Hashable { /// A `flipcash.com/` or `flipcash.com/` link opened into the app. case deeplink - /// Whether the profile offers Message and Send Cash: not from the DM they would lead back - /// into, and never on the viewer's own profile. - func showsChatActions(profileUserID: UserID, selfUserID: UserID) -> Bool { - guard profileUserID != selfUserID else { return false } + /// Whether Open Chat returns to the DM the profile was opened from rather than pushing a + /// second copy of it. + var returnsToExistingDM: Bool { switch self { - case .directMessage: return false - case .groupMember, .mention, .deeplink: return true - } - } - - /// Whether the profile offers muting the DM with this person: only from that DM, since from a - /// group the row would read as muting the group. - var showsMute: Bool { - switch self { - case .directMessage: return true - case .groupMember, .mention, .deeplink: return false + case .directMessage: return true + case .groupMember, .mention, .deeplink: return false } } @@ -273,7 +401,11 @@ final class UserProfileViewModel { private(set) var username: Username? private(set) var blurhash: String? - private(set) var joinedText: String? + private(set) var bio: String? + private(set) var coverPicture: ProfilePicture? + private(set) var customization: TipCardCustomization? + private(set) var joinedAt: Date? + private(set) var minDmChatInitFee: FiatAmount? /// What to call this person: their name when they have one, their handle /// when they don't. A handle is public and stable, so it beats the generic @@ -342,17 +474,32 @@ final class UserProfileViewModel { if let name = profile.displayName, !name.isEmpty { self.name = name } if let username = profile.username { self.username = username } if blurhash == nil { blurhash = profile.profilePicture?.thumbnailBlurhash } - if let joined = profile.joinedLine { - joinedText = joined - } + bio = profile.bio + coverPicture = profile.coverPicture + customization = profile.tipCardCustomization + if let joinedAt = profile.joinedAt { self.joinedAt = joinedAt } + minDmChatInitFee = profile.minDmChatInitFee } + /// 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: .tipcard(for: userID, username: username), displayName: name) + 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 { + try await blocklistController.unblock(userID: userID) + } catch { + session.dialogItem = .error(title: "Something Went Wrong", subtitle: "We were unable to unblock the user. Please try again") + ErrorReporting.captureError(error, reason: "Failed to unblock user") + } + } + /// Blocks the user and closes the profile — see ``UserProfileOrigin/blockReturnsToOpener``. The /// blocklist reconcile hides the conversation. func block() async { diff --git a/FlipcashTests/Chat/MentionDestinationTests.swift b/FlipcashTests/Chat/MentionDestinationTests.swift index c1bda37a4..3958f7278 100644 --- a/FlipcashTests/Chat/MentionDestinationTests.swift +++ b/FlipcashTests/Chat/MentionDestinationTests.swift @@ -59,9 +59,4 @@ import FlipcashCore @Test func aFailedLookupIsNotReadAsUnclaimed() { #expect(MentionDestination.destination(for: .failure(Offline()), counterpart: nil) == .lookupFailed) } - - @Test func aMentionOffersChatActionsButNotMute() { - #expect(UserProfileOrigin.mention.showsChatActions(profileUserID: Self.otherID, selfUserID: Self.counterpartID)) - #expect(!UserProfileOrigin.mention.showsMute) - } } diff --git a/FlipcashTests/PostPaymentChatTests.swift b/FlipcashTests/PostPaymentChatTests.swift new file mode 100644 index 000000000..7f76fd87e --- /dev/null +++ b/FlipcashTests/PostPaymentChatTests.swift @@ -0,0 +1,76 @@ +// +// PostPaymentChatTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("PostPaymentChat") +struct PostPaymentChatTests { + + private let dmID = ConversationID(data: Data(repeating: 7, count: 32)) + + @Test("A record that landed before the sheet dismissed is pushed at dismissal") + func recordFirst_pushesAtDismissal() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == dmID) + #expect(!state.isAwaitingChat) + } + + @Test("A record that lands after dismissal is pushed on arrival, once") + func dismissalFirst_pushesOnArrival() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: nil, isScreenInFront: true) == nil) + #expect(state.isAwaitingChat) + #expect(state.dmArrived(dmID, isScreenInFront: true) == dmID) + #expect(state.dmArrived(dmID, isScreenInFront: true) == nil) + } + + @Test("Dismissing without a payment pushes nothing and waits for nothing") + func noPayment_nothing() { + var state = PostPaymentChat() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("A late success after an early dismissal doesn't leak into the next dismissal") + func lateSuccess_doesNotLeak() { + var state = PostPaymentChat() + #expect(state.sheetDismissed(dmID: nil, isScreenInFront: true) == nil) + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == dmID) + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == nil) + } + + @Test("A screen no longer in front drops the push at dismissal") + func notInFront_atDismissal_drops() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: false) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("A screen no longer in front drops the push on arrival") + func notInFront_onArrival_drops() { + var state = PostPaymentChat() + state.paymentSucceeded() + _ = state.sheetDismissed(dmID: nil, isScreenInFront: true) + #expect(state.dmArrived(dmID, isScreenInFront: false) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("Giving up stops the wait") + func gaveUp_stopsWaiting() { + var state = PostPaymentChat() + state.paymentSucceeded() + _ = state.sheetDismissed(dmID: nil, isScreenInFront: true) + state.gaveUp() + #expect(state.dmArrived(dmID, isScreenInFront: true) == nil) + } +} diff --git a/FlipcashTests/ProfileCoverFillTests.swift b/FlipcashTests/ProfileCoverFillTests.swift deleted file mode 100644 index d9f416401..000000000 --- a/FlipcashTests/ProfileCoverFillTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ProfileCoverFillTests.swift -// FlipcashTests -// - -import SwiftUI -import Testing -import FlipcashCore -import FlipcashUI -@testable import Flipcash - -@MainActor -@Suite("ProfileCoverFill") -struct ProfileCoverFillTests { - - @Test("A valid hex gives the parsed colour") - func validHexIsParsed() { - let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "#19191A")) - - #expect(color == Color(hex: "#19191A")) - } - - @Test("An unparsable hex gives the default") - func invalidHexFallsBack() { - let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "not-a-colour")) - - #expect(color == ProfileCoverFill.fallback) - } - - @Test("No customization gives the default") - func nilFallsBack() { - #expect(ProfileCoverFill.color(for: nil) == ProfileCoverFill.fallback) - } -} diff --git a/FlipcashTests/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift new file mode 100644 index 000000000..f4568773d --- /dev/null +++ b/FlipcashTests/ProfileMenuItemsTests.swift @@ -0,0 +1,41 @@ +// +// ProfileMenuItemsTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("ProfileMenuItems") +struct ProfileMenuItemsTests { + + @Test("A DM offers Mute, Report and Block") + func dm_unmuted() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true) == [.mute, .report, .block]) + } + + @Test("Without a DM there is nothing to mute") + func noDM() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: false) == [.report, .block]) + } + + @Test("A blocked profile offers Report and Unblock, with no Mute", arguments: [true, false]) + func blocked(hasDM: Bool) { + #expect(ProfileMenuItems.resolve(isBlocked: true, hasDM: hasDM) == [.report, .unblock]) + } + + @Test("Mute reads as in the chat mute row") + func muteLabel() { + #expect(ProfileMenuItem.mute.title == "Mute Notifications") + #expect(ProfileMenuItem.mute.systemImage == "bell.slash") + } + + @Test("Report and Block are destructive; Unblock and Mute are not") + func destructive() { + #expect(ProfileMenuItem.report.isDestructive) + #expect(ProfileMenuItem.block.isDestructive) + #expect(!ProfileMenuItem.unblock.isDestructive) + #expect(!ProfileMenuItem.mute.isDestructive) + } +} diff --git a/FlipcashTests/ProfilePinnedActionTests.swift b/FlipcashTests/ProfilePinnedActionTests.swift new file mode 100644 index 000000000..e80725b71 --- /dev/null +++ b/FlipcashTests/ProfilePinnedActionTests.swift @@ -0,0 +1,55 @@ +// +// ProfilePinnedActionTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("ProfilePinnedAction") +struct ProfilePinnedActionTests { + + private let fee = FiatAmount(value: Decimal(string: "0.50")!, currency: .usd) + private let dmID = ConversationID(data: Data(repeating: 7, count: 32)) + + @Test("The viewer's own profile pins nothing") + func isSelf_none() { + #expect(ProfilePinnedAction.resolve(isSelf: true, isBlocked: false, dmID: dmID, fee: fee) == .none) + } + + @Test("A blocked profile pins Unblock whether or not a DM exists", arguments: [true, false]) + func blocked_unblock(hasDM: Bool) { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: true, dmID: hasDM ? dmID : nil, fee: fee) + #expect(action == .unblock) + } + + @Test("An existing DM pins Open Chat") + func dm_openChat() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: dmID, fee: fee) + #expect(action == .openChat(dmID)) + } + + @Test("No DM and a fee pins the priced start") + func noDM_fee_startChatting() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: nil, fee: fee) + #expect(action == .startChatting(fee)) + } + + @Test("No DM and no fee pins the unpriced start") + func noDM_noFee_unpriced() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: nil, fee: nil) + #expect(action == .startChattingUnpriced) + } + + @Test("Labels") + func labels() { + #expect(ProfilePinnedAction.startChatting(fee).title == "Send \(fee.formatted()) to Start Chatting") + #expect(ProfilePinnedAction.startChattingUnpriced.title == "Start Chatting") + #expect(ProfilePinnedAction.openChat(dmID).title == "Open Chat") + #expect(ProfilePinnedAction.unblock.title == "Unblock") + #expect(ProfilePinnedAction.none.title == nil) + } +} diff --git a/FlipcashTests/ProfileTutorialStateTests.swift b/FlipcashTests/ProfileTutorialStateTests.swift deleted file mode 100644 index 9866dd4ff..000000000 --- a/FlipcashTests/ProfileTutorialStateTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// ProfileTutorialStateTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -/// The checklist is derived from the profile rather than from a dismissal flag, -/// so it must stay silent until a profile has loaded and must disappear on its -/// own once every chore is done. -@Suite -@MainActor -struct ProfileTutorialStateTests { - - private static func state( - hasProfile: Bool = true, - name: Bool = true, - picture: Bool = false, - minimumTip: Bool = false - ) -> ProfileTutorialState { - ProfileTutorialState( - hasProfile: hasProfile, - hasDisplayName: name, - hasProfilePicture: picture, - hasMinimumTipAmount: minimumTip - ) - } - - @Test("The checklist is withheld until a profile has loaded") - func withheldWithoutProfile() { - #expect(!Self.state(hasProfile: false).isVisible) - } - - @Test("The checklist shows while any chore is outstanding") - func shownWhileIncomplete() { - #expect(Self.state().isVisible) - #expect(Self.state(name: false, picture: true, minimumTip: true).isVisible) - #expect(Self.state(picture: true).isVisible) - #expect(Self.state(minimumTip: true).isVisible) - } - - @Test("A finished checklist disappears without needing a dismissal") - func hiddenWhenComplete() { - let state = Self.state(picture: true, minimumTip: true) - #expect(state.isComplete) - #expect(!state.isVisible) - } - - @Test("Profile fields drive the step completion the card renders") - func itemsCarryProfileState() { - let state = Self.state(picture: true) - #expect(state.items == [ - .displayName(isCompleted: true), - .profilePicture(isCompleted: true), - .minimumTipAmount(isCompleted: false), - ]) - } - - @Test("A name-less profile gets the checklist, with naming it first and outstanding") - func namelessProfileShowsNameChore() { - let state = ProfileTutorialState(profile: Profile(displayName: nil, phone: Phone?.none, email: nil)) - #expect(state.isVisible) - #expect(state.items.first == .displayName(isCompleted: false)) - } -} diff --git a/FlipcashTests/UserProfileChatActionsTests.swift b/FlipcashTests/UserProfileChatActionsTests.swift index 2f82fa4d3..b0c52c384 100644 --- a/FlipcashTests/UserProfileChatActionsTests.swift +++ b/FlipcashTests/UserProfileChatActionsTests.swift @@ -8,45 +8,11 @@ import Testing import FlipcashCore @testable import Flipcash -/// A person's profile offers Message and Send Cash into the DM with them, except where that would -/// lead back to where the viewer came from or point at themselves. +/// How a person's profile behaves by the surface it was opened from. @MainActor @Suite("User profile chat actions") struct UserProfileChatActionsTests { - // MARK: - Visibility - - - @Test("A group member's profile offers the chat actions") - func groupMember_showsChatActions() { - #expect(UserProfileOrigin.groupMember.showsChatActions(profileUserID: UUID(), selfUserID: UUID())) - } - - @Test("A profile opened from the DM with that person hides the chat actions") - func directMessage_hidesChatActions() { - #expect(!UserProfileOrigin.directMessage.showsChatActions(profileUserID: UUID(), selfUserID: UUID())) - } - - @Test( - "The viewer's own profile hides the chat actions from either origin", - arguments: [UserProfileOrigin.groupMember, .directMessage, .mention, .deeplink] - ) - func ownProfile_hidesChatActions(_ origin: UserProfileOrigin) { - let me = UUID() - #expect(!origin.showsChatActions(profileUserID: me, selfUserID: me)) - } - - @Test("A profile opened from a person link offers the chat actions but not mute") - func deeplink_showsChatActionsButNotMute() { - #expect(UserProfileOrigin.deeplink.showsChatActions(profileUserID: UUID(), selfUserID: UUID())) - #expect(!UserProfileOrigin.deeplink.showsMute) - } - - @Test("Only a profile opened from the DM offers muting it") - func mute_showsOnlyFromDirectMessage() { - #expect(UserProfileOrigin.directMessage.showsMute) - #expect(!UserProfileOrigin.groupMember.showsMute) - } - @Test("Blocking from a link-opened profile returns to where the link was followed") func deeplink_blockReturnsToOpener() { #expect(UserProfileOrigin.deeplink.blockReturnsToOpener) @@ -68,6 +34,14 @@ struct UserProfileChatActionsTests { #expect(!UserProfileOrigin.mention.arrivesFetched) } + @Test("Open Chat returns to the DM only when the profile was opened from that DM") + func openChat_returnsToDMOnlyFromDirectMessage() { + #expect(UserProfileOrigin.directMessage.returnsToExistingDM) + #expect(!UserProfileOrigin.groupMember.returnsToExistingDM) + #expect(!UserProfileOrigin.mention.returnsToExistingDM) + #expect(!UserProfileOrigin.deeplink.returnsToExistingDM) + } + // MARK: - Destinations - @Test("The origin is part of the profile destination's identity but not its log keys") diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift index a753c172b..e43fbb126 100644 --- a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift @@ -20,6 +20,13 @@ extension View { modifier(SoftScrollEdge(edges: edges)) } + /// Hides the top scroll edge effect until the scroll view has moved past `offset`, for a screen + /// whose content starts with a full-bleed image under the bar: at rest the effect would only + /// darken that image and leave a visible band where it stops. No-op below iOS 26. + public func hidesTopScrollEdge(untilOffset offset: CGFloat) -> some View { + modifier(HidesTopScrollEdge(offset: offset)) + } + /// Pins `bar` to `edge` so the scroll content below it runs underneath. On iOS 26+ the bar joins /// the scroll edge effect; before that it's a plain safe-area inset. @ViewBuilder @@ -44,3 +51,22 @@ private struct SoftScrollEdge: ViewModifier { } } } + +private struct HidesTopScrollEdge: ViewModifier { + + let offset: CGFloat + + @State private var isPastOffset = false + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .onScrollGeometryChange(for: Bool.self) { $0.contentOffset.y > offset } action: { _, isPast in + isPastOffset = isPast + } + .scrollEdgeEffectHidden(!isPastOffset, for: .top) + } else { + content + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index dfa0be46f..c84c40f24 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -39,6 +39,8 @@ extension ShapeStyle where Self == Color { public static var backgroundSecondary: Color { Color("backgroundSecondary") } public static var backgroundRow: Color { Color.white.opacity(0.05) } public static var rowSeparator: Color { Color.white.opacity(0.1) } + /// `#323234` — the flat fill behind a profile's cover banner when the profile has no cover picture. + public static var coverPlaceholder: Color { Color(r: 50, g: 50, b: 52) } public static var unreadIndicator: Color { Color(r: 10, g: 132, b: 255) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index 84c654034..ed6076ead 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -151,7 +151,9 @@ private struct FloatingToastTransition: ViewModifier { content.transition(tabBar == nil ? Self.fromScreenEdge : Self.fromTabBar) } - private static let fromScreenEdge: AnyTransition = .move(edge: .bottom).combined(with: .opacity) + /// Rises a fixed distance rather than from the screen edge: a toast raised over a pinned bar would + /// otherwise travel the bar's height too. 56pt is the trip a toast 12pt off the edge always made. + private static let fromScreenEdge: AnyTransition = .offset(y: 56).combined(with: .opacity) /// Starts tucked behind the bar, which draws over the screen's content, and swells up out of it. private static let fromTabBar: AnyTransition = .modifier( diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index ca0597213..86779f72b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -56,6 +56,10 @@ public final class ToastController { didSet { if isCovered { current = nil } } } + /// Extra room the toast keeps above the host's bottom edge, for a bar a screen pins there. Set + /// through ``View/toastClearance(_:)``. + public var bottomClearance: CGFloat = 0 + /// A controller with no toast showing. public init() {} @@ -108,6 +112,36 @@ extension View { } } +extension View { + /// Raises `controller`'s toast above this view, measured, for as long as it is on screen. Put it on + /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. + public func toastClearance(_ controller: ToastController) -> some View { + modifier(ToastClearance(controller: controller)) + } +} + +private struct ToastClearance: ViewModifier { + let controller: ToastController + @State private var height: CGFloat = 0 + @State private var isVisible = false + + func body(content: Content) -> some View { + content + .onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in + height = newValue + if isVisible { controller.bottomClearance = newValue } + } + .onAppear { + isVisible = true + controller.bottomClearance = height + } + .onDisappear { + isVisible = false + controller.bottomClearance = 0 + } + } +} + private struct ToastHost: View { let controller: ToastController let bottomPadding: CGFloat @@ -129,7 +163,7 @@ private struct ToastHost: View { onDismiss: toast.action == nil ? nil : { controller.dismiss(toast.id) } ) .allowsHitTesting(toast.action != nil) - .padding(.bottom, bottomPadding) + .padding(.bottom, bottomPadding + controller.bottomClearance) .floatingToastTransition() .id(toast.slot) .task(id: toast.id) { diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index c4ecac6c1..8395481fb 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -77,7 +77,8 @@ final class BlockUnblockSmokeTests: BaseUITestCase { titleButton.tap() // MARK: Block from the profile screen. - waitAndTap(app.buttons["Block"], timeout: 30, "Expected the Block row on the profile screen") + waitAndTap(app.buttons["profile-overflow"], timeout: 30, "Expected the overflow menu on the profile screen") + waitAndTap(app.buttons["Block"], timeout: 10, "Expected Block in the profile's overflow menu") let blockDialog = app.otherElements .matching(NSPredicate(format: "identifier BEGINSWITH %@", "Block ")) .firstMatch